Files
alknet/docs/research/stream-unification/findings.md
T
glm-5.2 f03e38326c docs(research): resolve 8-vs-9-byte question — channels wire format is 8 bytes
Settle the open question: channels header is [channel_id:u32][length:u32]
(8 bytes) with opaque payload. The 9-byte alternative (including
stream_type in the channels header) is rejected — it leaks a handler
concept into the channels layer. The handler owns its framing entirely
within the payload. TTY's 5-byte format composes as payload bytes;
total header for TTY inside channels is 13 bytes (8 + 5).
2026-07-18 14:04:44 +00:00

415 lines
20 KiB
Markdown

---
status: draft
last_updated: 2026-07-18
---
# stream-unification — Findings: channels as pure channel multiplexing
**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 — how channels, TTY, and other
handlers compose their framing. This is *above* the transport leaf
(ADR-092, settled) and *below* the channel-protocol layer (ADR-072/
073, settled). 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
---
## TL;DR
The previous framing — "mod 2 vs mod 3 vs mod 4 for the `stream_type`
space within a channel" — was a symptom. The actual question is the
separation of concerns between the channels layer and the handler,
and the resolution is **layered framing via strip/add**: the channels
layer routes by `channel_id` only; handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them. Every channel is a
`BiStream`. The "pass a stream to/from any ALPN" objective becomes
universal, not qualified.
The channels wire format is **8 bytes**: `[channel_id:u32 BE][length:u32 BE]`
followed by an opaque payload. The channels layer owns `channel_id`
and `length`; the payload is the handler's framing, carried
transparently. The channels layer strips its 8-byte header on read,
hands the payload to the handler. The handler parses its own framing
from the payload — TTY's `[stream_type:u8][length:u32 BE][payload]`,
call's length-prefixed JSON, tunnel's raw bytes.
This means the total wire format for a TTY chunk inside channels is:
`[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]`
(13 bytes of header: 8 channels + 5 TTY). The two length fields are
close but not identical (`ch_len = tty_len + 5`). This is a small
amount of waste per chunk, but the trade-off is clean separation:
the channels layer has no `stream_type` concept — not in its header,
not in its code, not in its mental model. The handler owns its
framing entirely. For extreme multiplexing scenarios this clean
separation is worth the few extra bytes.
This dissolves the mod 2/3/4 question at the channels layer (the
channels layer has no `stream_type` concept), fixes the "control
isn't actually bidirectional" TTY flaw at the TTY layer (TTY owns
its sub-streams), and makes recursive composition literal (each
layer strips its header, the inner layer parses its own framing
from the payload).
**No production/backward-compat constraint.** The develop branch is a
rewrite of main (which is labeled pre-alpha). The decision is purely
"what's cleanest," not "what's least disruptive."
---
## 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 do channels, TTY, and other handlers compose their framing? Who owns sub-stream multiplexing? | **In progress — this resolution.** Channels layer routes by `channel_id`; handlers own their sub-multiplexing on the `BiStream` they receive. |
| 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 structural question (the actual tangle)
The channels layer has two objectives in tension:
1. **"Pass a stream to/from any ALPN"** — every channel is a `BiStream`;
any handler gets `accept_bi()` and treats the channel as a duplex
stream. Uniform, transport-agnostic, recursive-composition-friendly.
2. **"Channels carry N sub-streams"** — a TTY channel carries
stdin/stdout/stderr/control; the handler destructs via
`into_sub_streams()`. Carries what the source produces.
The tension is real when a sub-stream is *unidirectional* (stderr).
You can't represent stderr as a `BiStream` without wasting the write
half; you can't make it a "third half" (mod 3) without breaking pair
symmetry; you can't make the channel a single `BiStream` without
losing the stdout/stderr distinction.
ADR-074's current design resolves this with two access paths
(`accept_bi` for clean-pair channels, `into_sub_streams` for
multi-stream channels), making the "pass a stream to/from any ALPN"
objective *qualified* — it applies to single-stream channels, not
multi-stream channels. The mod 2/mod 3/mod 4 question was a numbering
symptom of this qualified design.
### The resolution: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels,
routed by `channel_id`." It does not know about TTY's sub-streams,
SSH's channel protocol, or how call frames its JSON. Handlers own
their sub-multiplexing on the `BiStream` the channels layer gives
them.
- **Every channel is a `BiStream`.** `accept_bi()` yields one
`BiStream` per channel. No `into_sub_streams()`, no second-class
accessor.
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
sub-demuxes `stream_type` from its `BiStream` (5-byte format).
Tunnel uses the `BiStream` as raw bytes. Call length-prefixes JSON.
SSH runs its own channel protocol. The channels layer carries the
bytes transparently.
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.**
The channels layer has no `stream_type` concept. `stream_type` is
the inner layer's framing byte, carried transparently.
- **The control channel is handler-internal.** TTY sub-demuxes
control from its io `BiStream` using its 5-byte format. The channels
layer doesn't carry control. The "control isn't actually
bidirectional" flaw is fixed at the TTY layer (stream_type 3 =
ctrl_in, 4 = ctrl_out), not the channels layer.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`.
The outer layer strips its 8-byte header; the inner layer parses
its own 8-byte header from the payload. Each level is the same
shape — `BiStream → accept_bi → N BiStreams`.
---
## How the wire formats compose (the add/strip insight)
The channels wire format and TTY's wire format compose by layering:
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The channels layer reads its 8-byte header (`channel_id` + `length`),
reads `length` bytes of payload, and hands the payload to the handler.
The handler parses its own framing from the payload — TTY reads its
5-byte header (`stream_type` + `length`) from the payload bytes.
The two length fields are close but not identical: `ch_len = tty_len + 5`.
This is a small amount of waste per chunk (the channels `length` is
always 5 bytes more than TTY's `length`), but the trade-off is clean
separation of concerns. The channels layer has no `stream_type` concept
— not in its header, not in its code, not in its mental model.
### The add/strip utility
Each layer has its own add/strip pair:
- **Channels layer**: `add_channel_id(channel_id, payload_bytes) -> chunk`
on write (prepends `[channel_id:u32][len(payload_bytes):u32]`);
`strip_channel_id(chunk) -> (channel_id, payload_bytes)` on read.
- **TTY layer** (inside the handler): parses its 5-byte header from
the payload bytes per its existing `wire.rs`. Doesn't know or care
that a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is
SSH's model (layered headers, each layer strips its own at its
boundary), applied to channels. A `alknet/channels`-inside-
`alknet/channels` recursive composition is the outer layer stripping
its 8-byte header, the inner layer parsing its own 8-byte header from
the payload — same code, same shape, each level.
### What this means for ADR-077's rejection
ADR-077 rejected sub-multiplex inside channels on the grounds of
"double-chunking (5-byte inside 9-byte)." The actual composition under
this resolution is 13 bytes total (8 channels + 5 TTY), not 14. The
two length fields are close but not identical — the channels `length`
is always `tty_len + 5`. This is a small amount of waste per chunk,
but the trade-off is clean separation: the channels layer has no
`stream_type` concept, and the handler owns its framing entirely.
ADR-077's rejection was based on a misunderstanding of how the layers
compose. The add/strip composition makes the channels layer carry the
handler's framing transparently in the payload — no shared fields, no
leaked abstraction. The rejection is reversed by this resolution.
---
## The "merge and split" trap (what was conflated in earlier rounds)
Earlier rounds of this discussion got confused by "can we merge and
split stderr." The vocabulary is now clear:
- **Untagged interleave** (`StreamExt::merge(stdout, stderr)`) — bytes
arrive interleaved, no way to tell which came from where. The
distinction is lost. This is "true merge" — what PTY mode does, what
Docker `Tty: true` does. Once merged this way, stderr is
unrecoverable. No amount of "interleaved reading" recovers it
without a tag.
- **Tagged interleave** — each chunk carries a tag (stream_type), so
the receiver demuxes. This is what the channels layer already is
(for `channel_id`) and what TTY's sub-multiplex is (for `stream_type`).
The tag IS the framing.
"Interleaved reading" doesn't avoid stream_type; it IS stream_type.
The channels layer is tagged interleave by construction. The design
question is not "how to avoid stream_type" but "which layer owns
which tag" — and the resolution is "channels layer owns `channel_id`,
handler owns `stream_type`."
---
## Stderr under this resolution
Stderr is a handler concern, not a channels concern. Two cases:
- **PTY mode** (Docker `Tty: true`): the PTY merges stdout and stderr
into one output stream. The TTY handler sees one output stream,
sub-demuxes nothing for stderr (there is no stderr). One channel,
one `BiStream`, no stderr stream_type. Mod 2 at the TTY sub-stream
level (one pair: in/out). Clean.
- **Pipe mode** (Docker `Tty: false`): the OS gives the handler two
distinct streams (child.stdout, child.stderr). The TTY handler
sub-demuxes these onto its 5-byte format's stream_types (0 = in,
1 = out, 2 = err). The channels layer carries the resulting chunks
transparently. The "stderr as a unidirectional read" asymmetry is
inside TTY's sub-stream space, not the channels layer's.
The channels layer never sees stderr. It sees bytes. The TTY handler
owns the stdout/stderr distinction entirely.
---
## The wire format decision: 8 bytes
The channels wire format is **8 bytes**: `[channel_id:u32 BE][length:u32 BE]`
followed by an opaque payload. The channels layer owns `channel_id`
and `length`; the payload is the handler's framing, carried
transparently.
The 9-byte alternative (`[channel_id:u32][stream_type:u8][length:u32]`)
was considered and rejected. The 9-byte format puts `stream_type` in
the channels header, which means the channels layer carries a concept
it doesn't own. For TTY this composes cleanly (the 9-byte header is
TTY's 5-byte header with `channel_id` prepended), but for non-TTY
handlers (tunnel, call, SSH) the `stream_type` byte is dead weight —
the channels layer carries a byte it doesn't understand, and the
handler ignores a byte in a header it doesn't control.
The 8-byte format is uniform across all handlers: the channels layer
carries `channel_id` + `length` + opaque payload. Every handler
parses its own framing from the payload. The cost is that TTY's
`wire.rs` needs to be called from a payload buffer rather than
directly from the wire, and the total header for a TTY chunk is
13 bytes (8 channels + 5 TTY) instead of 9. The two length fields
are close but not identical (`ch_len = tty_len + 5`).
**Why 8 bytes wins:**
- **Clean separation of concerns.** The channels layer has no
`stream_type` concept — not in its header, not in its code, not in
its mental model. The handler owns its framing entirely.
- **Uniform across all handlers.** Tunnel, call, SSH, and TTY all
receive the same shape: a payload buffer. No handler gets a
`stream_type` byte it doesn't use.
- **The waste is small.** 5 extra bytes per TTY chunk (the channels
`length` field is always `tty_len + 5`). For typical TTY chunks
(4 KiB+), this is ~0.1% overhead. For extreme multiplexing
scenarios, the clean separation is worth the trade-off.
- **TTY's `wire.rs` rework is bounded.** TTY already has a `ChunkReader`
that reads from an `AsyncRead`. Adapting it to read from a `&[u8]`
payload buffer (or a `Cursor<Bytes>`) is a small, well-scoped change.
The framing logic (stream_type constants, length validation, control
message parsing) is unchanged.
---
## 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** | Channels layer as pure channel multiplexing: routes by `channel_id` only; handlers own sub-multiplexing on the `BiStream` they receive; `into_sub_streams()` removed; every channel is a `BiStream`. The 8-byte wire format (`[channel_id:u32][length:u32][payload]`). The add/strip composition. Amends ADR-071 (channels layer has no `stream_type` concept; wire format is 8 bytes, not 9), ADR-074 (`into_sub_streams` removed, `accept_bi` is the only accessor), ADR-077 (reversed — TTY always uses its 5-byte format; the channels layer carries it transparently in the payload). | **Ready to draft.** |
---
## What changes in each crate
### `alknet-core` (ADR-092's changes, plus this resolution's implications)
- ADR-092's changes: `accept_bi` returns `BiStream`; `from_stream`
removed; `from_bidi` is the only public stream constructor;
`SendStream`/`RecvStream` collapse to thin newtypes.
- This resolution doesn't change core beyond ADR-092. `BiStream` is
the handler leaf; the channels layer yields `BiStream`s; handlers
parse them per their ALPN. Core is not aware of the layered framing.
### `alknet-channels` (the bulk of this resolution)
- The channels layer routes by `channel_id` only. It has no
`stream_type` concept — not in its header, not in its code.
- `into_sub_streams()` is removed. `accept_bi` is the only accessor;
it yields one `BiStream` per channel.
- The wire format is `[channel_id:u32 BE][length:u32 BE][payload]`
(8-byte header). The payload is opaque to the channels layer; the
handler parses its own framing from the payload.
- The add/strip utility: `add_channel_id(channel_id, payload_bytes) -> chunk`
on write (prepends 8-byte header); `strip_channel_id(chunk) -> (channel_id, payload_bytes)`
on read (strips 8-byte header, returns payload).
- Recursive composition is literal: an `alknet/channels` channel
runs another channels demux on its `BiStream`. The outer layer
strips its 8-byte header; the inner layer parses its own 8-byte
header from the payload.
### `alknet-tty`
- TTY always uses its 5-byte format — direct mode and inside-channels
mode. The `channels` feature on `alknet-tty` becomes "run TTY's
sub-demux on a channels-backed `BiStream`" — the same code as
direct mode, different `BiStream` source.
- The control channel is sub-demuxed by TTY, not the channels layer.
The "control isn't actually bidirectional" flaw is fixed at the TTY
layer: stream_type 3 = ctrl_in (write), 4 = ctrl_out (read). TTY's
`wire.rs` needs updating (the `STREAM_CONTROL = 3` "bidirectional"
comment and the `InvalidStreamType > 3` bound are the implementation
lag ADR-077 already specified).
- The 5-byte format is TTY's internal format. When TTY is inside
channels, the channels layer strips its 8-byte header and hands TTY
the payload bytes. TTY parses its 5-byte header from the payload.
TTY's `wire.rs` needs a small adaptation to read from a payload
buffer (`&[u8]` or `Cursor<Bytes>`) rather than directly from the
wire, but the framing logic (stream_type constants, length
validation, control message parsing) is unchanged.
- ADR-077 is reversed: the 5-byte format is NOT scoped to direct —
it's TTY's internal format, carried transparently in the channels
payload. The two-mode TTY design (direct vs inside-channels) is
preserved, but the modes differ only in *where the `BiStream` comes
from*, not in *how TTY parses it*. The same `wire.rs` code runs in
both modes.
### `alknet-call`, `alknet-ssh`, etc.
- The call protocol's `EventEnvelope` framing is the inner layer's
format. The channels layer carries it transparently. No change to
the call protocol itself.
- SSH runs its own channel protocol on the `BiStream` the channels
layer gives it. No change to SSH.
- Tunnel uses the `BiStream` as raw bytes. No sub-multiplexing.
---
## The recursive multiplexing property (made cleaner)
A channels connection carries N channels, each a `BiStream`. A
channel with ALPN `alknet/channels` runs another channels demux on
its `BiStream` — the outer layer strips its 8-byte header, the inner
layer parses its own 8-byte header from the payload. Each level is
the same shape: `BiStream → accept_bi → N BiStreams`. The recursion
is unbounded and uniform at every level.
This is a property, not a feature. The primary use case is one level
of multiplexing. But the add/strip composition makes it cleaner than
ADR-071's group framing did — the recursion is the same operation
(strip an 8-byte header) at every level, not a different framing per
level.
---
## Open questions
- **Recursive composition.** Deferred. The add/strip composition
makes it cleaner, but it's not a goal. Low-leverage POC if ever
needed.
---
## References
- ADR-092: `BiStream` as the handler leaf (the transport-leaf layer,
settled; this doc is the layer above it)
- ADR-071: channels wire format (amended by this resolution — the
channels layer routes by `channel_id` only; wire format is 8 bytes
(`[channel_id:u32][length:u32][payload]`), not 9; `stream_type` is
the inner layer's framing, carried transparently in the payload)
- ADR-074: `ChannelBidiStreamSource` / `into_sub_streams` (amended —
`into_sub_streams` removed; `accept_bi` is the only accessor, yields
one `BiStream` per channel)
- ADR-077: TTY inside channels (reversed — TTY always uses its 5-byte
format; the channels layer carries it transparently; the two-mode
design is preserved but differs only in `BiStream` source, not in
parsing)
- ADR-072: channel 0 pre-negotiated as `alknet/call` (the hardcoded
`channel_id` constraint)
- ADR-073: channel lifecycle operations (the `channel/open` operation
that allocates `channel_id`s)
- `crates/alknet-tty/src/wire.rs:13,32-33,150-151` — the `STREAM_CONTROL
= 3` "bidirectional" flaw and the `InvalidStreamType > 3` bound
(implementation lag; fix specified in ADR-077, subsumed by this
resolution's "TTY owns its sub-streams")
- `/workspace/alknet-channels-poc/src/demux.rs:91-109, 161-181` — the
demux's per-`stream_type` routing (no pairing assumption; the
mechanism supports any convention; this resolution says the channels
layer doesn't have a convention, the handler does)
- `crates/alknet-tty-local/tests/pipe.rs:363 separate_stderr` — the
test that proves separate stderr is a real feature (resolves as a
TTY-layer concern, not a channels-layer concern)
- `docs/research/alknet-channels/poc-summary.md` — the channels POC
(28 tests) that validated the per-`channel_id`/`stream_type`
routing mechanism the channels layer uses