Skip to content

Latest commit

 

History

History
176 lines (128 loc) · 6.32 KB

File metadata and controls

176 lines (128 loc) · 6.32 KB

LinkCode PTY sidecar protocol

Private stdin/stdout IPC protocol for linkcode-pty. For build, development, and benchmark notes, see README.md.

The Rust implementation is in src/proto.rs; the TypeScript counterpart is in apps/daemon/src/pty/codec.ts. Stderr is reserved for diagnostics and must not carry protocol data.

Transport

Each frame has a 5-byte header followed by a body:

[u32 little-endian total][u8 type][body]

total is the byte length of the type byte plus the body:

total = 1 + body.length

Rules:

  • total must be non-zero.
  • total must be at most 16 MiB.
  • If frame decoding fails, the stream is considered corrupt. The receiver should stop using that sidecar instance instead of trying to resynchronize mid-stream.
  • Multi-byte integers are little-endian.

Frame types

Daemon to sidecar:

Type Name Body
0x01 OPEN JSON OpenParams
0x02 INPUT binary DataFrame
0x03 RESIZE JSON ResizeParams
0x04 CLOSE JSON CloseParams
0x05 CREDIT JSON CreditParams

Sidecar to daemon:

Type Name Body
0x81 OPENED JSON Opened
0x82 OUTPUT binary DataFrame
0x83 EXIT JSON Exit
0x84 ERROR JSON Error

Unknown frame types are ignored by the current sidecar. A malformed OPEN frame fails only that terminal — the sidecar replies ERROR for its terminalId and keeps running every other terminal. If the frame is malformed badly enough that terminalId itself cannot be recovered, the sidecar cannot reply ERROR at all: it logs to stderr, and the daemon's pending open for that frame is reclaimed by its own open timeout (10s) rather than hanging forever. This should not happen in practice, since the daemon always encodes terminalId itself.

Data frame body

INPUT and OUTPUT carry raw bytes so terminal traffic never pays JSON/base64 overhead:

[u16 little-endian terminal_id_length][terminal_id UTF-8 bytes][raw bytes]

Rules:

  • terminal_id_length must be at least 1 and at most 65535.
  • terminal_id is generated by the daemon and must be unique among pending and live terminals.
  • raw bytes are not interpreted by the sidecar.
  • The daemon decodes OUTPUT bytes into UTF-8 text with a streaming TextDecoder per terminal.

JSON control bodies

JSON object keys are camelCase. Unknown JSON fields may be ignored by receivers. The examples below are illustrative; exact shell paths are platform-specific.

OPEN

{
  "terminalId": "term-mj7w5r-1",
  "cols": 80,
  "rows": 24,
  "cmd": "/bin/zsh",
  "args": [],
  "cwd": "/Users/alice/project",
  "env": {}
}

Fields:

Field Type Required Notes
terminalId string yes Non-empty UTF-8, max 65535 bytes, unique while pending/live.
cols number yes Terminal columns, encoded as Rust u16.
rows number yes Terminal rows, encoded as Rust u16.
cmd string yes Executable already resolved by the daemon.
args string[] no Defaults to [].
cwd string or null no Working directory. If omitted/null, the sidecar does not set one.
env object no Additional environment variables. Defaults to {}.
credit number or null no Initial read-credit budget in bytes (Rust u64). Omitted/null means unthrottled.

Behavior:

  • On success, the sidecar sends OPENED before any OUTPUT for that terminal.
  • If the terminal id already exists, the sidecar sends ERROR.
  • If spawning fails, the sidecar sends ERROR.
  • If the OPEN JSON itself is malformed, the sidecar logs to stderr and replies ERROR for that terminalId alone; every other terminal keeps running. If terminalId cannot be recovered from the malformed body, no ERROR is sent — see Frame types.

RESIZE

{
  "terminalId": "term-mj7w5r-1",
  "cols": 120,
  "rows": 32
}

RESIZE is best effort. Missing terminals and platform resize failures do not currently produce an ERROR.

CLOSE

{
  "terminalId": "term-mj7w5r-1"
}

CLOSE requests termination. Cleanup is reported by the later EXIT frame from the reader thread when the PTY reaches EOF and the child has been reaped. CLOSE also lifts the terminal's read-credit gate so a parked reader can drain to that EOF; the dying process is unthrottled from this point on.

CREDIT

{
  "terminalId": "term-mj7w5r-1",
  "bytes": 65536
}

Grants bytes of additional PTY read budget to one terminal (flow control). A terminal opened with a credit field reads at most its remaining budget from the PTY and parks once the budget hits zero — the kernel PTY buffer then fills and the shell's writes block, propagating backpressure into a flooding process. Terminals opened without credit ignore grants and stay unthrottled (compatibility with pre-credit daemons). CREDIT is best effort: an unknown or already-exited terminalId is ignored. Sidecar shutdown, like CLOSE, releases every gate so parked readers can reach EOF.

OPENED

{
  "terminalId": "term-mj7w5r-1",
  "pid": 12345
}

pid is 0 when the platform or PTY backend does not expose a process id.

EXIT

{
  "terminalId": "term-mj7w5r-1",
  "exitCode": 0
}

exitCode is null when waiting on the child failed or when the daemon synthesizes an exit because the sidecar died.

ERROR

{
  "terminalId": "term-mj7w5r-1",
  "message": "terminal id already exists"
}

ERROR is currently used for OPEN failures. Best-effort operations such as INPUT, RESIZE, and CLOSE do not currently report per-command errors.

Lifecycle expectations

  • One sidecar process can multiplex many terminals.
  • The daemon lazily starts the sidecar on first open.
  • If the sidecar exits or its stream becomes corrupt, the daemon rejects pending opens and emits exit(null) for live terminals.
  • After a sidecar restart, the daemon resets its frame decoder so partial bytes from the old process cannot corrupt the new stream.
  • When daemon stdin closes, the sidecar kills all known terminals and exits.