Repository navigation
Shell protocol (vsock port 1027)
dockma shell runs a command in the VM, on a pseudo-terminal or on plain
pipes, over a small framed protocol. This document specifies that protocol as
the code implements it. The two implementations are:
-
Client (host, Swift):
ShellFrameandShellFrameDecoderinSources/DockmaCore/Shell/ShellProtocol.swift, driven byShellSessioninSources/DockmaCore/Shell/ShellSession.swift. -
Server (guest, Go):
handleShellandreadInputinvm/agent/shell.go.
Change both together, and update this document with them.
dockma shell ──unix──► ~/.dockma/shell.sock ──daemon──► vsock port 1027 ──► dockma-agent ──► command
- The daemon serves
shell.sockwithVsockSocketProxy: it connects to the agent's port 1027 for each accepted connection and copies bytes both ways. It does not parse frames. - The agent accepts connections only from the host (CID 2,
isHostPeer). - One connection carries one session. The agent closes the connection when the session ends.
- The command runs as root, in
/root, with exactly this environment:PATH=/usr/sbin:/usr/bin:/sbin:/bin,HOME=/root,USER=root,SHELL=/bin/sh,TERM=<term from start>.
Every message in both directions is one frame:
0 1 5
+--------+-----------------+-------------------------+
| type | length | payload (length bytes) |
| u8 | u32 big-endian | |
+--------+-----------------+-------------------------+
- All integers in headers and payloads are big-endian.
-
lengthis the payload size only; the header is always 5 bytes. - The maximum payload is 1 MiB (
1 << 20). The client's decoder throws on a larger length; the agent treats it as a read error, which ends the session as a disconnect does (see Exit and teardown).
Client → agent:
| Type | Name | Payload | Notes |
|---|---|---|---|
| 1 | start |
JSON object, see below | Must be the first frame. |
| 2 | stdin |
raw bytes | Input for the command. With flow control, limited by credit. |
| 3 | stdinEOF |
empty | Pipes: close the command's stdin once queued input is written. TTY: the pty stays open. With flow control, later stdin is dropped. |
| 4 | resize |
rows u16, cols u16 (4 bytes) |
TTY: sets the pty's window size. Pipes: ignored. |
| 5 | signal |
signal number u8 (1 byte) |
Sent to the command's process group. Only 1, 2 and 15 are acted on. |
Agent → client:
| Type | Name | Payload | Notes |
|---|---|---|---|
| 16 | stdout |
raw bytes | All output in TTY mode; standard output with pipes. |
| 17 | stderr |
raw bytes | Standard error with pipes; also the "command not found" message. |
| 18 | exit |
exit code i32 (4 bytes) |
Last frame of a session that ran. |
| 19 | error |
UTF-8 message | The session is over; no exit follows. |
| 20 | started |
empty | The command is running. Sent once, before any output. |
| 21 | window |
stdin credit u32 in bytes (4 bytes) |
Only when start asked for flow control. |
Types 0, 6–15 and 22–255 are unassigned.
The payload is a JSON object. Its keys are frozen: they are the Codable
property names of ShellStart and the JSON tags of the Go shellStart.
| Key | Type | Meaning | If empty or zero |
|---|---|---|---|
argv |
array of string | Command and arguments. argv[0] is looked up in PATH. |
["/bin/sh", "-l"], a login shell |
tty |
bool | Run on a pty (true) or on pipes (false). |
pipes |
rows |
u16 | Initial pty height. | 24 |
cols |
u16 | Initial pty width. | 80 |
term |
string | Value of TERM. |
xterm-256color |
flow |
bool | Ask for stdin flow control (see below). | off |
The Swift client always sends flow: true. Key order does not matter; agents
ignore unknown keys (Go's encoding/json default).
A session that runs:
client agent
│ start {argv, tty, rows, cols, term, flow} │
│──────────────────────────────────────────►│ spawn the command
│ window 262144 │ (only if flow)
│◄──────────────────────────────────────────│
│ started │
│◄──────────────────────────────────────────│
│ stdin / resize / signal / stdinEOF │
│──────────────────────────────────────────►│
│ stdout / stderr / window │
│◄──────────────────────────────────────────│
│ exit <code> │ command exited, output drained
│◄──────────────────────────────────────────│ agent closes the connection
window and started are written before the agent starts reading client
frames, so the client never has to send anything after start before it knows
whether it has credit.
Sessions that do not run:
| Situation | Agent sends |
|---|---|
The first frame is not start
|
error "protocol error: first frame must be start" |
The start payload is not valid JSON for shellStart
|
error "protocol error: <decoder error>" |
argv[0] does not exist |
stderr "<argv[0]>: command not found\n", then exit 127 (no started) |
| Any other spawn failure (pipes, pty, exec) |
error "cannot run <argv[0]>: <error>" or the pipe error |
| The connection fails before a full first frame, or the first frame is over 1 MiB | nothing; the agent closes the connection |
TTY (tty: true):
- The command runs on a pty of the requested size, in its own session with the
pty as controlling terminal (creack/pty's
SetsidandSetctty). - All output, including the command's stderr, arrives as
stdout. -
resizeframes set the window size. -
stdinEOFnever closes the pty. Without flow control the agent ignores it. With flow control, input queued before it is still written, butstdinsent after it is dropped and its credit is not returned. The Swift client sendsstdinEOFonly when its own stdin has ended, so this doesn't arise. - The Swift client puts the local terminal in raw mode, so ^C, ^Z and ^
travel asstdinbytes and the pty's line discipline turns them into signals. It does not sendsignalframes in this mode; SIGTERM or SIGHUP to the client restores the terminal and exits it with 128 + the signal number, which the agent sees as a disconnect.
Pipes (tty: false):
- The command runs in a new session (
setsid) with separate pipes for stdin, stdout and stderr, forwarded asstdoutandstderrframes. -
stdinEOFcloses the command's stdin after any queued input is written. -
resizeis ignored. - The Swift client forwards SIGINT, SIGTERM and SIGHUP it receives as
signalframes instead of acting on them.
Without flow control, the agent writes each stdin payload to the command
synchronously, on the same loop that reads client frames. A command that does
not read its stdin then blocks that loop, and signal and resize frames
behind the unread stdin are never applied: a ^C can't reach a stuck command.
With flow: true:
- The agent grants 262144 bytes (256 KiB) of credit up front, in a
windowframe sent beforestarted. - The client sends at most its current credit in
stdinpayloads, and decrements its credit by each payload's length. At zero credit it waits. - The agent queues
stdinpayloads and writes them to the command on a separate goroutine. After writing a payload it returns that payload's length as awindowframe. Credit comes back even when the write failed because the command closed its stdin; that input is dropped, as it would be without flow control. - The frame-reading loop never blocks on the command, so
signal,resizeandstdinEOFare applied as soon as they arrive.stdinEOFstill takes effect only after the queued input ahead of it;stdinsent afterstdinEOFis dropped without returning credit, in both modes.
A client that sends beyond its credit is still bounded: the agent's queue blocks the reading loop once it would exceed 256 KiB, which brings back the non-flow behaviour for that client only.
Detecting an older agent. Agents that predate flow control ignore the
flow key and never send window. The client therefore decides from the
first of the two frames it sees:
-
windowbeforestarted: credit-based flow control. -
startedalone: unlimited; send stdin freely.
The Swift client holds back stdin until one of the two arrives.
- The payload is one byte, the signal number. The agent sends it to the
command's process group (
kill(-pid, sig)), so children of a shell get it too. - The agent acts only on SIGHUP (1), SIGINT (2) and SIGTERM (15). Every other number, and a payload that is not exactly one byte, is ignored.
- Numbers are Linux's. They coincide with macOS's for these three, which is
why the Swift client can send its own constants (
SIGINTetc.) unchanged.
Exit code. The exit payload is a signed 32-bit integer:
- the command's exit status;
- 128 + n when the command was killed by signal n (so SIGTERM gives 143);
- 255 when the agent has no process state for the command;
- 127 when the command does not exist (see above).
Draining. After the command exits, the agent waits up to 200 ms for its output copiers to finish, because background processes can keep the pty or pipes open indefinitely. Then it closes its ends of the output:
- Pipes: closing unblocks the copiers, so all output read so far is sent
before
exit. - TTY: the agent does not wait for the pty copier (closing can't interrupt a
blocked read on the pty). With
sleep 600 &thenexit, the session ends after the 200 ms drain timeout, not after 600 s.
The agent sends nothing after exit; output still arriving is dropped. It then
closes the connection.
Client disconnect. If the connection closes or fails (including an oversized frame) while the command is still running, the agent sends SIGHUP to the process group, waits up to 2 s for the command to exit, then sends SIGKILL.
VM stop or restart. dockma stop or dockma restart closes an open
session's connection without an exit frame. The client prints "connection to
the VM closed (VM stopped or restarted?)" and exits with 255. (Checked by hand
with dockma restart.)
Guest reboot. When the guest reboots in place (reboot -f, or a kernel
panic with panic=5), the agent and the command are gone, but the client is
not told: it receives no exit and its connection stays open. The session
ends only when the client next writes a frame (in TTY mode, the next
keypress); then the connection closes and the client prints "connection to
the VM closed (VM stopped or restarted?)" and exits with 255. A client that
has nothing left to send, such as a pipe-mode session after stdinEOF, waits
forever. This is a known bug, not a protocol rule. (Checked by hand with
dockma shell -- reboot -f and a second open session.)
Client results. dockma shell exits with the code from exit. It prints
a message and exits with 255 when it receives error, when a frame fails to
decode, or when the connection closes without exit. In the last case the
message depends on whether started was seen: without it, the agent most
likely predates dockma shell (the guest image is older than the CLI).
-
Agent leniency. The agent logs and ignores unknown client frame types,
and ignores
resizeandsignalframes with a wrong payload size. -
Client strictness. The Swift decoder throws on unknown agent frame types
and on
resize,signal,exitandwindowframes with a wrong payload size, which ends the session with 255. -
Adding features. Because of that asymmetry, a new client → agent frame
is safe to add (old agents ignore it), but a new agent → client frame must be
opt-in: the client asks for it with a new
startkey, which old agents ignore.flowandwindowwere added this way. Payload changes to existing frames are incompatible. -
Incompatible changes bump
GuestImage.protocolVersionandvm/agent/protocoltogether, sodockma startrefuses a mismatched guest.
These bytes are asserted by both test suites
(Tests/DockmaCoreTests/ShellProtocolTests.swift and
vm/agent/shell_test.go):
| Frame | Hex |
|---|---|
resize 24 rows × 80 cols |
04 00000004 0018 0050 |
window 262144 |
15 00000004 00040000 |
More examples, derived from the rules above:
| Frame | Hex |
|---|---|
stdinEOF |
03 00000000 |
signal SIGINT |
05 00000001 02 |
started |
14 00000000 |
exit 130 |
12 00000004 00000082 |
start with the 82-byte payload {"argv":["uname","-a"],"tty":false,"rows":24,"cols":80,"term":"xterm","flow":true}
|
01 00000052 followed by the payload |
| Concern | Swift | Go |
|---|---|---|
| Frame types, encoding, decoding |
ShellFrame, ShellFrameDecoder (ShellProtocol.swift) |
constants, readFrame, encodeFrame (shell.go) |
start payload |
ShellStart |
shellStart |
| Session driver |
ShellSession.run, receive, pumpStdin
|
handleShell, readInput
|
| Flow control | ShellSession.takeCredit |
stdinQueue, writeStdin, stdinWindow
|
| Teardown timings | — |
outputDrainTimeout, hangupGrace
|
| Daemon proxy | VsockSocketProxy |
— |
| Tests | Tests/DockmaCoreTests/ShellProtocolTests.swift |
vm/agent/shell_test.go |