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)
24 KiB
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:
-
Transport leaf is split.
accept_bireturns(SendStream, RecvStream); every handler that wants a duplex stream re-joins (HTTP'sQuicStream, 44 lines) or bypasses (WS'sWsStream). Five abstractions exist for "a bidirectional byte stream." Resolved by ADR-092 (drafted, pushed):accept_bireturns oneBiStream; the join moves into core's quinn/iroh impls once; the split never crosses a crate boundary as part of a constructor (Connection::from_streamremoved,from_bidiis the only public stream constructor). -
The control channel "isn't actually bidirectional" in the current TTY code (
wire.rs:13,32-33:STREAM_CONTROL = 3is 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. -
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 uses4K/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. -
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:u32prefix = the 9-byte channels header. TTY should be rewritten to use the channels format. The one hardcodedchannel_idis channel 0 =alknet/call(ADR-072); everything else is dynamic. Whether TTY-direct retires the 5-byte format entirely (using 9-byte withchannel_id = 0orchannel_id = 1) is a bigger call — backward compat — flagged as a POC candidate and a possible ADR-094.
- a
-
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_bireturnBiStream, not(SendStream, RecvStream).BiStreamis a concrete newtype (internalBox<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
BidiStreamSourceimpls once (viatokio::io::joinor equivalent). Connection::from_bidiis 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/RecvStreamcollapse to thin newtypes overBox<dyn Async* + Send + Unpin>, retained only forinto_sub_streams(ADR-074) and the channels reassembly path'sSubStreamHandleleaves.HttpAdapter::handledropsQuicStream(44 lines) andQuicStreamDuplex(38 lines).- WS runs through
Connection::from_bidi(WsBidiStream)+ the call-protocol handler;WsStreamtrait,drive_ws_sessionloop, 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:
- 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 atchannel/opentime (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. - 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.
- 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_stderrtest asserts stderr arrives as distinct chunks; the Dockerexec/attachAPI exposesTty: true(merge) vsTty: 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, nochannel/openissued) +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
WsBidiStreamas 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:
BiStreamas 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:
BidiStreamSourcetrait (the extension point ADR-092 amends) - ADR-065:
Connection::from_stream/from_bidi(amended by ADR-092 —from_streamremoved,from_bidionly) - ADR-078: two-pump shutdown-on-completion (preserved by ADR-092)
docs/research/alknet-crate-extraction/findings.mdPhase 6 — the deferredalknet-httprework; ADR-092 resolves the deferraldocs/research/alknet-channels/poc-summary.md— the channels POC (28 tests, WASM compile check); the shape ADR-093 builds oncrates/alknet-tty/src/wire.rs:13,32-33— theSTREAM_CONTROL = 3"bidirectional" flaw ADR-093 fixescrates/alknet-tty-local/tests/pipe.rs:363 separate_stderr— the test that proves separate stderr is a real feature, not vestigial