refactor(tty): split control channel into STREAM_CTRL_IN/STREAM_CTRL_OUT halves (Phase 7)

The single STREAM_CONTROL = 3 was documented as bidirectional but the
adapter had to ignore Exit from the client because the two directions
were indistinguishable on the same stream_type — half-duplex in
disguise. Phase 7 splits it into two halves so the bidirectionality is
literal on the wire.

Changes:
- wire.rs: STREAM_CTRL_IN = 3 (client→server), STREAM_CTRL_OUT = 4
  (server→client); InvalidStreamType bound > 3 → > 4; Chunk::control
  → Chunk::ctrl_in/ctrl_out; ChunkWriter::write_control_json →
  write_ctrl_in_json/write_ctrl_out_json; tests split accordingly.
- control.rs: ControlMessage doc updated with the stream_type column;
  JSON shape unchanged.
- adapter.rs: pump_client_to_backend dispatches on STREAM_CTRL_IN
  (Resize/Signal/Eof; Exit on ctrl_in is a protocol violation,
  ignored); send_exit_chunk emits on STREAM_CTRL_OUT; STREAM_CTRL_OUT
  from the client is a protocol violation, ignored. 3 new tests for
  the direction enforcement; existing tests updated to the new
  stream_types.
- negotiation.rs: framing-disambiguation doc updated (server-sent
  stream_type set is {1, 2, 4}).
- alknet-tty-local/tests: common/mod.rs, pty.rs, pipe.rs updated to
  the new constants.

Specs:
- ADR-052 amended (§4a 'Control channel split (Phase 7 amendment)').
- tty-wire.md + tty-adapter.md updated (last_updated 2026-07-18).

Verification:
- cargo test -p alknet-tty: 65 passed (was 61; +4 new tests).
- cargo test -p alknet-tty-local: 19 passed.
- cargo test --workspace --all-features: 1017 passed, 0 failed.
- cargo clippy --workspace --all-features: clean.
- cargo fmt --all: clean.
This commit is contained in:
glm-5.2 committed 2026-07-18 17:02:59 +00:00
1 parent 859ad35896
commit c2b7055a64
11 files changed
+571 -142

No files matched your search

+52 -17
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-07
last_updated: 2026-07-18
---
# alknet-tty — TtyAdapter and Session Lifecycle
@@ -100,14 +100,18 @@ A `alknet/tty` session on one bidi stream proceeds in three phases:
`Some`, a concurrent stderr pump emits stderr chunks (stream_type 2).
On backend stdout EOF, emit a zero-length stdout sentinel.
- **B. client → backend**: client chunks → backend. stdin chunks
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`). Control
chunks (stream_type 3) → `ControlMessage` dispatch: `Resize` →
`TtyControl::resize`, `Signal` → `TtyControl::signal`, `Eof` →
close stdin. `Exit` from the client is ignored (server→client only).
(stream_type 0) → `TtyHandle.stdin` (via `AsyncWrite`).
Client→server control chunks (`STREAM_CTRL_IN`, stream_type 3) →
`ControlMessage` dispatch: `Resize` → `TtyControl::resize`, `Signal`
→ `TtyControl::signal`, `Eof` → close stdin. `Exit` on
`STREAM_CTRL_IN` is a protocol violation (it's server→client only)
and is ignored. `STREAM_CTRL_OUT` (stream_type 4) from the client is
a protocol violation (it's the server→client half) and is ignored.
On client read-half close or a zero-length stdin chunk, signal EOF
to the backend's stdin.
- **C. exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
enqueue `{"type":"exit","code":N}` as a control chunk (stream_type 3).
enqueue `{"type":"exit","code":N}` as a server→client control
chunk (`STREAM_CTRL_OUT`, stream_type 4).
A drainer task writes chunks to the client in arrival order. After the
exit chunk is written (task C resolves and the exit chunk drains),
@@ -118,6 +122,36 @@ hardcoded the local PTY backend; the adapter dispatches to any
`TtyBackend`. See `/workspace/alknet-tty-poc/src/session.rs` for the
reference implementation of the three-pump driver.
### Bidirectional Control Channel (Phase 7)
The control channel is split into two halves so it is genuinely
bidirectional on the wire:
- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`,
`Eof`).
- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`).
The adapter enforces the direction:
- An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
(server→client message on the client→server half) — the adapter
ignores it (the previous single `STREAM_CONTROL = 3` could not
distinguish the two directions, so `Exit` from the client was always
ignored; the split makes the rejection explicit).
- A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol
violation (client→server message on the server→client half) — the
adapter ignores it (the server never dispatches control messages it
receives on the server→client half).
- `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a
protocol violation (the client should not write on the server→client
half) — the adapter ignores them.
The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type
4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing
the two halves can route exit vs. control without parsing the JSON
`type` tag first. See `docs/research/alknet-crate-extraction/findings.md`
Phase 7 and `tty-wire.md` §"Control Channel".
### Negotiation Errors
If the server cannot allocate the session, it sends a JSON error response
@@ -146,17 +180,18 @@ The disambiguation is by the first byte: a JSON error frame's 4-byte
big-endian length prefix always starts with `0x00` (error frames MUST
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is
a wire-format invariant, not an assumption), while a raw chunk's first
byte is a `stream_type` in `{0, 1, 2, 3}`. A stream_type of `0` (stdin
from server) is invalid — the server never sends stdin chunks — so the
client distinguishes: read the first byte; if it is `0x00`, interpret
the next 4 bytes as a big-endian length prefix and read that many bytes
as a JSON error frame; otherwise interpret it as a `stream_type` byte
and continue reading the raw chunk header. This is a one-way-door
wire-format invariant (ADR-052): error frames use the negotiation
framing (length prefix) and MUST be under 16 MiB; success uses the raw
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs
`0x00`-as-invalid-stream_type disambiguation is what makes the two
distinguishable on the wire.
byte is a `stream_type`. The server never sends `0` (stdin —
client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so
the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`);
`0x00` is unambiguous. The client distinguishes: read the first byte; if
it is `0x00`, interpret the next 4 bytes as a big-endian length prefix
and read that many bytes as a JSON error frame; otherwise interpret it
as a `stream_type` byte and continue reading the raw chunk header. This
is a one-way-door wire-format invariant (ADR-052): error frames use the
negotiation framing (length prefix) and MUST be under 16 MiB; success
uses the raw chunk framing (stream_type byte first); the
`0x00`-as-length-prefix vs `0x00`-as-invalid-stream_type disambiguation
is what makes the two distinguishable on the wire.
### Exit-Chunk Ordering (ADR-055)
+78 -35
View File
@@ -1,13 +1,14 @@
---
status: draft
last_updated: 2026-07-07
last_updated: 2026-07-18
---
# 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
carriage), the raw chunk codec, the control channel (split into
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves — Phase 7), and the
sentinels. The two-carriage model is decided in
[ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md);
this document specifies what an implementer builds.
@@ -164,19 +165,34 @@ no `call.responded`/`call.completed` — this is not the call protocol.
- **`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 | 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 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | ctrl-out | server→client | JSON control message (`Exit`) |
`stream_type > 3` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 5th channel is a wire-format
`stream_type > 4` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 6th channel is a wire-format
change requiring a new ALPN (`alknet/tty/v2` per ADR-006), not a
negotiated addition to this format. See ADR-052 §"Fixed channel set,
not extensible."
**Bidirectional control channel (Phase 7).** The control channel is
split into two halves so it is genuinely bidirectional on the wire:
`STREAM_CTRL_IN = 3` carries client→server control (`Resize`,
`Signal`, `Eof`); `STREAM_CTRL_OUT = 4` carries server→client control
(`Exit`). The previous single `STREAM_CONTROL = 3` was documented as
"bidirectional" but the adapter ignored `Exit` from the client
because the two directions were indistinguishable on the same
stream_type — see `docs/research/alknet-crate-extraction/findings.md`
Phase 7. The split makes the bidirectionality explicit: each
direction has its own stream_type, and the adapter enforces the
direction (an `Exit` arriving on `STREAM_CTRL_IN` is a protocol
violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is
likewise a protocol violation and is ignored).
- **`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`).
@@ -208,9 +224,14 @@ Zero-length data chunks are sentinels:
Control chunks are never zero-length (the JSON payload is at least
`{}`).
### Control Channel (stream_type 3)
### Control Channel
Control chunks carry a JSON payload tagged by `type`. The schema is the
The control channel is split into two halves (Phase 7):
- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control.
- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control.
Each half carries JSON payloads tagged by `type`. The schema is the
POC's `ControlMessage` (`/workspace/alknet-tty-poc/src/control.rs`):
```rust
@@ -231,12 +252,22 @@ pub enum ControlMessage {
}
```
| 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) |
| stream_type | direction | Message | Shape | Maps to |
|-------------|----------------|---------|-------|---------|
| 3 (ctrl_in) | client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` | SSH `window-change`, docker exec resize, `ioctl(TIOCSWINSZ)` |
| 3 (ctrl_in) | client→server | signal | `{"type":"signal","name":"INT"}` | SSH `signal`, docker exec signal, `kill(-pgid, sig)` (REQ-TTY-02) |
| 3 (ctrl_in) | client→server | eof | `{"type":"eof"}` | SSH channel EOF, docker stdin close, `ChildStdin::drop` |
| 4 (ctrl_out) | server→client | exit | `{"type":"exit","code":0}` | the terminal/completion signal (ADR-055) |
The adapter enforces the direction: an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a
`Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a
protocol violation (the adapter ignores it). The split makes the
control channel genuinely bidirectional on the wire — the previous
single `STREAM_CONTROL = 3` was documented as "bidirectional" but the
adapter had to ignore `Exit` from the client because the two directions
were indistinguishable on the same stream_type. See
`docs/research/alknet-crate-extraction/findings.md` Phase 7.
**Signal names.** `name` is an uppercase string. The supported set (per
the POC's `signal_from_name`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`,
@@ -261,12 +292,12 @@ type is not.
Two signals both close the client's stdin:
1. **`{"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.
1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`)
— 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.
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker
POC's sentinel. Accepted for compatibility with that pattern.
@@ -288,17 +319,26 @@ a session — see [tty-adapter.md](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
stream_type set (0-4), and the two-carriage sequence are bytes clients
and servers parse. A 6th channel type requires a new ALPN
(`alknet/tty/v2` per ADR-006), not a negotiated addition.
- **The control channel is split into two halves (Phase 7).**
`STREAM_CTRL_IN = 3` is client→server (`Resize`, `Signal`, `Eof`);
`STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces
the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on
`STREAM_CTRL_OUT` is ignored. The split is what makes the control
channel genuinely bidirectional on the wire — the previous single
`STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
had to ignore `Exit` from the client because the two directions were
indistinguishable on the same stream_type.
- **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 additive
`ControlMessage` variant on stream_type 3, not a wire-format header
change.
`ControlMessage` variant on `STREAM_CTRL_IN`/`STREAM_CTRL_OUT`, 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
@@ -312,18 +352,21 @@ a session — see [tty-adapter.md](tty-adapter.md).
without entering raw mode. The error response MUST be under 16 MiB
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
is `0x00` — this is what makes the framing-disambiguation trick
(first byte `0x00` = error frame, first byte `1`/`2`/`3` = raw chunk)
sound; it is a wire-format invariant, not an empirical observation.
See [tty-adapter.md](tty-adapter.md) §"Negotiation errors".
(first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk;
the server never sends `0` (stdin, client→server) or `3`
(`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it
is a wire-format invariant, not an empirical observation. See
[tty-adapter.md](tty-adapter.md) §"Negotiation errors".
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON |
| Wire format and two-carriage model | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | `alknet/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON |
| Bidirectional control channel split | Phase 7 (this doc, amended) | `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` (server→client) replace the single `STREAM_CONTROL = 3`; the adapter enforces the direction |
| No alknet-call dependency (self-contained framing) | [ADR-057](../../decisions/057-alknet-tty-no-alknet-call-dep.md) | alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"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 |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT` (stream_type 4); "exit chunk is last" invariant |
| Stdin closure canonical signal | OQ-47 | Either `eof` control chunk (`STREAM_CTRL_IN`) or zero-length stdin chunk; `eof` recommended |
## Open Questions
@@ -2,7 +2,9 @@
## Status
Accepted
Accepted (amended 2026-07-18 — Phase 7: the control channel is split
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
(server→client) halves; see §"Control channel split" below)
## Context
@@ -123,12 +125,15 @@ The bidi stream has two phases:
| 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 |
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) |
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
`stream_type > 3` is 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/v2` per ADR-006), not a
negotiated addition to this format.
`stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
amendment, §4a: the original `3 = control (bidirectional)` is split
into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
`> 3`.) There is no extension escape hatch in the byte — a 6th
channel is a wire-format change requiring a new ALPN (`alknet/tty/v2`
per ADR-006), not a negotiated addition to this format.
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
@@ -176,6 +181,53 @@ tearing down the session. This is a two-way-door extension point within
the one-way-door wire format — adding a control message type is
additive; changing the chunk header is not.
### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
The single `stream_type 3 = control (bidirectional)` in §3 and §4 above
is **split into two halves** so the control channel is genuinely
bidirectional on the wire:
| stream_type | channel | direction | payload |
|-------------|-------------|----------------|------------------------------------------------------|
| 3 | `STREAM_CTRL_IN` | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | `STREAM_CTRL_OUT` | server→client | JSON control message (`Exit`) |
The `stream_type > 3` protocol-error bound becomes `stream_type > 4`.
The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
**Why the split.** The original §4 documented stream_type 3 as
"bidirectional" and listed the four control messages with their
directions. But the adapter had no way to distinguish the two
directions on the same stream_type — `Exit` from the client was always
ignored (the adapter's `pump_client_to_backend` matched `Exit` and
logged "ignoring Exit control from client (server→client only)"). The
spec said "bidirectional"; the code was half-duplex. The split makes
the bidirectionality literal: each direction has its own stream_type,
the adapter enforces the direction (an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation; a `Resize` arriving on
`STREAM_CTRL_OUT` is a protocol violation), and a client can route
exit vs. control without parsing the JSON `type` tag first.
**Door type.** One-way, same as the original §3 / §4. The stream_type
set is bytes clients and servers parse. A client written against the
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
`InvalidStreamType (> 3)` and tear down the session — the split is a
wire-format change, not an additive extension. The reversal path is
the same as the original: a new ALPN (`alknet/tty/v2`), which coexists
rather than replaces. The trade is one new stream_type byte now vs.
the half-duplex-in-disguise flaw forever.
**What changes in the spec.** §3's stream_type table gains a 5th row
(`4 = ctrl_out, server→client`); the bound becomes `> 4`. §4's
direction table is unchanged in content (the four messages keep their
directions and shapes) but the direction is now encoded in the
stream_type, not just in the adapter's behavior. ADR-055's "exit chunk
is last" invariant is unchanged — the exit chunk still rides the
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
the old single `STREAM_CONTROL` (stream_type 3). See
`docs/research/alknet-crate-extraction/findings.md` Phase 7 for the
full migration notes.
### 5. Negotiation errors use the JSON framing, not the raw chunk format
If the server cannot allocate the session (unknown backend, PTY