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.
This commit is contained in:
1 parent
909935ded3
commit
b5397f61aa
1 file changed
+190
-337
@@ -3,144 +3,61 @@ status: draft
|
||||
last_updated: 2026-07-18
|
||||
---
|
||||
|
||||
# stream-unification — Findings: the leaf, the instance, the convergence
|
||||
# stream-unification — Findings: the stream_type convention and the TTY/channels 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.
|
||||
**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
|
||||
**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
|
||||
## Layering (to keep the questions separate)
|
||||
|
||||
The deep dive surfaced a layered tangle:
|
||||
| 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. |
|
||||
|
||||
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).
|
||||
The two findings-doc questions that got conflated in the previous
|
||||
draft, separated:
|
||||
|
||||
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).
|
||||
- **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 tangle, restated
|
||||
## The core question: stream_type assignment convention
|
||||
|
||||
Five abstractions for "a bidirectional byte stream" before ADR-092:
|
||||
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.
|
||||
|
||||
| # | 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:
|
||||
### ADR-071's current convention: mod-3 groups
|
||||
|
||||
| Group | stream_type | direction | purpose |
|
||||
|-------|-------------|-----------|---------|
|
||||
@@ -153,56 +70,20 @@ ADR-071 groups stream_types in threes:
|
||||
| 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.
|
||||
`% 3 == 2` → diagnostic read (err).
|
||||
|
||||
**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 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.
|
||||
|
||||
### 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)
|
||||
### Proposed convention: mod-2/mod-4 by instance
|
||||
|
||||
An **instance** is a self-contained bidirectional unit, addressed as
|
||||
a contiguous block of stream_types within a channel.
|
||||
a contiguous block of `stream_type` values within a channel.
|
||||
|
||||
**No control channel** — mod 2:
|
||||
|
||||
@@ -214,8 +95,8 @@ a contiguous block of stream_types within a channel.
|
||||
| ... | | |
|
||||
| 127 | 254 | 255 |
|
||||
|
||||
128 instances per channel. Instance K uses stream_types `2K` (in) and
|
||||
`2K+1` (out).
|
||||
128 instances per channel. Instance K uses `stream_type = 2K` (in)
|
||||
and `2K+1` (out).
|
||||
|
||||
**With control channel** — mod 4:
|
||||
|
||||
@@ -227,117 +108,115 @@ a contiguous block of stream_types within a channel.
|
||||
| ... | | | | |
|
||||
| 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).
|
||||
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
|
||||
**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.
|
||||
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 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.
|
||||
**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.
|
||||
|
||||
**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:
|
||||
### Why mod 2 wins
|
||||
|
||||
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.
|
||||
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 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).
|
||||
**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 and was a natural extension: TTY's
|
||||
5-byte header + `channel_id:u32` prefix = the 9-byte channels header.
|
||||
The convergence has two parts.
|
||||
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.
|
||||
|
||||
### Format convergence
|
||||
### Semantic convergence (independent of format)
|
||||
|
||||
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).
|
||||
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 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:
|
||||
**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` (breaking ADR-072's "channel 0 is call" rule).
|
||||
`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.
|
||||
- **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.
|
||||
backward-compat implications. It's the one open question that benefits
|
||||
from a POC — see below.
|
||||
|
||||
---
|
||||
|
||||
@@ -353,55 +232,40 @@ 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.
|
||||
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
|
||||
|
||||
Ordered by leverage (the load-bearing unknowns that, once validated,
|
||||
make the ADR drafting mechanical rather than speculative).
|
||||
### POC 1: stderr split/recombine — **already answered, no POC needed**
|
||||
|
||||
### POC 1: stderr split/recombine (load-bearing for mod 2 vs mod 3)
|
||||
**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?"
|
||||
|
||||
**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?
|
||||
**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`).
|
||||
|
||||
**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."
|
||||
**Status:** Confirmatory, not exploratory. The load-bearing property
|
||||
is already proven. The mod-2 framing is trivially clean. No new POC.
|
||||
|
||||
**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)
|
||||
### 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 the backward-compat with existing
|
||||
TTY-direct deployments a real constraint, or are there no existing
|
||||
deployments to break?
|
||||
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
|
||||
@@ -410,8 +274,8 @@ 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).
|
||||
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.
|
||||
|
||||
@@ -420,18 +284,17 @@ 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)
|
||||
### 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
|
||||
**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.
|
||||
|
||||
**Scope:** Deferred. Not a POC for this round. Recorded for
|
||||
completeness.
|
||||
**Status:** Deferred. Not a POC for this round.
|
||||
|
||||
---
|
||||
|
||||
@@ -439,62 +302,52 @@ completeness.
|
||||
|
||||
| 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.
|
||||
| **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 (for the research iteration)
|
||||
## Open questions
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
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-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-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
|
||||
- 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, WASM compile check); the shape ADR-093 builds on
|
||||
(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 ADR-093 fixes
|
||||
"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)
|
||||
Reference in new issue
Block a user