Files
alknet/docs/research/stream-unification/findings.md
T
glm-5.2 909935ded3 docs(research): add stream-unification findings — the leaf, the instance, the convergence
Captures the deep dive that started as the alknet-crate-extraction
Phase 6 tangle and surfaced a layered issue:

1. Transport leaf split (ADR-092, drafted+pushed separately) — accept_bi
   returns BiStream, from_stream removed, from_bidi is the only public
   stream constructor. Load-bearing, separable.
2. Control channel 'isn't actually bidirectional' in TTY code (wire.rs
   STREAM_CONTROL=3 one stream both sides write). ADR-071 already fixes
   at wire-format level (3=ctrl_in, 4=ctrl_out); TTY code lags.
3. ADR-071's mod-3 stream_type decomposition is structural but
   asymmetric (stderr baked into group shape). Cleaner framing is
   mod 2/mod 4 by instance: an instance is a bidirectional unit addressed
   as a contiguous block of stream_types. No control: 128
   instances/channel (mod 2). With control: 64 instances/channel
   (mod 4). Combined address space ~255*128 or ~255*64.
4. TTY and channels should converge on one format. Channels was
   written after TTY as a natural extension (5-byte + channel_id:u32 =
   9-byte). Whether TTY-direct retires the 5-byte format is a bigger
   call — backward compat — flagged as POC candidate.
5. Recursive multiplexing follows: each instance can be a channels
   connection. Unbounded, uniform per level via the instance framing.

Following the research-then-sync pattern: iterate here, fix
inter-document drift, sync to specs only after it settles. ADR-092 is
pushed because it's load-bearing and separable; ADR-093 (multiplexing
redesign) and ADR-094 (retire 5-byte format) draft after POCs validate.

POC candidates (ordered by leverage):
- POC 1: stderr split/recombine (load-bearing for mod 2 vs mod 3)
- POC 2: TTY-direct-as-channels (load-bearing for format convergence)
- POC 3: recursive composition (low leverage, deferred)
2026-07-18 04:59:23 +00:00

24 KiB
Raw Blame History

status, last_updated
status last_updated
draft 2026-07-18

stream-unification — Findings: the leaf, the instance, the convergence

Status: Draft findings, iterating. This doc is the working scratch for the stream/multiplexing redesign that surfaced during the alknet-crate-extraction Phase 6 deep dive. It is not synced to the architecture specs — per the research-then-sync pattern, we iterate here, fix inter-document drift, and only then sync to docs/architecture/ and the ADRs. ADR-092 (BiStream as the handler leaf) has been drafted and pushed because it's load-bearing and separable; everything else in this doc is pre-ADR and will produce ADR-093 (the multiplexing/stream_type redesign) and possibly ADR-094 (retire the 5-byte TTY-direct format) once it settles.

Date: 2026-07-18 Scope: (a) the transport-leaf unification (ADR-092, drafted); (b) the stream_type / multiplexing redesign that ADR-092 enables; (c) the TTY/channels convergence; (d) the POC candidates that would take guesswork out before committing.


TL;DR

The deep dive surfaced a layered tangle:

  1. Transport leaf is split. accept_bi returns (SendStream, RecvStream); every handler that wants a duplex stream re-joins (HTTP's QuicStream, 44 lines) or bypasses (WS's WsStream). Five abstractions exist for "a bidirectional byte stream." Resolved by ADR-092 (drafted, pushed): accept_bi returns one BiStream; the join moves into core's quinn/iroh impls once; the split never crosses a crate boundary as part of a constructor (Connection::from_stream removed, from_bidi is the only public stream constructor).

  2. The control channel "isn't actually bidirectional" in the current TTY code (wire.rs:13,32-33: STREAM_CONTROL = 3 is one stream_type both sides write to). ADR-071's mod-3 stream_type decomposition already fixes this at the wire-format level (stream_types 3 = ctrl_in, 4 = ctrl_out), but the TTY code hasn't been updated. ADR-077 amends ADR-052 to add stream_type 4, but the implementation lags.

  3. ADR-071's mod-3 stream_type decomposition is structural but asymmetric. "Data group 0/1/2 = in/out/err; control group 3/4/5 = ctrl_in/ctrl_out/ctrl_err" bakes stderr into the group structure. The cleaner framing is mod 2 by instance: an instance is a self-contained bidirectional unit, addressed as a contiguous block of stream_types. No control: instance K uses 2K/2K+1 (128 instances/channel). With control: instance K uses 4K/4K+1/4K+2/4K+3 (64 instances/channel). The stream_type space is the instance address space, not a role space. This is richer than "control is just another pair" — the instance is the addressing unit.

  4. TTY and channels should converge on one format. Channels was written after TTY and was a natural extension — TTY's 5-byte header

    • a channel_id:u32 prefix = the 9-byte channels header. TTY should be rewritten to use the channels format. The one hardcoded channel_id is channel 0 = alknet/call (ADR-072); everything else is dynamic. Whether TTY-direct retires the 5-byte format entirely (using 9-byte with channel_id = 0 or channel_id = 1) is a bigger call — backward compat — flagged as a POC candidate and a possible ADR-094.
  5. The recursive multiplexing property follows. A channels connection with N channels, each with up to 128 (or 64) instances, has N×128 (or N×64) logical sub-streams. Combined address space (channel_id × instance) is ~255×128 or ~255×64. An instance can itself be a channels connection (recursive composition, ADR-074). The "channel within a channel within a channel" shape is unbounded, and the mod-2/mod-4 instance framing makes each level uniform.

POC candidates (ordered by leverage): stderr split/recombine (load-bearing for the mod-2 vs mod-3 decision); TTY-direct-as-channels (load-bearing for the format-convergence decision); recursive composition (low leverage — already implied by the abstraction, just needs validation).


The tangle, restated

Five abstractions for "a bidirectional byte stream" before ADR-092:

# Abstraction Where Status
1 BiStream trait (AsyncRead + AsyncWrite + Send + Unpin) crates/alknet-core/src/types.rs:226 Vestigial in code — zero consumers. Resurrected by ADR-092 as a concrete newtype.
2 Connection (yields (SendStream, RecvStream) via accept_bi) crates/alknet-core/src/types.rs:507 Handler-facing. Leaf split. ADR-092 changes accept_bi to return BiStream.
3 SendStream (AsyncWrite-only) + RecvStream (AsyncRead-only) crates/alknet-core/src/types.rs:228-294 Quinn-welded enums. ADR-092 collapses to thin newtypes, retained for into_sub_streams().
4 WsStream trait (recv/send axum::ws::Message) crates/alknet-http/src/websocket/upgrade.rs:44 Bypasses Connection. ADR-092 replaces with WsBidiStream through Connection::from_bidi.
5 MpscSendStream / MpscRecvStream (channels POC) /workspace/alknet-channels-poc/src/mpsc_stream.rs Split mpc-backed halves. ADR-092: channels crate joins via tokio::io::join before from_bidi.

The two findings.md Phase 6 issues (QuicStream wrapper, WS bespoke dispatch) are symptoms of the split leaf. ADR-092 addresses the transport seam; this doc addresses the multiplexing seam that ADR-092 exposes.


ADR-092 recap (what's settled)

  • accept_bi / open_bi return BiStream, not (SendStream, RecvStream).
  • BiStream is a concrete newtype (internal Box<dyn AsyncReadWrite + Send + Unpin>), not the bare ADR-007 trait (which is removed; the bounds survive as implied bounds).
  • The join moves into core's quinn/iroh BidiStreamSource impls once (via tokio::io::join or equivalent).
  • Connection::from_bidi is the only public stream constructor. Connection::from_stream(send, recv, ...) is removed — the split never crosses a crate boundary as part of a constructor.
  • SendStream / RecvStream collapse to thin newtypes over Box<dyn Async* + Send + Unpin>, retained only for into_sub_streams (ADR-074) and the channels reassembly path's SubStreamHandle leaves.
  • HttpAdapter::handle drops QuicStream (44 lines) and QuicStreamDuplex (38 lines).
  • WS runs through Connection::from_bidi(WsBidiStream) + the call-protocol handler; WsStream trait, drive_ws_session loop, and ~150 lines of dispatch glue removed.
  • "VPN-like without being a VPN" over WS in v1 becomes real (webtransport.md's path, over WS).
  • WebTransport h3 extraction is recorded as a future channels-variant move enabled by the unification (out of scope per ADR-044).

ADR-092 is pushed (f8d4650, amended 528cfa0). It is load-bearing and separable from the multiplexing redesign below.


What ADR-092 exposes: the multiplexing seam

With the transport leaf unified, the remaining asymmetry is in the stream_type space — the multiplexing layer above the transport.

ADR-071's mod-3 decomposition (current)

ADR-071 groups stream_types in threes:

Group stream_type direction purpose
Data 0 write in (stdin)
1 read out (stdout)
2 read err (stderr)
Control 3 write ctrl_in
4 read ctrl_out
5 read ctrl_err (optional)
Future 6/7/8, 9/10/11, ... write/read/read next groups

Formula: stream_type % 3 == 0 → write, % 3 == 1 → read, % 3 == 2 → diagnostic read.

The flaw: stderr is structural. The "err is the third half" bakes stderr into the group shape. The "group" concept is real (data vs control) but the +2 = err slot is asymmetric — a bidirectional thing is two halves, but the group has three slots, one of which is "err." This forces every group to either allocate an err slot it may not use (tunnel has no err) or skip the group structure entirely.

The control channel flaw in the TTY code

crates/alknet-tty/src/wire.rs:13,32-33:

STREAM_CONTROL: u8 = 3   // "bidirectional, JSON control message"
InvalidStreamType > 3

One stream_type both sides write to. That's the "control isn't actually bidirectional" flaw ADR-071 §stream_type decomposition calls out: one stream both sides write loses independent flow control, independent EOF, and clean separation. ADR-077 amends ADR-052 to add stream_type 4 (ctrl_out), splitting control into 3 (in) + 4 (out). The TTY code hasn't been updated — it still has the old "bidirectional 3" comment and the > 3 bound. The update is needed regardless of the mod-2/mod-3 decision.

The implicit split (the key insight)

Bidirectionality is always two unidirectional halves. There is no "bidirectional stream_type" — bidirectionality is two stream_types (write, read), the same way QUIC bidi streams are two unidirectional halves, the same way tokio::io::split(BiStream) is two halves, the same way SendStream/RecvStream after ADR-092 is two halves. The stream_type space is inherently unidirectional; the bidirectional thing is the pair.

This means: TTY and channels present the same shape to their handlers. A set of unidirectional sub-streams, paired into bidirectional channels where the handler wants a joined pair. TTY wants five unidirectional leaves (stdin, stdout, stderr, ctrl_in, ctrl_out); the tunnel wants two paired into one BiStream (tokio::io::join(send_0, recv_1)). The handler chooses how to compose the leaves; the channels layer just exposes them.


The instance framing (mod 2 / mod 4)

An instance is a self-contained bidirectional unit, addressed as a contiguous block of stream_types within a channel.

No control channel — mod 2:

Instance in (write) out (read)
0 0 1
1 2 3
2 4 5
...
127 254 255

128 instances per channel. Instance K uses stream_types 2K (in) and 2K+1 (out).

With control channel — mod 4:

Instance in (write) out (read) ctrl_in (write) ctrl_out (read)
0 0 1 2 3
1 4 5 6 7
2 8 9 10 11
...
63 252 253 254 255

64 instances per channel. Instance K uses stream_types 4K (in), 4K+1 (out), 4K+2 (ctrl_in), 4K+3 (ctrl_out).

The stream_type space is the instance address space, not a role space. A handler talks about "instance K of my channel" as a contiguous block of stream_types. This is richer than ADR-071's "data group / control group" framing — the instance is the addressing unit, and the handler can have N independent bidirectional sub-streams on one channel, each with or without its own control.

Combined address space: channel_id × instance. Channel 0 is hardcoded as alknet/call (ADR-072), so channels 1..255 are dynamic — ~255 × 128 (no control) or ~255 × 64 (with control) logical sub-streams per channels connection. That's a lot, and it's the load-bearing count for the "recursive multiplexing" point below.

The "ignore stderr for a moment" resolution. Stderr is the one feature that breaks the pure mod-2 framing — it's a unidirectional read (server→client), not a bidirectional pair. Three options:

  1. Stderr as its own instance (mod 2). Instance 0 = io, instance 1 = stderr-as-bidirectional (write half unused, declared but never written). The demux routes by (channel_id, stream_type) regardless of convention; the per-channel declaration at channel/open time (ADR-073 already has this field) is the only truth. PTY mode (stderr merged) declares instance 0 only; pipe mode declares instances 0 and 1. One unused stream_type per stderr-carrying channel; cheap.
  2. Stderr as a unidirectional leaf (the asymmetric case). The per-channel declaration allows unidirectional leaves — not every stream_type has to be part of a pair. Stderr rides a single stream_type (e.g., type 5), declared read-only. The demux doesn't care; the convention is "pairs are the default, unidirectional leaves are explicitly declared." This keeps mod 2 as the bidirectional convention without forcing stderr into a pair.
  3. Combine stderr into stdout (the Docker PTY choice). Always merge; lose separate stderr. This loses the pipe/non-PTY mode feature — tty-local/pipe.rs:363 separate_stderr test asserts stderr arrives as distinct chunks; the Docker exec/attach API exposes Tty: true (merge) vs Tty: false (separate). Not acceptable for the non-PTY use case.

The stderr question is load-bearing for mod 2 vs mod 3. If stderr-as-its-own-instance (option 1) is clean, mod 2 wins — every bidirectional thing is a pair, stderr is a pair with one unused half, no asymmetry. If the unused half is a real wart, the per-channel declaration with unidirectional leaves (option 2) wins, and the mod-2/mod-3 distinction is less load-bearing than "the declaration is the truth, the convention is a hint." This is a POC candidate (see below).


TTY/channels convergence

Channels was written after TTY and was a natural extension: TTY's 5-byte header + channel_id:u32 prefix = the 9-byte channels header. The convergence has two parts.

Format convergence

TTY-direct uses the 5-byte format (ADR-052, scoped to direct by ADR-077). Channels uses the 9-byte format (ADR-071). TTY-inside-channels uses the 9-byte format. The 5-byte format is the 9-byte format with the channel_id prefix elided (because TTY-direct has only one channel).

Option A (current, ADR-077): two formats coexist. TTY-direct keeps the 5-byte format; TTY-inside-channels uses 9-byte. The 4-byte channel_id overhead is paid only inside channels.

Option B (retire the 5-byte format): TTY-direct uses 9-byte with channel_id = 0. One demux implementation, one set of stream_type semantics, one crate. The 4-byte overhead per chunk is noise for the TTY use case (terminal I/O, not bulk transfer). But: backward-compat with existing TTY-direct deployments, and the "channel 0 is always call" rule (ADR-072) conflicts — TTY-direct's single channel is alknet/tty, not alknet/call. Resolutions:

  • B1: TTY-direct is a channels connection with channel_id = 0 = alknet/tty (breaking ADR-072's "channel 0 is call" rule).
  • B2: TTY-direct is a channels connection with channel_id = 0 = alknet/call (unused, no channel/open issued) + channel_id = 1 = alknet/tty (pre-allocated). Pays one unused channel for rule consistency. Channel 0 is always call, even when unused.
  • B3: TTY-direct is not a channels connection at all; it keeps the 5-byte format (Option A). The 9-byte format applies only inside channels. (Same as current ADR-077.)

Option B is the bigger call. It's a wire-format change with backward-compat implications. It's a POC candidate (validate B1/B2 in a minimal implementation) and a possible ADR-094. Not in scope for the mod-2/mod-4 stream_type redesign (ADR-093).

Semantic convergence (independent of format)

Regardless of Option A/B, TTY and channels should present the same shape to their handlers: a set of unidirectional sub-streams declared per-channel, paired into bidirectional instances where the handler wants a joined BiStream. The into_sub_streams() accessor (ADR-074) returns the declared stream_types as Vec<(u8, SubStreamHandle)> with SubStreamHandle::Send (write half) or Recv (read half). The handler joins the pairs it wants via tokio::io::join. The channels layer exposes the leaves; the handler composes them.

This means ADR-074's two paths (accept_bi for the joined 0/1 pair, into_sub_streams for the typed leaves) become the same data, presented differently. accept_bi is a convenience that joins 0/1 for the common case (tunnel, SSH, echo, HTTP); into_sub_streams is the primary accessor (the unidirectional leaves). Both are the same data; the handler chooses how to compose.


The recursive multiplexing property

A channels connection with N channels, each with up to 128 (or 64) instances, has N×128 (or N×64) logical sub-streams. An instance can itself be a channels connection (recursive composition, ADR-074 — a channel with ALPN alknet/channels inside alknet/channels). The "channel within a channel within a channel" shape is unbounded, and the mod-2/mod-4 instance framing makes each level uniform — every level is "pairs of stream_types (or 4-tuples with control), declared per-channel, addressed by instance."

This is a property, not a feature. The primary use case is one level of multiplexing. Recursive composition is a natural consequence of the abstraction, not a goal. Recorded here because the instance framing makes it cleaner than ADR-071's group framing did — the instance is the recursive unit, the group was not.


POC candidates

Ordered by leverage (the load-bearing unknowns that, once validated, make the ADR drafting mechanical rather than speculative).

POC 1: stderr split/recombine (load-bearing for mod 2 vs mod 3)

Question: Can a tokio::io::split half be recombined with a different half cleanly? Specifically: if stderr is its own instance (mod 2, write half unused), does the unused write half cause any real problem — dangling sender, premature EOF on the read half, flow-control weirdness? Or does declaring "instance 1 = stderr, write half unused" just work?

Why it matters: If the unused write half is clean, mod 2 wins (every bidirectional thing is a pair, stderr is a pair with one unused half, no asymmetry, no mod 3). If it's a wart, the per-channel declaration with unidirectional leaves (option 2) wins, and the mod-2/mod-3 distinction is less load-bearing than "the declaration is the truth."

Scope: ~100 lines. A test-only POC in the channels POC's shape: declare a channel with two instances, instance 0 = io pair (types 0/1), instance 1 = stderr (types 2/3, write half never written). Verify the read half on instance 1 delivers stderr chunks; the write half never errors; the demux doesn't choke on an unused write stream_type. Run it over the POC's tokio::io::duplex stand-in.

Output: A docs/research/alknet-channels/poc-stderr-split.md summary with a yes/no answer and the code path. If yes, ADR-093 drafts with mod 2. If no, ADR-093 drafts with per-channel declaration as the primary, mod 2 as a convention hint.

POC 2: TTY-direct-as-channels (load-bearing for format convergence)

Question: Is Option B1 (TTY-direct = channels with channel_id = 0 = alknet/tty, breaking ADR-072) or Option B2 (channel 0 = call unused, channel 1 = TTY) feasible and clean? Or is the 4-byte channel_id overhead per chunk in TTY-direct mode a real concern for terminal I/O? And is the backward-compat with existing TTY-direct deployments a real constraint, or are there no existing deployments to break?

Why it matters: If Option B is clean and backward-compat is non-binding, the 5-byte format retires, the demux implementation unifies, and the TTY crate's direct mode becomes a thin wrapper. If backward-compat is binding or the overhead is a concern, Option A (two formats coexist per ADR-077) stands.

Scope: ~200 lines. A POC that runs the TTY adapter over a channels-format demux with channel_id = 0 (B1) and channel_id = 1 (B2), using the existing TTY TtyBackend (the local pipe backend). Verify the pty.rs / pipe.rs tests still pass in shape. Measure chunk overhead if relevant.

Output: A docs/research/alknet-channels/poc-tty-direct-as-channels.md summary. Recommends Option A, B1, or B2. If B, becomes the input to ADR-094 (retire the 5-byte format). If A, ADR-077 stands and the 5-byte format is kept.

POC 3: recursive composition (low leverage, deferred)

Question: Does alknet/channels inside alknet/channels actually work end-to-end? The abstraction permits it (ADR-074); the POC didn't validate it (poc-summary.md §"What the POC Does NOT Validate" #5).

Why it matters: Low leverage — the instance framing makes it cleaner, but the primary use case is one level. Recursive composition is a property, not a feature.

Scope: Deferred. Not a POC for this round. Recorded for completeness.


What goes where (ADR plan)

ADR Scope Status
ADR-092 BiStream as the handler leaf; accept_bi returns BiStream; Connection::from_stream removed; from_bidi is the only public stream constructor Drafted, pushed (f8d4650, amended 528cfa0). Load-bearing, separable.
ADR-093 stream_type as the unidirectional leaf; mod 2 / mod 4 instance framing; per-channel stream_type declaration; into_sub_streams() as the primary accessor; demux convention-agnostic. Amends ADR-071 (wire format stream_type decomposition), ADR-074 (into_sub_streams), ADR-077 (TTY uses mod 2, control channel fixed). Pre-ADR, drafting after POC 1 (stderr split) validates the mod-2 decision.
ADR-094 Retire the 5-byte TTY-direct format in favor of 9-byte channels format with channel_id = 0 (or channel_id = 1 with channel 0 = call unused). Backward-compat analysis. Pre-ADR, drafting after POC 2 (TTY-direct-as-channels) recommends Option B. If POC 2 recommends Option A, ADR-094 is not drafted and ADR-077 stands.

ADR-092 is separable from 093/094 — it's the transport seam, not the multiplexing seam. ADR-093 is the multiplexing redesign that ADR-092 exposes. ADR-094 is the format-convergence call that ADR-093 enables but doesn't require.


Open questions (for the research iteration)

  • Stderr as instance vs unidirectional leaf. POC 1 resolves this. Default assumption: stderr as instance (mod 2, unused write half is clean). POC validates.
  • Mod 2 vs mod 3. If POC 1 confirms the unused-write-half is clean, mod 2 wins. If not, the per-channel declaration becomes primary and the mod distinction is a hint, not a rule.
  • 5-byte format retirement. POC 2 resolves this. Default assumption: Option A (two formats coexist, ADR-077 stands) is safer; Option B (retire 5-byte) is cleaner but a wire-format change. POC validates feasibility and overhead.
  • channel 0 is always call (ADR-072) under TTY-direct-as-channels. If POC 2 recommends B1 (TTY-direct channel 0 = alknet/tty), ADR-072 needs an exception or amendment. If B2 (channel 0 = call unused, channel 1 = TTY), ADR-072 stands. If A (keep 5-byte), no change.
  • Recursive composition. Deferred. The instance framing makes it cleaner, but it's not a goal. POC 3 is low leverage.
  • WS-as-BiStream for the browser WASM path. ADR-092 records WsBidiStream as the server-side adapter; the browser-side adapter is a separate concern (the WASM SDK, not alknet-http). Where does the browser-side adapter live? Default: separate, not alknet-http. Resolved at implementation time.

References

  • ADR-092: BiStream as the handler leaf (drafted, pushed)
  • ADR-071: channels wire format (the mod-3 stream_type decomposition ADR-093 amends)
  • ADR-074: ChannelBidiStreamSource / into_sub_streams (the per-channel sub-stream accessor ADR-093 amends)
  • ADR-077: TTY inside channels (the two-mode TTY design ADR-093 amends; the 5-byte format scoping ADR-094 may retire)
  • ADR-070: BidiStreamSource trait (the extension point ADR-092 amends)
  • ADR-065: Connection::from_stream / from_bidi (amended by ADR-092 — from_stream removed, from_bidi only)
  • ADR-078: two-pump shutdown-on-completion (preserved by ADR-092)
  • docs/research/alknet-crate-extraction/findings.md Phase 6 — the deferred alknet-http rework; ADR-092 resolves the deferral
  • docs/research/alknet-channels/poc-summary.md — the channels POC (28 tests, WASM compile check); the shape ADR-093 builds on
  • crates/alknet-tty/src/wire.rs:13,32-33 — the STREAM_CONTROL = 3 "bidirectional" flaw ADR-093 fixes
  • crates/alknet-tty-local/tests/pipe.rs:363 separate_stderr — the test that proves separate stderr is a real feature, not vestigial