- Rewrite dash-joined clauses with colons, semicolons, commas, and parentheses, varied per instance - No decision, status, ADR reference, heading structure, or code-fence content changed Verification: grep -c '—' returns 0 for all four files; structure, links, tables, and fences unchanged
14 KiB
ADR-007: TTY Inside Channels (Sub-Streams, Not Wire Format)
Status
Accepted (reversed 2026-07-18 by alknet ADR-093, ported here as
ADR-008: TTY always uses
its 5-byte format; the channels layer carries it transparently in the
payload (see "Reversal (ADR-008, 2026-07-18)" below). Ported from
alknet ADR-077 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. The alknet ADRs
referenced by alknet number (052, 053, 055, 057, 061, 071, 073, 074,
092) are not ported into alktty's ADR range; they are alknet-core /
alknet-channels ADRs. The alknet originals at
/workspace/@alkdev/alknet/docs/architecture/decisions/ remain
authoritative.)
Reversal (ADR-008, 2026-07-18)
The two-mode TTY design (direct vs inside-channels, with different
sub-stream access paths) is reversed. TTY's 5-byte format
([stream_type:u8][length:u32][payload], ADR-001) is TTY's internal
format, used in both direct mode and inside-channels mode. The two
modes differ only in where the BiStream comes from (a top-level
alk/tty connection vs a channel/open with ALPN alk/tty), not in
how TTY parses it. The same wire.rs code runs in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
(alknet ADR-093 / ADR-008) and hands TTY the payload bytes. TTY parses
its 5-byte header from the payload. The channels layer carries TTY's
5-byte chunks transparently in its payload: no shared fields, no
leaked abstraction, no double-chunking concern (the 13-byte total
header is 8 channels + 5 TTY, not 8 + 9; the channels length is
always tty_len + 5).
The channels feature on alktty (if such a feature were added) becomes
"run TTY's sub-demux on a channels-backed BiStream" (the same code
as direct mode, a different BiStream source). The control channel split
(STREAM_CTRL_IN / STREAM_CTRL_OUT, Phase 7) is TTY-internal; the
channels layer doesn't know about it. alknet ADR-074's
into_sub_streams() (the accessor this ADR's two-mode design relied
on) is removed by alknet ADR-093 / ADR-008; TTY sub-demuxes its
BiStream via its own 5-byte format instead.
The body below describes the original (two-mode) shape; the
reversal above is the operative decision. The two-mode description is
kept as the historical context for the reversal. See ADR-008 for the
resolution rationale (the channels layer has no stream_type concept;
the handler owns its sub-stream multiplexing) and the cross-ADR
impacts.
Context
ADR-001 defines the alktty wire format: [stream_type: u8][length: u32 be][payload], a 5-byte chunk header for five sub-streams
(stdin/stdout/stderr/ctrl-in/ctrl-out) within one bidi stream. This
format is stable, implemented (src/wire.rs), and used for direct
alk/tty connections.
The phase-0 research recommended that the TTY chunk format be "absorbed
into channels" and that alk/tty remain as a "direct-connect
shortcut." But the research did not pin what changes in the TTY crate
when a TTY session runs inside a channels connection. This is a real
integration question the research hand-waved.
The problem: the TtyAdapter's current handle() loops accept_bi(),
spawning a drive_session task per bidi stream that parses 5-byte TTY
chunks off the stream. Inside a channels connection, the stream is
already de-chunked by the channels layer's 9-byte format: the
handler sees an AsyncRead + AsyncWrite pair, not a chunk-encoded
stream. If the TTY adapter tries to parse 5-byte chunks off an
already-de-chunked stream, it breaks.
Decision
Two modes for TTY, one adapter
The TtyAdapter operates in two modes, determined by how it receives
its Connection:
| Mode | When | Wire format | How the adapter gets sub-streams |
|---|---|---|---|
Direct (alk/tty ALPN) |
Top-level QUIC/TCP connection with ALPN alk/tty |
TTY's 5-byte format (ADR-001, amended; see below) | accept_bi() → parse 5-byte chunks → split into stream_types 0-4 |
| Inside channels | channel/open with ALPN alk/tty on a channels connection |
Channels' 9-byte format (alknet ADR-071); the channels layer de-chunks | into_sub_streams() (alknet ADR-074) → five named handles for stream_types 0-4 |
In both modes, the TtyBackend trait and TtyHandle are unchanged
(ADR-002). The backend allocates a PTY and returns a TtyHandle; the
adapter pumps data between the handle and the sub-streams. The
difference is only in how the adapter gets the sub-streams: 5-byte
chunk parsing (direct) vs. into_sub_streams() (channels).
The control channel is now properly bidirectional
The phase-0 findings flagged that the TTY control channel "isn't
actually bidirectional… the adapter ignores Exit from the client." The
root cause: stream_type 3 was "bidirectional" (one stream both sides
wrote to, which is not properly multiplexed).
alknet ADR-071's stream_type decomposition fixes this: every stream_type is unidirectional. Control is now two halves:
| stream_type | direction | purpose |
|---|---|---|
| 3 | write (client→server) | control in: resize, signal, eof |
| 4 | read (server→client) | control out: exit, keepalive response |
The TTY adapter writes resize/signal/eof to ctrl_in (stream_type 3)
and reads exit/keepalive from ctrl_out (stream_type 4). Each has its
own reassembly buffer, its own flow control, its own EOF. The control
channel is actually bidirectional (two unidirectional streams, not
one shared stream both sides write to).
This amends ADR-001's stream_type assignments for direct mode too:
direct alk/tty connections now use stream_types [0, 1, 2, 3, 4] (data
in/out/err + control in/out), not [0, 1, 2, 3]. The 5-byte format's
stream_type field gains value 4; the ControlMessage enum is
unchanged (the JSON shape is the same; the stream_type it rides on
splits from 3 into 3+4).
What changes in alktty
-
The adapter's session-driving code splits into two entry points:
drive_session_direct(send, recv, backends, ...)(the existing path): parse 5-byte chunks, split into stream_types 0-4, pump. Used for directalk/ttyconnections.drive_session_channels(sub_streams, backends, ...)(the new path): receiveChannelSubStreams(five named handles: stdin= SendStream, stdout=RecvStream, stderr=Option, ctrl_in=SendStream, ctrl_out=RecvStream), pump directly without chunk parsing. Used when the channel'sConnectionis backed byChannelBidiStreamSource.
-
The
TtyAdapter::handle()branches on theConnection's source type. The channels crate'sChannelBidiStreamSourceis aBidiStreamSource(alknet ADR-070); theConnectionwraps it. The adapter detects whether theConnectionis channels-backed (via a downcast or a channels-crate extension trait; exact ergonomics per alknet ADR-074's implementation detail) and callsdrive_session_channelsinstead ofdrive_session_direct.This is the one place alktty knows about channels. It is a branch on the connection source, not a dependency on channels' wire format. The branch can be feature-gated (
channelsfeature on alktty) so the direct-only path has no channels dependency. -
The 5-byte wire format (ADR-001) is unchanged for direct connections. ADR-001's scope is now "the wire format for direct
alk/ttyconnections." The channels path does not use it. This amends ADR-001's scope: the format is not replaced, it's scoped. -
The control channel works the same in both modes, now properly bidirectional. In direct mode, control-in JSON rides in 5-byte chunks with
stream_type=3and control-out rides withstream_type=4. In channels mode, control-in rides in 9-byte chunks withstream_type=3(write) and control-out withstream_type=4(read), but the channels layer de-chunks them, so the adapter reads raw JSON bytes fromctrl_in/ctrl_outin both cases. TheControlMessageenum (resize, signal, eof, exit) is unchanged: the JSON shape is the same; only the stream_type assignments change (3 splits into 3+4). -
The exit-chunk-is-last invariant (ADR-004) generalizes. In direct mode, the exit chunk is the last 5-byte chunk on
stream_type=4(read, server→client) before stream close (ADR-004, amended). In channels mode, the exit control message is the last data onstream_type=4beforechannel/closeis sent on channel 0 (alknet ADR-073 §channel/close). The ordering invariant is the same (exit before close), but the mechanism differs: 5-byte chunk ordering on stream_type 4 (direct) vs.stream_type 4ordering +channel/closeafter pump completion (channels).
What does NOT change
TtyBackendtrait,TtyHandle,TtyControl(ADR-002): unchanged. Backends don't know about channels or direct mode.DockerTtyBackend,LocalTtyBackend: unchanged. They implementTtyBackend::allocate()and return aTtyHandle.ControlMessageenum: unchanged. The JSON shape is the same in both modes.- The
alk/ttyALPN string: unchanged. Direct connections use it; channelschannel/openrequests it.
Crate dependency
alktty does not depend on alknet-channels unconditionally. The
channels-integration code is behind a channels feature on alktty.
When the feature is off, TtyAdapter only supports direct mode (the
existing behavior). When the feature is on, the adapter branches into
channels mode for channels-backed connections. This preserves alknet
ADR-003's no-handler-depends-on-another-handler rule for the default
build; the feature-gated dependency is opt-in, same as alknet-docker's
tty feature (alknet ADR-061).
Consequences
Positive:
- The TTY crate's direct mode is unchanged: existing
alk/ttydeployments (browser terminals over WebSocket, direct QUIC TTY) keep working with the 5-byte format. - The channels path uses the channels layer's de-chunking, so there is no double-chunking (5-byte inside 9-byte). The TTY adapter sees clean sub-streams.
- The
TtyBackendtrait is insulated: backends don't know which mode the adapter is in. Docker, SSH, and local backends work in both modes without changes. - The control channel and exit-chunk invariant carry forward cleanly:
the
ControlMessageenum and ordering semantics are mode-independent.
Negative:
alkttyhas two session-driving entry points (drive_session_directvsdrive_session_channels). This is the necessary cost of supporting both direct and channels modes without double-chunking. The alternative (always use channels format, even for direct) would break existing direct deployments and add 4 bytes of overhead per chunk for no benefit.- The
channelsfeature onalkttyadds a dependency edge (alktty→alknet-channels, feature-gated). This is the same pattern asalknet-docker'sttyfeature (alknet ADR-061) and is opt-in. - ADR-001's scope is amended (from "the TTY wire format" to "the TTY wire format for direct connections"). This is a scope clarification, not a format change; the 5-byte format itself is unchanged.
Door type
One-way (scope amendment) + two-way (feature gate). ADR-001's scope
amendment (direct-only) is one-way: once the channels path exists,
re-merging the formats would require unifying 5-byte and 9-byte chunk
handling, which is a rewrite. The channels feature gate is two-way
(it can be removed if channels integration is no longer needed).
Reversed by ADR-008 (2026-07-18): the two-mode design is reversed: TTY always uses its 5-byte format, carried transparently in the channels payload. The one-way door is re-cast (the channels crate is not yet implemented, so this is the right time). See ADR-008 for the amended door-type discussion.
References
- ADR-008: channels
pure channel multiplexing (reverses this ADR: TTY always uses its
5-byte format; the channels layer carries it transparently; the
two-mode design is preserved but differs only in
BiStreamsource, not in parsing) - ADR-001: alktty wire format (amended; scoped to direct connections by this ADR; re-amended by ADR-008: TTY always uses its 5-byte format, in both direct and inside-channels modes)
- ADR-002: TtyBackend trait and TtyHandle (unchanged by this ADR)
- ADR-004: exit-chunk-is-last (generalized by this ADR + alknet ADR-073)
- ADR-006: alktty does not depend on alkcall's internal wire types (preserved: the channels feature is on alknet-channels, not alkcall's internal wire types)
- alknet ADR-071: channels wire format (the 9-byte format the channels
path uses; amended by alknet ADR-093 / ADR-008: 8-byte format, no
stream_type) - alknet ADR-074: ChannelBidiStreamSource /
into_sub_streams(the accessor the channels path uses; amended by alknet ADR-093 / ADR-008:into_sub_streams()removed) - alknet ADR-092:
BiStreamas the handler leaf (the transport-leaf decision that enables the reversal:accept_bireturnsBiStream) - alknet ADR-061: DockerTtyBackend in alknet-docker (the feature-gated dependency pattern this ADR mirrors)
- alknet channels phase-0 findings §DP-3, §OQ-CH-02, §Relationship to Existing Crates / alktty
- Port origin: alknet ADR-077 at
/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md