How Messages Are Encoded in the Length‑Prefixed JSON vsock Protocol: v, t, and id Fields Explained

Each message in the vphone‑cli vsock protocol consists of a 4‑byte big‑endian length header followed by a UTF‑8 JSON payload that must include the fields v (protocol version), t (message type), and optionally id (request identifier) to enable asynchronous request‑response matching.

The Lakr233/vphone-cli project implements a lightweight host‑guest communication channel over virtio‑socket (vsock) using a length‑prefixed JSON framing scheme. Understanding how this protocol encodes the mandatory v, t, and id fields is essential for extending the toolset or debugging communication failures between the macOS host and the virtualized guest.

Binary Framing Structure

The protocol uses a simple yet strict binary layout for every message transmitted over the vsock file descriptor. Each outbound or inbound message follows this exact structure:


[uint32 big-endian payload length][UTF-8 JSON payload]

  • The first 4 bytes encode the length of the JSON payload as an unsigned 32‑bit integer in big‑endian order.
  • The next length bytes contain a UTF‑8 encoded JSON object.

The implementation in VPhoneControl.swift enforces a maximum payload size of 4 MiB (4 × 1024 × 1024 bytes) to prevent memory exhaustion attacks.

Required JSON Fields

Every JSON payload must carry three standard keys to ensure protocol versioning and message routing.

Protocol Version (v)

The v field specifies the protocol version. Currently, all messages must set "v": 1. The guest daemon vphoned rejects any message whose version does not match the expected value, ensuring both parties operate on compatible protocol semantics.

Message Type (t)

The t field defines the operation being requested. Valid values include "hello", "hid", "file_get", "file_put", and "update". This type determines how the receiver parses the remaining operation‑specific fields (e.g., "page" and "usage" for HID events, or "path" for file operations).

Request Identifier (id)

The id field is optional but critical for asynchronous operations. When present, the host generates a unique hexadecimal string (e.g., "a1b2c3") and includes it in the request dictionary. The guest echoes the same id in its response, allowing the host to match incoming messages to pending PendingRequest objects in the startReadLoop handler.

Implementation in VPhoneControl.swift

The host‑side implementation lives in sources/vphone-cli/VPhoneControl.swift, which handles the complete lifecycle of message encoding and decoding.

Writing Messages

The writeMessage(fd:dict:) method constructs the length‑prefixed frame. It serializes the dictionary to JSON, calculates the big‑endian length header, and writes both parts atomically using Darwin.write:

private func writeMessage(fd: Int32, dict: [String: Any]) -> Bool {
    guard let json = try? JSONSerialization.data(withJSONObject: dict) else {
        return false
    }
    let length = UInt32(json.count)
    var header = length.bigEndian
    // Write 4-byte length header
    let headerWritten = Darwin.write(fd, &header, 4) == 4
    // Write JSON payload
    let payloadWritten = Darwin.write(fd, [UInt8](json), json.count) == json.count
    return headerWritten && payloadWritten
}

Reading Messages

The readMessage(fd:) method reverses the process. It first reads exactly 4 bytes to decode the length, validates that the size is within bounds (greater than 0 and less than 4 MiB), then reads the full payload and deserializes it:

private static func readMessage(fd: Int32) -> [String: Any]? {
    var header: UInt32 = 0
    guard readFully(fd: fd, &header, 4) else { return nil }
    
    let length = Int(UInt32(bigEndian: header))
    guard length > 0 && length < 4 * 1024 * 1024 else { return nil }
    
    let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: length)
    defer { buffer.deallocate() }
    
    guard readFully(fd: fd, buffer, length) else { return nil }
    let data = Data(bytes: buffer, count: length)
    return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}

Request-Response Cycle

During a normal request‑response cycle, the sendRequest method automatically injects the v and id fields into the caller’s dictionary before transmission:

  1. The host generates a unique id and adds v: 1, t: <operation>, and id: <hex> to the request.
  2. writeMessage(fd:dict:) transmits the length‑prefixed JSON over the vsock.
  3. The startReadLoop receives the response, extracts the id, looks up the corresponding PendingRequest, and delivers the result.

Binary Payload Handling

For operations requiring raw binary data (such as file transfers or camera frames), the protocol extends the basic JSON frame. After the initial "hello" handshake, if the guest responds with "need_update": true, the host sends an "update" message containing the standard v, t, and id fields in the JSON header. Immediately following this header, the raw binary payload is streamed directly without additional length prefixes.

Summary

  • The length‑prefixed JSON vsock protocol uses a 4‑byte big‑endian length header followed by UTF‑8 JSON payload.
  • Every JSON message must include v (version 1), t (message type), and optionally id (request tracking).
  • The writeMessage function in VPhoneControl.swift atomically writes the length header and JSON payload using Darwin.write.
  • The readMessage function validates the 4‑byte length, enforces a 4 MiB limit, and deserializes the payload.
  • Binary data transfers follow the JSON header directly without additional framing.

Frequently Asked Questions

What is the maximum payload size allowed in the length‑prefixed JSON vsock protocol?

The implementation enforces a hard limit of 4 MiB (4 × 1024 × 1024 bytes) for the JSON payload. The readMessage function validates this boundary after decoding the 4‑byte length header to prevent memory exhaustion.

What happens if the protocol version v does not match?

The guest daemon vphoned validates the v field and rejects messages with non‑matching versions. Both host and guest must agree on version 1; otherwise, the connection will drop or return an error, ensuring protocol compatibility.

Is the id field required for every message?

No, the id field is optional for one‑way notifications but required for request‑response pairs. When present, the guest must echo the same identifier in its reply, enabling the host’s startReadLoop to correlate asynchronous responses with their original PendingRequest objects.

How does the protocol handle large binary transfers like camera frames?

For large binary transfers, the protocol sends a standard JSON header containing v, t, and id fields using the usual length‑prefixed framing, then streams the raw binary payload immediately afterward without additional length headers. This hybrid approach keeps metadata structured while allowing efficient binary streaming.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →