Files
alknet/docs/research/stream-unification/findings.md
T
glm-5.2 b5397f61aa docs(research): rewrite stream-unification findings — focus on the multiplexing layer
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.
2026-07-18 05:51:54 +00:00

353 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 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`/`split`
recombine halves from different sources?") — ADR-092's layer.
Answer: yes, stdlib `tokio::io::join`/`split` is 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-181` and
`mux.rs:58-63, 152-177`): 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, 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
1. **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.
2. **The "unused write half" wart is trivially clean** (verified in
the existing POC code — see "Stream_type split/recombine" above).
An unused `stream_type` is 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_type` path
(`demux.rs:161-174`) means even a stray write to an unallocated
`stream_type` is dropped with a counter bump, not a panic.
3. **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.
4. **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, 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.
**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: `BiStream` as 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_types` field
declared at `channel/open` time — 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_type` independence
- `/workspace/alknet-channels-poc/src/demux.rs:91-109, 161-181` —
the demux's per-`stream_type` routing (no pairing assumption)
- `/workspace/alknet-channels-poc/src/mux.rs:58-63, 152-177` — the
mux's per-`(channel_id, stream_type)` independent pumps
- `crates/alknet-tty/src/wire.rs:13,32-33` — the `STREAM_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)