phase 4: architecture docs + BAST schema + renumbered ADRs
Port the alknet-tty architecture docs into alktty and add the BAST document for the alk/tty wire format. Docs-only; no Rust source changes. Spec docs (docs/architecture/, flat layout — single-crate repo): - overview.md — crate purpose, two-carriage model, deps, ALPN, backend location map, feature gates - tty-wire.md — 5-byte chunk codec, control channel split (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels - tty-backend.md — TtyBackend trait, TtyHandle, TtyControl, REQ-TTY-01 (backends need not be natively async) - tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk ordering (ADR-004), cancel cleanup (ADR-005), access control - tty-local.md — LocalTtyBackend (local feature module), PTY + pipe modes, REQ-TTY-02 (signal forwarding to process group) - README.md — architecture index ADRs (docs/architecture/decisions/, renumbered 001..008 from alknet 052,053,054,055,056,057,077,093 in order): - 001 wire format + two-carriage model (incl. Phase 7 control- channel split amendment) - 002 TtyBackend trait + TtyHandle - 003 local backend placement (records both the alknet sibling- crate decision and the alktty single-crate consolidation behind a local feature) - 004 exit code on a control chunk - 005 backend cleanup on session cancel - 006 self-contained negotiation framing - 007 tty inside channels (reversed by 008; kept for historical context with reversal notice) - 008 channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) BAST document (docs/architecture/tty-bast.md): - Normative JSON spec for the alk/tty wire format, conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema - 5-byte chunk header (struct, big-endian: stream_type uint8, length uint32) + StreamType enum (Stdin=0..CtrlOut=4) - ControlMessage union (field-name discriminator on type: resize/signal/eof/exit) with documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding - NegotiationFrame (4-byte BE length + UTF-8 JSON body) + NegotiateRequest / TerminalParams JSON shapes - StreamType enum deviation noted: on-wire uint8, not BAST's standard u32 enum index (chunk header is 5 bytes, not 8) - alktty does not depend on alktype; the hand-rolled wire.rs is the runtime codec, the BAST is the human-readable contract AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8 mapping (the previous table substituted ADR-050 for 054, relabeled 056 as control-message split, dropped 077, and added a new control-split ADR at 006 — inconsistent with both the plan and the prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet- core ADR, not tty-specific, and is not ported; the Phase 7 control split stays as an amendment inside ADR-001, mirroring alknet. Verification (all pass, no Rust source changed): - cargo test (80 passed) - cargo test --all-features (103 passed) - cargo clippy --all-targets -- -D warnings (clean) - cargo fmt --check (clean) - cargo check --target wasm32-unknown-unknown (clean) - cargo clippy --target wasm32-unknown-unknown -- -D warnings (clean) - cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in src/session.rs and src/channels.rs (untouched by this commit; not introduced here) - BAST JSON parses; StreamType indices match wire.rs constants (0=Stdin..4=CtrlOut) - all markdown cross-reference links resolve
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
# ADR-001: alktty Wire Format and Two-Carriage Model
|
||||
|
||||
## Status
|
||||
|
||||
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. Ported
|
||||
from alknet ADR-052 2026-08-17; cross-references renumbered to
|
||||
alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003,
|
||||
ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008.
|
||||
Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012,
|
||||
044, 049, 050) are not ported into alktty's ADR range because they are
|
||||
not tty-specific; the alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
||||
authoritative.)
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-docker POC validated that interactive attach — bidirectional
|
||||
byte pumping over a framed bidi stream with a 1-byte stream-type
|
||||
multiplexer — is the same problem regardless of whether the backend is
|
||||
`bollard::attach_container()` or russh's `pty_request`. The POC's raw
|
||||
chunk format (`[stream_type: u8][length: u32 be][payload bytes]`,
|
||||
stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished
|
||||
version of SSH's channel multiplexer: fixed set of channel types, no
|
||||
negotiation, no open/close handshake, no windowing (QUIC provides flow
|
||||
control on the bidi stream). That impoverishment is the feature — a
|
||||
terminal session needs exactly those channels and no more.
|
||||
|
||||
The alknet-tty POC (built 2026-07-05) extended that format with a 4th
|
||||
stream_type (3 = control) carrying JSON control messages (resize,
|
||||
signal, eof, exit) and validated the full round-trip against a real
|
||||
`portable_pty` PTY: negotiate → PTY alloc → bidirectional echo →
|
||||
mid-session resize → EOF → exit code, plus SIGINT forwarding to a child
|
||||
process group. The wire format this ADR commits is the POC's raw codec
|
||||
+ control schema, generalized from one backend to the backend-agnostic
|
||||
crate.
|
||||
|
||||
Three load-bearing questions are decided here:
|
||||
|
||||
1. **Separate ALPN with raw carriage, not call-protocol operations.** A
|
||||
terminal session could be modeled as a call-protocol `Subscription`
|
||||
operation (`tty/open`) streaming `call.responded` events. That is
|
||||
rejected: JSON-encoding every byte chunk is wasteful (base64 for
|
||||
binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams
|
||||
partial bytes with no message boundary that maps to a JSON object).
|
||||
The two-carriage model — a single JSON negotiation frame, then raw
|
||||
chunks — keeps the call protocol's JSON-RPC shape for the *request*
|
||||
and switches to bytes for the *body*, which is the part that is
|
||||
actually bytes. This is the pattern the docker POC validated and the
|
||||
SSH research independently arrived at for PTY.
|
||||
|
||||
2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary
|
||||
services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with
|
||||
string-named types negotiated per channel. alktty multiplexes one
|
||||
service — a terminal session — with a fixed `u8` set and no
|
||||
negotiation. Adding a 6th channel type is a wire-format change
|
||||
(one-way door). The ALPN model handles extensibility at the protocol
|
||||
level: a genuinely new sideband (e.g., file transfer alongside the
|
||||
terminal) is a different ALPN, not a 6th tty channel type. A new
|
||||
ALPN is cheap; a wire-format change is not.
|
||||
|
||||
3. **Control messages as JSON, not binary.** A binary control format
|
||||
(`[control_type: u8][params...]`) would be faster but harder to
|
||||
extend and inconsistent with the negotiation layer. Control messages
|
||||
are rare (resize on window drag, signal on Ctrl-C, one eof, one exit
|
||||
per session) — serialization cost is negligible against the data
|
||||
chunks. If a hot control path appears, a binary `control_type` can
|
||||
be added without breaking the chunk format (additive within the
|
||||
control channel).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. alktty is a `ProtocolHandler` on ALPN `alk/tty`
|
||||
|
||||
`alk/tty` is a custom ALPN per the alknet ADR-006 `alk/<name>` convention
|
||||
(renamed from `alknet/<name>` in alkcall 0.1.1). The `TtyAdapter`
|
||||
implements `ProtocolHandler` (alknet ADR-002, revised by alknet ADR-007
|
||||
to receive a `Connection`). The handler owns the entire connection
|
||||
lifecycle and accepts one bidi stream per terminal session. This is a
|
||||
separate ALPN, not a set of operations in the call protocol's
|
||||
`OperationRegistry` — the raw-carriage byte pump is not a
|
||||
`StreamingHandler` (alknet ADR-049); it is its own wire format after a
|
||||
single JSON negotiation frame.
|
||||
|
||||
### 2. Two-carriage model: JSON negotiation, then raw chunks
|
||||
|
||||
The bidi stream has two phases:
|
||||
|
||||
- **Negotiation (JSON carriage).** The client opens a bidi stream and
|
||||
writes a single length-prefixed JSON frame — a 4-byte big-endian
|
||||
length prefix + UTF-8 JSON body. The frame carries the terminal
|
||||
parameters and backend selector the server needs to allocate the
|
||||
session:
|
||||
|
||||
```json
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
```
|
||||
|
||||
The `carriage` field is `"raw"` for terminal sessions (the only
|
||||
carriage in v1). The `tty` block is `null` for the pipe/runner case
|
||||
(no PTY — see ADR-003). The `backend` field selects the `TtyBackend`
|
||||
(ADR-002); backend-specific fields (e.g., `container` for docker)
|
||||
ride alongside.
|
||||
|
||||
- **Raw carriage.** After the negotiation frame, the stream switches to
|
||||
the chunk format below for the life of the session. There is no
|
||||
`call.responded`/`call.completed` — this is not the call protocol.
|
||||
|
||||
### 3. Chunk format
|
||||
|
||||
```text
|
||||
[stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
- `stream_type` — fixed set, no negotiation:
|
||||
|
||||
| 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` — see §4a) |
|
||||
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
|
||||
|
||||
`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 (`alk/tty/v2`
|
||||
per alknet 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
|
||||
16 MiB bound accommodates large paste operations and large `env`
|
||||
blocks while bounding memory per chunk (a reader can allocate a
|
||||
bounded buffer up front); it mirrors the docker POC's limit.
|
||||
|
||||
- Zero-length data chunks are sentinels: a zero-length stdin chunk is
|
||||
EOF from the client; a zero-length stdout chunk is "drained" from the
|
||||
server (output stream ended). Control chunks are never zero-length
|
||||
(the JSON payload is at least `{}`).
|
||||
|
||||
### 4. Control channel (stream_type 3) carries JSON control messages
|
||||
|
||||
Control chunks carry a small JSON payload, tagged by `type`:
|
||||
|
||||
| Direction | Message | Shape |
|
||||
|---|---|---|
|
||||
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` |
|
||||
| client→server | signal | `{"type":"signal","name":"INT"}` |
|
||||
| client→server | eof | `{"type":"eof"}` |
|
||||
| server→client | exit | `{"type":"exit","code":0}` |
|
||||
|
||||
- **resize** — window-size change. Maps to SSH `window-change`, docker
|
||||
exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY.
|
||||
- **signal** — signal forwarding. `name` is an uppercase string
|
||||
(`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`,
|
||||
`"USR1"`, `"USR2"`). Unknown names fall back to the backend's default
|
||||
kill (see ADR-002 / `tty-local.md` REQ-TTY-02).
|
||||
- **eof** — client signals no more stdin. Maps to SSH channel EOF,
|
||||
docker stdin close, or `ChildStdin::drop` / PTY writer close. This is
|
||||
one of two canonical "stdin done" signals; the other is a zero-length
|
||||
stdin chunk (the docker POC's sentinel). Both are accepted as
|
||||
equivalent — `eof` is recommended for explicitness (it's a control
|
||||
message, not a data-length hack); the zero-length stdin chunk is kept
|
||||
for compatibility with the docker POC's pattern. See OQ-47.
|
||||
- **exit** — server signals process exit with code. This is the last
|
||||
control chunk before stream close (see ADR-004).
|
||||
|
||||
The `type` tag is the extensibility 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 — 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 (`alk/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-004'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).
|
||||
|
||||
### 5. Negotiation errors use the JSON framing, not the raw chunk format
|
||||
|
||||
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 4-byte length-prefixed framing as the negotiation
|
||||
frame and closes the stream without entering raw mode. The error
|
||||
response shape is `{"error":"<code>","message":"..."}` (see
|
||||
[tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the
|
||||
codes). This is not `call.error` — this is not the call protocol; the
|
||||
error is a JSON response in the negotiation framing, and the stream
|
||||
closes after it.
|
||||
|
||||
**Framing disambiguation (success vs error).** Both a successful
|
||||
allocation (raw chunks) and a failed allocation (JSON error frame)
|
||||
begin with bytes the client must read before knowing which framing
|
||||
applies. 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, 4}`. 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: 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. (With the Phase 7 split, the server-sent
|
||||
set is `{1, 2, 4}` — stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is
|
||||
still unambiguous.)
|
||||
|
||||
### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency)
|
||||
|
||||
The 4-byte length prefix + JSON body is implemented in alktty as a
|
||||
small, self-contained module (`src/negotiation.rs`, ~30 lines: read
|
||||
4-byte BE length, bounds-check, read N bytes; write the inverse). The
|
||||
format coincides with alkcall's `EventEnvelope` framing by convention
|
||||
(both are length-prefixed JSON) — not by code reuse. alktty does not
|
||||
depend on alkcall's internal wire types: the negotiation payload is a
|
||||
tty-specific struct (`NegotiateRequest`), not a `call.requested`
|
||||
`EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is
|
||||
hardcoded to deserialize `EventEnvelope` (the length-prefix read and
|
||||
the type-specific deserialize are one entangled call), so it is not
|
||||
reusable for a different payload type. alktty implements its own
|
||||
framing on tokio's `AsyncRead`/`AsyncWrite`. See
|
||||
[ADR-006](006-negotiation-framing-self-contained.md) for the decision
|
||||
(and the three options considered: duplicate / promote to alkcall::core
|
||||
/ use alkcall) and alknet ADR-003 Amendment 2 for the dependency-edge
|
||||
clarification.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The wire format is POC-validated twice (docker POC for stream_type
|
||||
0/1/2 + bidirectional pump; tty POC for stream_type 3 + control
|
||||
messages + local PTY). No new wire-format invention in Phase 1.
|
||||
- The fixed channel set is a `match`, not a hash lookup — fast on the
|
||||
hot path where every chunk is data.
|
||||
- The two-carriage model keeps the call protocol's JSON-RPC shape for
|
||||
the structured request while letting the body be raw bytes, which is
|
||||
what a terminal actually is. No base64, no per-chunk `EventEnvelope`
|
||||
overhead.
|
||||
- Control messages as JSON are consistent with the negotiation layer
|
||||
and trivially extensible (tagged enum), at negligible cost for rare
|
||||
messages.
|
||||
- A separate ALPN composes with the ALPN dispatch model (alknet
|
||||
ADR-001/006): the endpoint routes `alk/tty` to the `TtyAdapter`; the
|
||||
call protocol is unaffected. Browser terminals (xterm.js over
|
||||
WebTransport, when WebTransport revives) connect to `alk/tty` directly
|
||||
without implementing SSH or the call protocol.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- A 6th channel type is a wire-format change (one-way door). The
|
||||
justification is that the use cases are bounded — a terminal session
|
||||
has stdin, stdout, stderr, and control (now split into ctrl-in /
|
||||
ctrl-out). New sideband needs are different ALPNs, not 6th channels.
|
||||
If this proves wrong, the reversal is a new ALPN string
|
||||
(`alk/tty/v2`), which coexists with the old one rather than replacing
|
||||
it — but every client and server implementing the old format would
|
||||
need updating to speak the new one.
|
||||
- Control as JSON means a `serde_json` deserialize per control chunk.
|
||||
Control chunks are rare (one per resize, one per signal, one eof, one
|
||||
exit), so this is negligible. A hot control path would warrant a
|
||||
binary format — the `type`-tagged enum leaves that door open without
|
||||
a wire-format change.
|
||||
- The negotiation frame is a custom JSON shape, not a `call.requested`
|
||||
event, so a client library can't reuse its call-protocol client to
|
||||
open a tty session — it speaks the tty wire format directly. This is
|
||||
intentional (the tty session is not a call-protocol operation) but
|
||||
means the tty client is a separate small client, not a `CallClient`
|
||||
method.
|
||||
- **No application-level flow-control window (OQ-45 resolved).** The
|
||||
chunk format carries no window; backpressure relies on QUIC's
|
||||
per-stream flow control composing with the bounded channels in the
|
||||
adapter's pump and the OS pipe/PTY buffer. This is sufficient by
|
||||
construction (see §Assumptions 1). If a workload ever requires
|
||||
sub-QUIC-window backpressure signaling, the reversal is an additive
|
||||
`ControlMessage::WindowUpdate` (or similar) variant on the control
|
||||
channel — a two-way-door extension to the control channel, not a
|
||||
wire-format header change. The `type`-tagged enum's "unknown types
|
||||
ignored" rule means older peers degrade gracefully if such a variant
|
||||
is introduced.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The chunk header (5 bytes: 1 type + 4 length), the fixed
|
||||
stream_type set (0-4), and the two-carriage sequence (JSON frame → raw
|
||||
chunks) are bytes clients and servers parse. Changing any of them breaks
|
||||
every client and server implementing the format. The reversal path is a
|
||||
new ALPN (`alk/tty/v2`), which coexists rather than replaces — but the
|
||||
cost of migrating every consumer is the one-way-door cost.
|
||||
|
||||
The control message `type` enum is a two-way-door extension point within
|
||||
the one-way wire format: adding a control message type is additive
|
||||
(unknown types are ignored), changing the meaning of an existing type is
|
||||
not.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **No application-level windowing; QUIC per-stream flow control is the
|
||||
backpressure mechanism.** The chunk format carries no window field.
|
||||
This is decided (OQ-45 resolved): the backpressure chain from a slow
|
||||
client read all the way back to the child process's stdout write 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, if a workload ever
|
||||
surfaces a problem QUIC's defaults cannot handle, is an additive
|
||||
window-update `ControlMessage` variant (a two-way-door extension to
|
||||
the control channel, not a wire-format header change) — noted in
|
||||
§Consequences. See OQ-45.
|
||||
|
||||
2. **The negotiation frame fits in one chunk of the underlying stream's
|
||||
initial flow-control window.** The frame is small (terminal params +
|
||||
command + env, typically < 4 KiB). QUIC's default initial bidi-window
|
||||
(quinn defaults are tens of KiB) accommodates it without a
|
||||
flow-control round-trip. A pathological `env` block larger than the
|
||||
window would stall until the window opens; the 16 MiB chunk limit is
|
||||
the hard cap.
|
||||
|
||||
3. **Control messages are rare enough that JSON serialization cost is
|
||||
negligible.** Validated by the tty POC: resize on window drag, one
|
||||
signal per Ctrl-C, one eof, one exit per session. No measurable cost
|
||||
observed.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-001 — ALPN-based dispatch
|
||||
- alknet ADR-002 — ProtocolHandler trait
|
||||
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per
|
||||
connection; new ALPN for incompatible versions
|
||||
- alknet ADR-007 — handler receives a `Connection`, accepts bidi streams
|
||||
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call;
|
||||
self-contained negotiation framing
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md) — the
|
||||
dependency-edge decision this ADR's §6 reflects
|
||||
- alknet ADR-012 — the call protocol's stream model (which tty is *not*
|
||||
using for the body, by design)
|
||||
- alknet ADR-049 — the `StreamingHandler` path tty explicitly does not
|
||||
use for the byte body
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend trait
|
||||
the negotiation frame's `backend` field dispatches to
|
||||
- [ADR-003](003-local-backend-placement.md) — the local backend's
|
||||
module placement (the `tty: null` pipe-mode case)
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk
|
||||
ordering this ADR's control channel carries
|
||||
- [ADR-008](008-channels-pure-channel-multiplexing.md) — TTY always
|
||||
uses its 5-byte format, including inside channels (the channels layer
|
||||
carries this wire format transparently in its payload)
|
||||
- Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md)
|
||||
- Port origin: alknet ADR-052 at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md`
|
||||
Reference in New Issue
Block a user