Files
alkcall/docs/architecture/decisions/040-backpressure-channel-limits-id-reuse.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

237 lines
12 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.
# ADR-040: Backpressure, Channel Limits, and ID Reuse
## Status
Accepted (amended 2026-07-18 by ADR-035 — backpressure is per-`channel_id`,
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-035,
2026-07-18)" below; **amended 2026-07-19 by ADR-041 — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense; the per-identity DoS defense lives in `channels-call` via
`ChannelLifecyclePolicy` — see "Amendment (ADR-041, 2026-07-19)" below**)
## Amendment (ADR-041, 2026-07-19)
The per-connection `max_channels = 256` cap is **reframed as a
per-connection memory bound**, not a DoS defense. A single peer can
open an unbounded number of transport connections, so a per-connection
cap is not a per-peer DoS defense — it is a bound on one connection's
reassembly-buffer cost. The per-identity DoS defense (256 per
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
is documented in [ADR-041](041-per-identity-channel-cap.md).
What changes in this ADR:
1. **§"Maximum channels per connection: 256 default"** — the cap stays
at 256, but its role is reframed. It is a per-connection memory
bound (limits one connection's reassembly-buffer cost regardless of
policy), not the DoS defense against an authenticated peer. The
per-identity DoS defense is the `ChannelLifecyclePolicy`
consultation in the `channel/open` handler (ADR-041).
2. **§"DoS defense summary"** — the table is **removed**. It framed
the per-connection cap as the DoS defense, which it is not. ADR-041
§2 contains the corrected per-identity DoS defense summary.
3. **The "per-connection, not per-peer — a peer can open more channels
on a second connection" line** — this was the channels layer
confessing a hole and hoping the layer above it would fill it. The
line is **corrected** to state that the per-connection cap is a
memory bound, and that the per-identity cap is the DoS defense
(ADR-041). A peer that opens a second connection gets a second
per-connection memory bound; it does **not** get a second
per-identity quota — the `ChannelLifecyclePolicy` is shared across
connections.
What stays:
- The 256 default and the `max_channels` field on `ChannelManager`
(still returns `channel:too_many_channels` when hit — the
per-identity policy returns the same error code, so an over-cap
peer sees the same error either way).
- The bounded-buffer backpressure decision (DP-5) — unchanged.
- The channel-ID reuse decision (monotonic `next_id` with
wrap-around) — unchanged.
- The drain-before-reuse invariant — unchanged, and the
`channel/close` handler now also calls
`ChannelLifecyclePolicy::on_close` at this point (ADR-041 §3).
## Amendment (ADR-035, 2026-07-18)
The bounded-buffer backpressure is per-`channel_id` (not per
`(channel_id, stream_type)`). The channels layer has one reassembly
buffer per channel (yielding a `BiStream`), not one per
`(channel_id, stream_type)`. The 1 MiB default and the 256-channel cap are
unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a
TTY channel with 5 active stream_types under the per-stream_type model).
This is a net improvement (lower memory ceiling per channel), not a
regression. The bounded-buffer *approach* is unchanged; only the
buffer granularity changes (per-channel, not per-stream_type).
The body below describes the **original** (per-stream_type) shape; the
amendment above is the operative decision. See ADR-035 for the resolution
rationale.
## 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:
1. **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.
2. **Channel ID reuse (OQ-CH-04):** after a channel is closed, can its ID be
reused?
3. **Maximum channels per connection (OQ-CH-05):** is there a limit?
4. **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](../questions/056-full-channel-level-flow-control-windowing.md)
(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:
1. The `channel/close` response is sent (the close is acknowledged).
2. All reassembly buffers for that `channel_id` are 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 — memory bound)
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 a **per-connection memory
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
= 256 MiB worst case per connection) regardless of policy. It composes
with the per-identity DoS defense (ADR-041) but is not itself a DoS
defense — a peer can open an unbounded number of transport connections,
so a per-connection cap cannot bound a peer's total channels. The
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
via `ChannelLifecyclePolicy`) is documented in
[ADR-041](041-per-identity-channel-cap.md).
Exceeding the per-connection limit returns `channel:too_many_channels`
(ADR-037 error codes) — the same error code the per-identity policy
returns when the per-identity cap is hit. An over-cap peer sees the
same error either way; which cap fired first is an implementation
detail. The limit is per-connection as a memory bound; the per-identity
cap (ADR-041) is what bounds a peer's total channels across all its
connections.
### DoS defense summary (OQ-CH-06)
The DoS defense against an authenticated peer opening many channels is
the **per-identity cap** enforced in `channels-call` via
`ChannelLifecyclePolicy` — documented in
[ADR-041](041-per-identity-channel-cap.md). A per-connection cap
cannot be the DoS defense because a peer can open an unbounded number
of transport connections; the unit that must be bounded is the
identity, not the connection.
The per-connection `max_channels = 256` (this ADR) is a **memory
bound** that limits one connection's reassembly-buffer cost. It
composes with the per-identity cap as defense-in-depth (the
`NoCap` policy path still has the per-connection memory bound), but
it is not the security boundary. See ADR-041 §2 for the corrected
DoS defense summary.
## 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 per-connection memory
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
`PeerId` across all the peer's connections) is documented in ADR-041.
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
complexity while still satisfying ID reuse (on wrap, after ~16.7M
channels).
**Negative:**
- The 256-channel per-connection cap may be too low for a hub with many
concurrent browser sessions each opening multiple channels. The cap
is configurable per `ChannelManager`; the hub deployment may set it
higher for deployments with many concurrent sessions. This is a
deployment-time decision, not an architecture decision. (The
per-identity cap in ADR-041 is the DoS-relevant bound; the
per-connection cap is a memory backstop.)
- 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-034: channels wire format (the chunks the buffers hold, as amended
by ADR-035)
- ADR-035: channels pure channel multiplexing (amends this ADR —
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
- ADR-041: per-identity channel cap as DoS defense (amends this ADR —
the per-connection `max_channels = 256` is reframed as a per-connection
memory bound, not a DoS defense; the per-identity DoS defense lives in
`channels-call` via `ChannelLifecyclePolicy`)
- ADR-037: channel lifecycle operations (`channel:too_many_channels`
error; the `channel/open` and `channel/close` handlers that gain the
`ChannelLifecyclePolicy` consultation)
- ADR-039: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
fields; the auth-blindness that forces the per-identity cap into
`channels-call`, not `channels-core`)
- ADR-026: forwarded-for identity (why the spoke caps the hub, not the
browser — `forwarded_for` is metadata, not authority, for the cap as
for `AccessControl::check`)
- `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