Phase 1 architecture for alknet-channels (multiplexing proxy on alknet/channels). Grounded in the completed de-risk POC (28 tests) and the landed ADR-070 (BidiStreamSource trait + Connection::from_source). ADRs: - 071: 9-byte chunk wire format (generalizes TTY's 5-byte) - 072: channel 0 pre-negotiated as alknet/call (no special control plane) - 073: channel lifecycle operations on the call protocol — channel/open, close, control, resources/subscribe; direction field pinned; subscribe from day one (not poll-for-v1 — StreamingHandler machinery exists) - 074: ChannelBidiStreamSource implements BidiStreamSource (ADR-070); into_sub_streams() typed accessor for TTY; accept_bi() generic path - 075: ChannelsAdapter + ChannelManager; REQ-CH-01..04 wire invariants - 076: bounded-buffer backpressure (1 MiB), 256-channel cap, monotonic IDs - 077: TTY inside channels uses sub-streams, not own wire format; amends ADR-052 scope to direct-connect TTY; channels feature on tty - 078: two-pump shutdown-on-completion contract (handler-level) - 079: hub relay translates channel 0, byte-forwards data channels - 080: ChannelClient (QUIC-only); AlknetClient core extraction deferred (OQ-55) Spec docs: overview, channels-wire, channels-connection, channels-adapter, channel-operations, channel-client. OQ-56 (full windowing) and OQ-57 (two-pump helper extraction) are genuine deferred(scope) deferrals with concrete blocking conditions; the contracts are decided, only the extensions are deferred. Hedging audit converted three research hedges into decisions: resources/subscribe (not poll), server-assigned IDs (not if-zero-RTT), bounded-buffer (not if-HOL-becomes-a-problem).
6.8 KiB
ADR-076: Backpressure, Channel Limits, and ID Reuse
Status
Accepted
Context
The phase-0 research (docs/research/alknet-channels/phase-0-findings.md
§DP-5, §OQ-CH-03/04/05/06) raised four operational questions about the
channels layer:
- Flow control (DP-5, OQ-CH-03): if one data channel's consumer is slow, could it block all other channels on the same transport (head-of-line blocking)? The research recommended "bounded-buffer backpressure (option c)… if head-of-line blocking becomes a real problem, full windowing can be added." The "if it becomes a problem" is a hedge — the POC validated bounded-buffer with a 1 MiB test and no deadlock. The decision is bounded-buffer.
- Channel ID reuse (OQ-CH-04): after a channel is closed, can its ID be reused?
- Maximum channels per connection (OQ-CH-05): is there a limit?
- Channel open DoS (OQ-CH-06): an authenticated peer could open many channels and never read from them, exhausting memory.
The de-risk POC (docs/research/alknet-channels/poc-summary.md §POC Target
1, §POC Target 3) validated the bounded-buffer backpressure path: the 1 MiB
tunnel_large_payload test exercises a channel writer faster than the TCP
echo server consumer, with no deadlock and no cross-channel blocking.
Decision
Backpressure: bounded-buffer, 1 MiB default (DP-5)
Each (channel_id, stream_type) pair has an independent bounded mpsc
buffer. When a channel's buffer is full, the demux stops reading chunks for
that channel_id until the consumer drains it. Other channels keep flowing
— the demux's per-chunk route awaits the matching sender without holding a
global lock.
Default buffer cap: 1 MiB per (channel_id, stream_type). Configurable
per ChannelManager (buffer_cap field). This prevents memory exhaustion
without the complexity of SSH's sliding-window protocol.
Full channel-level windowing (SSH-style sliding-window per channel) is a deferred extension, tracked as OQ-56 (deferred(scope)). It is blocked on a real deployment observing head-of- line blocking where the bounded-buffer mitigation is insufficient. The bounded-buffer decision is made; the extension is not.
Channel ID reuse: yes, after drain (OQ-CH-04)
After a channel is closed (channel/close acknowledged), its channel_id
is eligible for reuse. The reassembly buffers must be fully drained before
reuse to prevent data from the old channel leaking into the new one.
Drain-before-reuse invariant: the ChannelManager marks a closed
channel's ID as "draining" (not in the channels map, but not yet returned
to the free pool). The ID returns to the free pool only after:
- The
channel/closeresponse is sent (the close is acknowledged). - All reassembly buffers for that
channel_idare empty (the handler has consumed all data).
The next_id: AtomicU32 is monotonic (not a free-list) — IDs are not
immediately reused; the monotonic counter wraps at u32::MAX. This is
simpler than a free-list and avoids the drain-tracking complexity. With a
default max_channels of 256, the u32 space is effectively unlimited
(~16.7 million channels before wrap). Reuse happens naturally on wrap, by
which time old channels are long drained. The "reuse" in OQ-CH-04 is
satisfied by the wrap-around, not by a free-list.
Maximum channels per connection: 256 default (OQ-CH-05/06)
The channel_id is u32 — the wire format supports ~4 billion channels.
The practical limit is memory (reassembly buffers per channel) and the
transport's flow control.
Default per-connection channel limit: 256 (max_channels field on
ChannelManager, configurable). This is the DoS defense (OQ-CH-06): an
authenticated peer that opens many channels and never reads from them is
bounded by max_channels × buffer_cap = 256 × 1 MiB = 256 MiB worst case.
Bounded buffers (DP-5) limit the damage per channel; the connection cap
limits the number of channels. Defense in depth.
Exceeding the limit returns channel:too_many_channels (ADR-073 error
codes). The limit is per-connection, not per-peer — a peer can open more
channels on a second connection.
DoS defense summary (OQ-CH-06)
| Layer | Mechanism | Default |
|---|---|---|
| Per-channel | Bounded reassembly buffer (stop reading when full) | 1 MiB per (channel_id, stream_type) |
| Per-connection | Channel count cap | 256 channels |
| Per-peer | Auth (AccessControl::check on channel/open) |
Assembly-layer policy |
An authenticated peer that opens 256 channels and never reads from them
consumes at most 256 MiB of reassembly buffers — bounded, not unbounded.
The assembly layer's AccessControl policy can further restrict
channel/open (e.g., required_scopes: ["channel:open:alknet/tty"]) to
limit who can open channels at all.
Consequences
Positive:
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no deadlock, no cross-channel blocking). The decision is made, not hedged.
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB worst-case memory per connection — a clear DoS ceiling, not an open-ended one.
- Monotonic
next_idwith wrap-around avoids free-list drain-tracking complexity while still satisfying ID reuse (on wrap, after ~16.7M channels).
Negative:
- The 256-channel default may be too low for a hub with many concurrent
browser sessions each opening multiple channels. The cap is configurable
per
ChannelManager; the hub assembly layer may set it higher for deployments with many concurrent sessions. This is a deployment-time decision, not an architecture decision. - Bounded-buffer backpressure does not eliminate head-of-line blocking — it bounds the memory cost. A slow consumer still stalls its own channel's demux reads. For the intended use cases (TTY, SSH, tunnels) this is acceptable; full windowing is tracked as OQ-56 (deferred(scope)).
Door type
Two-way. The buffer cap (1 MiB), the channel limit (256), and the monotonic-ID-with-wrap strategy are all configurable / changeable without a wire-format change. The bounded-buffer approach (vs full windowing) is one-way in the sense that the demux/mux code is written around it — but full windowing is an additive extension (per-channel window tracking) that doesn't change the wire format, so even that reversal is feasible.
References
- ADR-071: channels wire format (the chunks the buffers hold)
- ADR-073: channel lifecycle operations (
channel:too_many_channelserror) - ADR-075: ChannelManager (
buffer_cap,max_channels,next_idfields) docs/research/alknet-channels/poc-summary.md§POC Target 1 (backpressure validation), §POC Target 3 (1 MiB tunnel test)docs/research/alknet-channels/phase-0-findings.md§DP-5, §OQ-CH-03/04/ 05/06