Control protocol v2 - "frame everything" (PROPOSAL)¶
Status: IMPLEMENTED in nether (2026-07-04). proto_version is now 2; every command/ack
reply is framed, with negative trailer exits for control-plane errors and a command-intake
guard against forged frames. Reference client tools/nether-ctl.c speaks both v1 and v2.
Proven live on HVF (scripts/proto_v2.py). Consumers still to migrate: the swerver
control_client, its console codec, and the supervisor - each reads proto_version
from the __info__ handshake, so they can adopt the uniform framed loop (and drop the settle
timer) at their own pace; a v1 client still works against a v2 server (section 4). This doc is
the design + migration reference; the live contract is in control-protocol.md.
Decisions taken: single -1 control-error code (section 3.4), and Option A - all ERR/OK
framed uniformly (section 6).
1. Why¶
Today (v1) a reply is one of two line shapes for the "command/ack" commands:
- Framed:
<body> 0x1e <exit> \n-__info__/__stats__/__help__and shell commands. - Bare:
ERR <reason>\norOK <reason>\nwith no0x1e-__shutdown__,__snapshot__,__put__,__get__, and every control-plane rejection (unknown command, read-only observer, agent-not-connected, too-many-clients, ...).
The two are indistinguishable at the start of the reply: a bare ERR read-only observer
can come back for a command a client expected to be framed, and a shell command's output
can itself begin ERR/OK. v1 resolves this with a timing heuristic (the reference
client and every consumer wait ~500 ms - SETTLE_MS - to see whether a 0x1e trailer
follows). That heuristic is the protocol's one real wart:
- It can truncate a real reply. A framed shell reply whose body begins
OK/ERRand whose0x1etrailer lags >500 ms (a slow/streaming producer, or a deliberately malformed guest - the stated threat model) is mis-delivered as a bare reply: the true multi-line output and exit code are silently discarded. (Found in a host-side codec review of the client settle path.) - It costs latency.
__snapshot__(socket stays open) forces every consumer to wait a full idle/settle window (~500 ms-2 s) before resolving, even though theOKline arrived immediately. - Every consumer must re-implement it (a settle timer + a bare-line guard + a per-command "is this framed?" table). It is the single most error-prone part of writing a client.
2. Goal¶
Make every line-oriented reply framed, so a consumer reads any command/ack reply with
one loop and no timer: read until a raw 0x1e, then the exit code, then \n. Delete the
settle heuristic entirely. Keep the streamed/binary replies (logs, screen, framebuffer) as
they are - they are read in a mode that already knows their shape.
3. The change¶
3.1 Framed becomes universal for command/ack replies¶
In v2, these replies all end with the 0x1e <exit> \n trailer:
| Command(s) | v1 shape | v2 shape |
|---|---|---|
__info__, __stats__, __help__ |
framed (exit 0) | framed (exit 0) - unchanged |
shell command (<other>) |
framed (guest exit) | framed (guest exit) - unchanged |
__shutdown__ |
bare OK ...\n |
framed OK ...\n 0x1e 0 \n |
__snapshot__, __put__, __get__ |
bare OK/ERR |
framed (exit 0 on OK, <0 on ERR) |
any control-plane ERR ... (unknown command, observer, agent-not-ready, too-many-clients) |
bare ERR ...\n |
framed ERR ...\n 0x1e <0 \n |
So in v2, is_framed(cmd) is true for everything except the streamed commands -
i.e. true for __info__/__stats__/__help__/__shutdown__/__snapshot__/__put__/
__get__ and all shell commands; false only for the streamed set below.
3.2 Streamed replies stay self-delimiting (unchanged)¶
__events__, __cmdlog__, __netlog__, __screen__, __screendiff__ (self-delimiting
text) and __frame__, __framediff__ (length-implicit binary) keep their v1 shape: the
consumer reads them to an idle gap / EOF in a mode it chose for that command. Framing them
with a raw 0x1e delimiter is wrong - a PPM/tile blob contains 0x1e bytes, and logs stream
without an in-band terminator. These commands are never subject to the bare/framed
ambiguity because a client only reads them in streamed mode.
An error for a streamed command (__netlog__ with net off, __screen__ with render off,
__frame__ with no gpu, ...) is a framed ERR line (see 3.4) - short, and distinguishable
from a real payload by its ERR prefix, which no success payload begins with (a PPM starts
P6, a log with its header line). A streamed-mode consumer that reads its payload to
idle/EOF and finds instead a single ERR ...0x1e<exit>\n frame treats it as the error.
3.3 Body escaping is unchanged¶
The body-before-the-trailer is still delimiter-escaped so it can never contain a raw 0x1e
(guest command output: 0x1e/0x1f -> 0x1f,(b^0x40), per tools/agent.c). Host-generated
ERR/OK bodies are trusted ASCII and contain no 0x1e/0x1f, so they need no escaping;
the framer still guarantees the invariant "a raw 0x1e appears only in the trailer." The
R2b unforgeability property is preserved untouched.
3.4 Exit-code semantics: negatives are control-plane errors¶
A byte-range exit (0..255) already means "a report (0), or a shell command's real exit."
v2 reserves negative trailer codes for control-plane errors, which POSIX exits can
never be - so a consumer separates "the guest command ran and exited N" from "nether rejected
or failed the command" with a single sign test, no string matching, no collision:
| Trailer exit | Meaning | Body |
|---|---|---|
0 |
success: a report, an OK ack, or a shell command that exited 0 |
report / OK ... / command stdout |
1..255 |
a shell command's real exit code | command stdout+stderr |
-1 |
a control-plane error (generic) | ERR <reason> |
-1 is the single control-error code for v2.0; the reason string carries the detail (as
today). If consumers later want to branch without parsing the string, distinct negatives can
be assigned (-2 unknown command, -3 observer-denied, -4 agent-not-ready, ...) as an
additive v2.x refinement - a consumer that only sign-tests keeps working.
Rationale for negative (vs a sentinel like 255): a shell command can legitimately exit
255, so a positive sentinel collides with a real guest exit; a negative never does.
3.5 Worked example¶
v1 __shutdown__ -> "OK shutting down\n" (bare; read to idle/EOF)
v2 __shutdown__ -> "OK shutting down\n\x1e0\n" (framed; read to 0x1e, exit 0)
v1 (observer) ls -> "ERR read-only observer...\n" (bare; 500ms settle to detect)
v2 (observer) ls -> "ERR read-only observer...\n\x1e-1\n" (framed; exit -1 = control error)
v1 ls (ok) -> "<files>\x1e0\n" (framed; unchanged)
v2 ls (ok) -> "<files>\x1e0\n" (framed; unchanged)
Consumer read loop, v2 (framed-category commands): read bytes until a raw 0x1e; then read
the ASCII integer to \n; exit >= 0 -> result (body is the reply), exit < 0 -> control
error (body is ERR <reason>). No timer. No bare/framed branch.
4. Backward compatibility¶
v2 is a wire change, so it is gated on proto_version. But a v1 consumer talking to a v2
nether mostly still works, which makes rollout safe:
| v1 consumer path on a v2 nether | Behavior | Verdict |
|---|---|---|
framed command that gets a control ERR (was bare) |
the 0x1e now arrives immediately, so the consumer's bare-guard memchr(RS) sees it and reads the frame instead of settling - it gets a framed ERR with a nonzero (negative) exit |
compatible (fails the command, as before; now with an exit code) |
__shutdown__ (read unframed) |
socket closes (EOF); body is OK shutting down\n\x1e0\n - trailing \x1e0\n is cosmetic in the body |
compatible (checks OK prefix) |
__snapshot__ (read unframed) |
still waits its idle window, then body carries the trailing \x1e0\n |
compatible, cosmetic trailer, no latency win until updated |
| shell command | unchanged (framed in both) | identical |
The handshake is version-safe: __info__ is framed in both v1 and v2, so a consumer reads
it the same way and learns proto_version before it has to pick a read strategy.
A v2-aware consumer must still handle v1 servers until every nether is upgraded: read
proto_version from __info__, and if 1, keep the settle-timer + bare-guard path; if >=2,
use the uniform framed loop and treat __shutdown__/__snapshot__/__put__/__get__ as
framed. This dual-path period ends when all deployed nether are v2.
5. Consumer migration¶
tools/nether-ctl.c(reference): add v2 tois_framed()(shutdown/snapshot/put/get become framed); gate thebare_status_linesettle path onproto_version==1. ~20 lines.- swerver
control_client: same shape change; dropSETTLE_MSon v2. - swerver console codec (
clients/nether/codec.ts,connection.ts):isFramed()returns true for the ack commands on v2; delete the settle timer on v2 (fixes the truncation bug found in review directly).unescapeBody/frame-finding are unchanged. - the supervisor: uses
__info__/__shutdown__; gains the uniform loop.
All four already read proto_version from the __info__ handshake, so the version gate has a
home. Nether ships one version at a time (no per-client downgrade); consumers adapt off
proto_version.
6. Nether implementation sketch¶
- Bump
PROTO_VERSION1 -> 2 (src/agent/control.zig).__info__/__help__report it. - Replace the bare
reply(c, "ERR ...")/reply(c, "OK ...")sites (41 today) with framing helpers: replyOk(c, body)->writeAll(body); writeFrame(c, 0).replyErr(c, body)->writeAll(body); writeFrame(c, -1).writeFrame(c, exit)-> writes0x1e, the ASCII (possibly negative) exit,\n.- Recommended (Option A): frame all
ERR/OKuniformly - one mechanical change; the ~12 streamed-command error sites also become framedERR(harmless: a streamed consumer detects them by theERRprefix, 3.2). Simplest to implement and to reason about ("everyERR/OKis framed"). Alternative (Option B): keep streamed-command errors bare - the 12 streamed error sites call areplyBare(); the rest frame. Preserves "a streamed reply never contains a0x1e" at the cost of per-site category awareness. Prefer A unless a consumer needs B. - The framed-report path (
__info__/__stats__/__help__) and the guest-command relay are already framed - no change.
7. Tests¶
- Flip the wire-shape assertions in the integration-contract test (
control.zig, "control protocol: introspection replies, versioning, observer gating"): the bareERR/OKreplies that today assert no0x1emust assert a framed trailer with a negative exit forERRand0forOK.__info__/__stats__/__help__keep the0x1e0\nassertion. - Add: an observer-denied drive command frames with exit
-1;__shutdown__OK frames with exit0; the streamed logs (__events__) still carry no trailer (Option A: their error path frames, their success path does not). - Consumer side (out of nether): the settle-timer truncation case (a framed body starting
OK/ERRwith a late trailer) must now decode correctly with no timer - the regression the whole change exists to kill.
8. Alternatives considered¶
- Leave it at v1 (settle heuristic). Zero migration, but keeps the truncation hazard and the per-consumer complexity. Rejected: the protocol now has 4 consumers; the wart compounds.
- Length-prefix every reply (a
<len>\n<bytes>header on everything, including binary and streamed). Fully uniform and escape-free, but a much larger change: every reply site, chunked length-framing for streaming logs, and a total break of the0x1e+escape model that the R2b unforgeability proof rests on. Rejected as disproportionate; the negative-exit frame reuses the existing, proven framing. - Advertise per-command shape in
__help__/__info__(so a consumer configuresis_framed()at runtime instead of hardcoding). Solves discoverability but not the bare/framed timing ambiguity - a consumer still needs the settle timer for the bare replies. Orthogonal; could layer on later, but v2 makes it unnecessary for the ack commands.
9. Rollout¶
- Land v2 in nether behind the
PROTO_VERSION=2bump + framed replies + flipped tests. - Update
tools/nether-ctl.c(reference) in the same change, dual-path onproto_version. - Consumers (swerver, console, supervisor) update to the dual-path loop at their own pace - v1 clients keep working against v2 nether (section 4), so there is no lockstep requirement.
- Once all consumers are v2-aware, the v1 settle-timer paths can be deleted everywhere.
10. Open questions¶
- Distinct negative codes now or later? v2.0 uses a single
-1; assigning-2/-3/-4per error class is additive and can wait for a consumer that wants to branch without string matching. - Option A vs B for streamed-command errors (section 6) - A is simpler; B preserves
"streamed replies never contain
0x1e." Pick before implementing. - Do we ever need framed streaming? If a future consumer wants a hard end-of-stream
marker on
__events__/__screen__(instead of idle-gap), that is a separate length- or sentinel-framed streaming design, out of scope here.