The previous draft was mixing two layers (transport leaf and stream_type multiplexing) and including side-topics (WsBidiStream home, etc.) that weren't load-bearing, which confused agents into conflating tokio::io:: join/split (ADR-092's layer, settled) with the demux/mux stream_type layer (this doc's layer, in progress). Rewrite to be focused: - Layering section upfront separates transport leaf (ADR-092, settled), multiplexing (this doc), and channel protocol (ADR-072/073, settled). The two questions that got conflated are explicitly separated. - Drop the ADR-092 recap (it's in the ADR, not this doc's concern). - Drop the 'five abstractions' table (ADR-092's framing, not this doc's). - Drop the WS open question (irrelevant to multiplexing). - Record that POC 1 (stderr split/recombine) is already answered by the existing POC evidence: per-stream_type independent demux/mux (verified in demux.rs:91-109, 161-181 and mux.rs:58-63, 152-177) means the 'unused write half' is an idle mpsc channel, not a wart. The mod-2 framing is trivially clean. No new POC needed. - The mod-2-vs-mod-3 question is settled by existing evidence; ADR-093 is ready to draft. - The one open question that benefits from a POC is POC 2 (TTY-direct-as-channels, for the format-convergence / retire-5-byte call). POC 3 (recursive composition) is low leverage, deferred. - The TTY control channel flaw is flagged as implementation-lag, not a design question (fix specified in ADR-077, subsumed by mod-4 instance framing). This drops the scope to what the doc is actually about: the stream_type convention and the TTY/channels convergence.
16 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-18 |
stream-unification — Findings: the stream_type convention and the TTY/channels convergence
Status: Draft findings, iterating. Per the research-then-sync
pattern, this doc iterates in docs/research/; we fix inter-document
drift here, then sync to docs/architecture/ and the ADRs only after
it settles.
Scope: The multiplexing layer — the stream_type space within a
channel. This is above the transport leaf (ADR-092, settled) and
below the channel-protocol layer (ADR-072/073). The transport leaf
(BiStream as the handler-facing duplex type) is settled in ADR-092
and is not re-litigated here.
Date: 2026-07-18
Layering (to keep the questions separate)
| Layer | Question | Status |
|---|---|---|
| Transport leaf | What type does accept_bi return? How does a handler get a duplex byte stream? |
Settled — ADR-092 (drafted, pushed). accept_bi returns BiStream; from_bidi is the only public stream constructor; the split never crosses a crate boundary as part of a constructor. |
| Multiplexing (this doc) | How are stream_type values assigned within a channel? What's the addressing convention? |
In progress. ADR-071's mod-3 group framing vs the proposed mod-2/mod-4 instance framing. |
| Channel protocol | How are channels opened/closed? What's channel 0? | Settled — ADR-072/073. Channel 0 = alknet/call (hardcoded); channels 1..N opened via channel/open. Not re-litigated here. |
The two findings-doc questions that got conflated in the previous draft, separated:
- Transport-leaf split/recombine ("can
tokio::io::join/splitrecombine halves from different sources?") — ADR-092's layer. Answer: yes, stdlibtokio::io::join/splitis a pure type combinator; the halves don't need to come from the same source. Settled; not this doc's concern. - Stream_type split/recombine ("if stderr is its own instance
with an unused write half, does the unused half cause problems?")
— this doc's layer. Answer (verified in the existing POC code,
alknet-channels-poc/src/demux.rs:91-109, 161-181andmux.rs:58-63, 152-177): the demux/mux is per-stream_typeindependent — there is no pairing assumption in the mechanism. An unusedstream_typeis an idle mpsc channel, not a wart. The mod-2 framing is trivially clean. No POC needed — the load-bearing property is already proven by the 28-test POC.
The core question: stream_type assignment convention
The mechanism (per-stream_type independent demux/mux, validated by
the channels POC) routes by (channel_id, stream_type) regardless of
convention. The convention choice — how stream_type values are
assigned to roles — is a documentation/ergonomics choice, not a
mechanism choice. Both conventions work; the question is which is
cleaner.
ADR-071's current convention: mod-3 groups
| 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 (err).
The flaw: stderr is structural. The +2 = err slot bakes a
unidirectional role (stderr is server→client only) into a
bidirectional group structure (in/out/err). Every group either
allocates an err slot it may not use (tunnel has no err) or skips the
group structure. The group concept (data vs control) is real, but the
3-slot shape is asymmetric — bidirectional things are two halves, but
the group has three slots, one of which is a unidirectional leaf.
Proposed convention: mod-2/mod-4 by instance
An instance is a self-contained bidirectional unit, addressed as
a contiguous block of stream_type values 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_type = 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 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 a 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 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. Huge; the load-bearing count
for the recursive-multiplexing property below.
Why mod 2 wins
- Uniformity. Every bidirectional thing is exactly two stream_types (write, read). Control is a pair; io is a pair; future sub-streams are pairs. No "err is a third half" asymmetry.
- The "unused write half" wart is trivially clean (verified in
the existing POC code — see "Stream_type split/recombine" above).
An unused
stream_typeis an idle mpsc channel: no dangling sender, no premature EOF (EOF fires on sender drop or zero-length sentinel), no flow-control weirdness (independent bounded buffers). The lenient unknown-stream_typepath (demux.rs:161-174) means even a stray write to an unallocatedstream_typeis dropped with a counter bump, not a panic. - Stderr is just another instance. PTY mode (stderr merged into stdout server-side) declares instance 0 only. Pipe mode (separate stderr) declares instance 0 (io) + instance 1 (stderr-as-bidirectional, write half unused). The unused write half costs one idle mpsc channel; cheap. No structural asymmetry.
- The demux/mux doesn't care. The convention is documentation;
the mechanism routes by
(channel_id, stream_type)regardless. Both conventions work; mod 2 is the cleaner documentation.
The mod-2-vs-mod-3 question is settled by the existing POC evidence. No new POC needed. The mechanism supports both; mod 2 wins on uniformity, and the "unused write half" property that made mod 2 look like a wart is trivially clean in the actual code.
The TTY control channel flaw (separate, already specified)
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 — the "control isn't actually
bidirectional" flaw. ADR-071 §stream_type decomposition already fixes
this at the wire-format level (stream_types 3 = ctrl_in, 4 = ctrl_out);
ADR-077 amends ADR-052 to add stream_type 4. The TTY code hasn't been
updated — it still has the old "bidirectional 3" comment and the > 3
bound.
This is an implementation-lag, not a design question. The fix is specified; the code needs to catch up. The mod-2/mod-4 instance framing subsumes this fix: control becomes instance 0's ctrl_in/ctrl_out pair (types 2/3 under mod 4), not a standalone "bidirectional" stream_type.
TTY/channels convergence
Channels was written after TTY as a natural extension: TTY's 5-byte
header + channel_id:u32 prefix = the 9-byte channels header. The
convergence has two parts.
Semantic convergence (independent of format)
Regardless of format choice, TTY and channels present the same shape
to handlers: a set of unidirectional sub-streams declared per-channel
at channel/open time, paired into bidirectional instances where the
handler wants a joined BiStream. into_sub_streams() (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.
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).
Format convergence (the open question)
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. One
demux implementation, one set of stream_type semantics, one crate.
Sub-options for the channel_id:
- B1: TTY-direct is a channels connection with
channel_id = 0=alknet/tty(breaks 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.
Option B is the bigger call. It's a wire-format change with backward-compat implications. It's the one open question that benefits from a POC — see below.
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. 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
POC 1: stderr split/recombine — already answered, no POC needed
Original framing: "If stderr is its own instance (mod 2, write half unused), does the unused write half cause problems — dangling sender, premature EOF, flow-control weirdness?"
Answer (verified in existing POC code): No. The demux/mux is
per-stream_type independent — there is no pairing assumption in the
mechanism. An unused stream_type is an idle mpsc channel. The
lenient unknown-stream_type path drops stray writes with a counter
bump. The 28-test channels POC already validated per-stream_type
independence (demux_three_concurrent_channels_no_cross_contamination,
demux_unknown_channel_drops_lenient, recv_dropped_sender_is_eof).
Status: Confirmatory, not exploratory. The load-bearing property is already proven. The mod-2 framing is trivially clean. No new POC.
POC 2: TTY-direct-as-channels — the one open question
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 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 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.
Status: Deferred. Not a POC for this round.
What goes where (ADR plan)
| ADR | Scope | Status |
|---|---|---|
| ADR-092 | Transport leaf: BiStream as the handler leaf; accept_bi returns BiStream; from_stream removed; from_bidi is the only public stream constructor. |
Drafted, pushed (f8d4650, 528cfa0). Load-bearing, separable. |
| ADR-093 | Multiplexing: mod-2/mod-4 instance framing; per-channel stream_type declaration; into_sub_streams() as the primary accessor; demux convention-agnostic. Amends ADR-071 (stream_type decomposition), ADR-074 (into_sub_streams), ADR-077 (TTY uses mod 2; control channel fixed). |
Ready to draft. The mod-2-vs-mod-3 question is settled by existing POC evidence; the control channel fix is already specified in ADR-077; the instance framing is the cleaner documentation. |
| ADR-094 | Format convergence: retire the 5-byte TTY-direct format in favor of 9-byte channels format. Option A (keep 5-byte), B1 (channel 0 = TTY), or B2 (channel 0 = call unused, channel 1 = TTY). Backward-compat analysis. | Pre-ADR, drafting after POC 2 recommends an Option. |
Open questions
- 5-byte format retirement. POC 2 resolves. 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.
References
- ADR-092:
BiStreamas the handler leaf (the transport-leaf layer, settled; this doc is the layer above it) - 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-072: channel 0 pre-negotiated as
alknet/call(the hardcoded channel_id constraint) - ADR-073: channel lifecycle operations (the
stream_typesfield declared atchannel/opentime — the per-channel declaration that makes the demux convention-agnostic) docs/research/alknet-channels/poc-summary.md— the channels POC (28 tests) that validated per-stream_typeindependence/workspace/alknet-channels-poc/src/demux.rs:91-109, 161-181— the demux's per-stream_typerouting (no pairing assumption)/workspace/alknet-channels-poc/src/mux.rs:58-63, 152-177— the mux's per-(channel_id, stream_type)independent pumpscrates/alknet-tty/src/wire.rs:13,32-33— theSTREAM_CONTROL = 3"bidirectional" flaw (implementation lag; fix specified in ADR-077, subsumed by the mod-4 instance framing)crates/alknet-tty-local/tests/pipe.rs:363 separate_stderr— the test that proves separate stderr is a real feature, not vestigial (resolves under mod 2 as instance 1 with unused write half)