The backpressure chain is complete by construction: QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer
→ process write() blocks. Every link awaits its producer; no unbounded
buffer breaks the chain. The reversal path (an additive ControlMessage
variant, not a wire-format header change) is noted in ADR-052's
consequences as a two-way door, not the expected path.
Tightened ADR-052 assumption 1 from a hedge ('expected to work; a POC
would confirm') to a decided constraint. Updated all OQ-45 references
across the tty spec docs from 'open (low risk)' to 'resolved'.
13 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-07 |
alknet-tty — Wire Format
The wire protocol for alknet/tty: the negotiation frame (JSON
carriage), the raw chunk codec, the control channel, and the sentinels.
The two-carriage model is decided in
ADR-052;
this document specifies what an implementer builds.
What
A alknet/tty bidi stream carries one terminal session. The stream has
two phases:
- Negotiation (JSON carriage). A single length-prefixed JSON frame from the client carrying the terminal parameters, backend selector, command, and environment.
- Raw carriage. After the negotiation frame, the stream switches to a chunk format for the life of the session: bidirectional byte pumping with a 1-byte stream-type multiplexer and a JSON control channel.
The format is the alknet-docker POC's raw chunk format
(/workspace/alknet-docker-poc/src/raw.rs, stream_type 0/1/2) extended
with a 4th stream_type (3 = control) and a JSON control message schema,
both validated by the alknet-tty POC (/workspace/alknet-tty-poc/src/raw.rs
src/control.rs). See ADR-052.
Why
A terminal session is a byte stream with a small control sideband. The
two-carriage model (JSON negotiation, then raw chunks) keeps the call
protocol's JSON-RPC shape for the structured request and switches to
bytes for the body, which is what a terminal actually is. The fixed
channel set (four stream types, no negotiation) is an impoverishment of
SSH's channel multiplexer that is the feature: alknet-tty multiplexes
one service (a terminal session) with a fixed channel structure, not
arbitrary services, so the demux is a match, not a hash lookup. The
full rationale — why not JSON for everything, why fixed channel set
rather than extensible — is in
ADR-052
§Context.
Architecture
Phase 1: Negotiation Frame (JSON Carriage)
The client opens a bidi stream (or the server accepts one) and writes a
single length-prefixed JSON frame. The framing is byte-identical to
alknet-call's FrameFramedReader/FrameFramedWriter
(crates/alknet-call/src/protocol/wire.rs): a 4-byte big-endian length
prefix + UTF-8 JSON body. alknet-tty reuses the framing utility
(FrameFramedReader/FrameFramedWriter), not the EventEnvelope type —
the negotiation payload is a tty-specific struct, not a call.requested
event. See ADR-052 §5 and ADR-003 Amendment 1.
The payload shape:
{
"carriage": "raw",
"backend": "local",
"tty": {
"term": "xterm-256color",
"cols": 80,
"rows": 24,
"pixel_width": 0,
"pixel_height": 0,
"modes": {}
},
"cmd": ["/bin/bash"],
"cwd": null,
"env": {}
}
Fields:
carriage—"raw"for terminal sessions (the only carriage in v1). Selects the post-negotiation byte format.backend— the backend selector string ("local","docker","ssh"). The adapter dispatches to the registeredTtyBackendby this key (ADR-053 §5).tty— terminal parameters.nullfor the pipe/runner case (no PTY — ADR-054).Somefor the PTY case. Thettyblock maps directly to SSH'spty_requestparameters (term, cols, rows, pixel_width, pixel_height, modes) and to docker'sCreateExecOptions { tty: true }; a local backend passes it toportable_pty::PtySystem::openpty. Themodesfield is reserved (OQ-44 — default terminal modes suffice for the current scope).cmd— command vector (argv[0] + args). Non-empty.cwd— working directory (null= inherit/default).env— environment variables (empty = inherit).
Backend-specific selector fields ride alongside (e.g., "container": "abc123" for docker). The adapter parses the negotiation frame,
extracts the backend string, and passes the remaining backend-specific
fields to the selected backend's allocate() as an opaque
serde_json::Map (ADR-053) — the adapter does not interpret them; the
backend deserializes its own strongly-typed params struct.
After the negotiation frame, the stream switches to raw chunks. There is
no call.responded/call.completed — this is not the call protocol.
Phase 2: Raw Chunk Format
[stream_type: u8][length: u32 be][payload bytes]
-
stream_type(1 byte) — the channel:stream_type channel direction payload 0 data-in (stdin) client→server raw bytes 1 data-out (stdout) server→client raw bytes 2 data-err (stderr) server→client raw bytes 3 control bidirectional JSON control message stream_type > 3is a protocol error (InvalidStreamType). There is no extension escape hatch in the byte — a 5th channel is a wire-format change requiring a new ALPN (alknet/tty/v2per ADR-006), not a negotiated addition to this format. See ADR-052 §"Fixed channel set, not extensible." -
length(4 bytes, big-endian) — payload length in bytes. Max 16 MiB (MAX_CHUNK_LEN = 16 * 1024 * 1024). A chunk larger than 16 MiB is a protocol error (ChunkTooLarge). -
payload(lengthbytes) — the raw bytes (for data channels) or UTF-8 JSON (for the control channel).
The codec is ChunkReader/ChunkWriter (the POC's
/workspace/alknet-tty-poc/src/raw.rs generalized):
ChunkReader::read_chunk() reads the 5-byte header, validates the
stream_type and length, reads the payload; ChunkWriter::write_chunk()
writes the header and payload. See ADR-052.
Sentinels
Zero-length data chunks are sentinels:
- Zero-length stdin chunk (stream_type 0, length 0) — EOF from the
client. The server closes the backend's stdin (
ChildStdin::drop/ PTY writer close). This is one of two canonical "stdin done" signals; the other is a{"type":"eof"}control chunk — see OQ-47. - Zero-length stdout chunk (stream_type 1, length 0) — "drained" from the server. The backend's stdout stream ended (process exited, container output stream ended, SSH channel closed). This is an implementation sentinel; the deterministic completion signal is the exit control chunk (ADR-055), not this sentinel — but the drained sentinel is emitted for symmetry with the docker POC's pattern.
Control chunks are never zero-length (the JSON payload is at least
{}).
Control Channel (stream_type 3)
Control chunks carry a JSON payload tagged by type. The schema is the
POC's ControlMessage (/workspace/alknet-tty-poc/src/control.rs):
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMessage {
Resize {
cols: u16,
rows: u16,
#[serde(default)]
pixel_width: u16,
#[serde(default)]
pixel_height: u16,
},
Signal { name: String },
Eof,
Exit { code: i32 },
}
| Direction | Message | Shape | Maps to |
|---|---|---|---|
| client→server | resize | {"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0} |
SSH window-change, docker exec resize, ioctl(TIOCSWINSZ) |
| client→server | signal | {"type":"signal","name":"INT"} |
SSH signal, docker exec signal, kill(-pgid, sig) (REQ-TTY-02) |
| client→server | eof | {"type":"eof"} |
SSH channel EOF, docker stdin close, ChildStdin::drop |
| server→client | exit | {"type":"exit","code":0} |
the terminal/completion signal (ADR-055) |
Signal names. name is an uppercase string. The supported set (per
the POC's signal_from_name): HUP, INT, QUIT, TERM, KILL,
USR1, USR2, TSTP, CONT. Unknown names fall back to the backend's
default kill (see tty-local.md REQ-TTY-02 —
portable_pty's ChildKiller::kill sends SIGHUP).
Exit code. code is i32 (matches std::process::ExitStatus::code();
negative values are signal-terminated, e.g., -9 for SIGKILL on Unix). The
exit chunk is the last control chunk before stream close (ADR-055).
Extensibility. The type tag is the extension seam: new control
message types are added by extending the tagged enum. Unknown type
values are ignored (not a protocol error) so that a newer client
sending a control message an older server doesn't recognize degrades
gracefully rather than tearing down the session. This is a two-way-door
extension point within the one-way-door wire format (ADR-052) — adding a
control message type is additive; changing the meaning of an existing
type is not.
Stdin Closure
Two signals both close the client's stdin:
{"type":"eof"}control chunk (stream_type 3) — explicit, recommended. Tells the server to close the backend's stdin (ChildStdin::drop/ PTY writer close). The client may still want to receive remaining stdout + the exit code, so the server does not tear down the session on eof — it just closes stdin and keeps pumping output.- Zero-length stdin chunk (stream_type 0, length 0) — the docker POC's sentinel. Accepted for compatibility with that pattern.
The spec recommends eof for explicitness (it's a control message, not
a data-length hack), but both are accepted. See OQ-47.
Connection vs Stream
A Connection (ADR-007) can open/accept multiple bidi streams. One
alknet/tty connection hosts multiple terminal sessions — one session
per bidi stream (DP-6, decided in the research). This matches the call
protocol's model (one operation per stream, multiple operations per
connection) and is the natural fit for QUIC's stream multiplexing. A
coordinator opens one connection to an endpoint and launches multiple
sessions (one stream each) for parallel tasks. The TtyAdapter::handle
accepts the connection and loops accept_bi, dispatching each stream to
a session — see tty-adapter.md.
Constraints
- The wire format is one-way (ADR-052). The 5-byte header, the fixed
stream_type set (0-3), and the two-carriage sequence are bytes clients
and servers parse. A 5th channel type requires a new ALPN
(
alknet/tty/v2per ADR-006), not a negotiated addition. - No windowing. The chunk format has no flow-control window; QUIC's
per-stream flow control is the backpressure mechanism (OQ-45 resolved:
the backpressure chain is complete by construction — QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY
buffer → process
write()blocks; no unbounded buffer breaks the chain). The reversal path, if ever needed, is an additiveControlMessagevariant on stream_type 3, not a wire-format header change. - No negotiation round-trip. The client writes the negotiation frame and starts sending chunks; the server reads the frame and starts pumping. There is no "the server acknowledges the negotiation before the client sends data" step — QUIC's stream reliability handles in-order delivery, and the negotiation frame is small (fits in the initial flow-control window — ADR-052 assumption 2).
- Negotiation errors are JSON, not chunks. If the server cannot allocate the session (unknown backend, PTY allocation failed, the command is invalid), it sends a JSON error response in the same length-prefixed framing as the negotiation frame and closes the stream without entering raw mode. See tty-adapter.md §"Negotiation errors".
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Wire format and two-carriage model | ADR-052 | alknet/tty ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
alknet-call framing utility reuse |
ADR-003 Am. 1 | Negotiation frame uses FrameFramedReader/FrameFramedWriter, not EventEnvelope |
| Exit code on a control chunk | ADR-055 | {"type":"exit","code":N} on stream_type 3; "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either eof control chunk or zero-length stdin chunk; eof recommended |
Open Questions
See open-questions.md for full details.
- OQ-44 (deferred(scope)): Terminal modes.
- OQ-45 (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
- OQ-47 (resolved): Stdin closure canonical signal.
References
- ADR-052 — the wire format decision
- ADR-055 — the exit-chunk ordering the control channel carries
- ADR-003 Amendment 1 —
the
alknet-callframing utility reuse /workspace/alknet-tty-poc/src/raw.rs— the chunk codec (ChunkReader/ChunkWriter, stream_type 0-3) this spec commits/workspace/alknet-tty-poc/src/control.rs— the JSON control schema (ControlMessagetagged enum) this spec commits/workspace/alknet-docker-poc/src/raw.rs— the seed codec (stream_type 0/1/2) the tty POC extendeddocs/research/alknet-docker/poc-summary.md— the POC that validated the raw chunk format and the two-carriage model- tty-adapter.md — the session lifecycle that consumes this wire format