--- id: w2-bounded-resources name: "Wave 2: bounded resources (the in-crate channel cap at the handler surface)" status: completed depends_on: [w2-spawn-return] scope: narrow risk: low impact: component level: implementation --- ## Description Enforce the in-crate bounded-resources posture at the handler surface (`overview.md` §Bounded-resources policy; AGENTS.md convention 9: SSH servers are internet-facing, and unbounded loops and unbounded buffers are bugs): - **The in-crate concurrent-channel cap**, a count of active channels enforced at every `channel_open_*` hook: opens beyond `Limits::max_concurrent_channels` are declined fail-closed (a clean refuse, never queued, never a half-open channel). The count decrements on channel close so the cap recovers. Short-held internal state (`parking_lot`, the project convention). - **The russh knobs are already wired** (`w2-server-mount` mapped `inactivity_timeout` always-`Some`, `max_auth_attempts`, `window_size`, `maximum_packet_size`, `channel_buffer_size` from `Limits`); this task verifies them behaviorally where cheap (e.g., the auth-attempt ceiling disconnects an exceeding client through the harness) and records anything not cheaply observable as covered by the config-mapping tests. - **Per-length exec-parse limits stay reserved** for the wave-3 grammar's consumption (`Limits::max_exec_command_len` is carried, not yet enforced; the grammar owns it). The per-identity policy layer (when the alkcall bridge carries it) is additional and arrives with the bridge itself (wave 3's tunnel leg), not from this task. ## Acceptance Criteria - [x] The concurrent-channel cap is enforced at every `channel_open_*` hook: beyond `Limits::max_concurrent_channels` → clean decline; the count recovers on close - [x] Harness tests: cap reached → next open declines; a close → an open succeeds again; the cap is `Limits`-tunable - [x] The wired russh knobs verified behaviorally where cheap (auth-attempt ceiling through the harness) or recorded as covered by the config-mapping tests - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/overview.md §Bounded-resources policy (the knob table) - docs/architecture/mapping.md §Data-plane / limits - docs/plans/implementation.md §Wave 2 (the bounded-resources bullet) - src/config.rs (`Limits`, the budget model and its documented postures) ## Notes - **The count is handler-owned, not locked.** New private module `src/channel_cap.rs` (`ChannelCap`): the per-session active-channel count with `admit` (check+increment at accept sites) / `has_room` (pure check) / `release` (saturating decrement). The only writer is the connection's russh packet loop, where every hook runs inline with exclusive `&mut self` (ADR-006 decision 4 puts serving paths on spawned tasks, but their closes cycle back through the packet loop as `CHANNEL_CLOSE`, firing the hook), so the `parking_lot` convention's short-held *shared* state does not apply: no lock exists because no shared mutation does. Saturating `release` means a stray decrement cannot manufacture capacity. - **Gate ordering** (handler.rs): feature gate first, then the cap. Session-channel opens admit-and-count exactly at the accept point. The tunnel rows check the cap *before* the seam stub, because the stub declines without opening a channel, so a counted admission it never opens would leak against the cap forever (decline-first-alloc-never applies to the budget, not just capability). Wave 3's accept-after-fabric-open flow replaces the stub and moves admission to the real accept point; the wire behavior this wave: a cap-exhausted tunnel row answers the cap reason instead of the seam's `ConnectFailed` (pinned by a row, not by convention). x11 declines in every build and never consumes a count. - **Decline reason**: `dispatch::cap_exceeded_open_decline()` → `AdministrativelyProhibited`: the cap is the assembly's configured policy ceiling; OpenSSH's own `MaxSessions` answers the same code, so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5). - **Decrement invariant**: every fired `channel_close` corresponds to exactly one previously-admitted open, since russh only routes `CHANNEL_CLOSE` for channels it considers established (a declined open never established one) and consumes the close at most once (the channel is removed from russh's map before the hook). - **Recovery observability (russh-pin fact, recorded in the test doc)**: a *client-initiated* channel close is not observable client-side at this pin, because russh removes a locally-closed channel from its client map before the server's `CHANNEL_CLOSE` arrives back, so `Channel::wait()` never delivers the close (the harness's `await_close` observes only *server-initiated* closes, which is what the spawn-return/dispatch rows use). The recovery row therefore rests on wire ordering (deterministic): close and open ride the client loop's single FIFO event queue, are written in that order, and the server's packet loop processes them in order, so the counted release precedes the recovery admission; had the release not happened, the recovery open declines and the row fails. - **`max_auth_attempts` verified behaviorally**: ceiling `2` (tunable) → two within-ceiling failures stay ordinary `AuthResult::Failure` riding the `{publickey}` advertisement; the attempt past the ceiling is answered with the server's `Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is russh's synthesized `Failure` with an *empty* remaining-methods set (no reply came; the advertisement is gone), and the session ends cleanly server-side (row: `tests/bounded_resources.rs` `auth_attempt_ceiling_disconnects_an_exceeding_client`). - **The size/window/clock knobs recorded as config-mapping-covered**: `window_size`, `maximum_packet_size`, `channel_buffer_size`, `max_auth_attempts` mapping and the always-set `inactivity_timeout` are pinned one-for-one by the adapter's config-mapping unit tests (`adapter.rs` `limits_knobs_map_one_for_one_with_inactivity_always_set`, `default_limits_map_to_the_documented_postures`); the client direction's keepalive/wall-clock postures are pinned by the harness (`client_config_maps_the_limits_postures`). Re-driving them through the wire buys no new information (russh reads the `Config` fields it owns); not cheaply behavioral, so recorded, per the task's split. - **`max_exec_command_len` stays carried, not enforced** (wave 3's grammar owns it), unchanged from `src/config.rs`/`src/dispatch.rs` documentation; nothing here consumes it. - **Verification**: `cargo test` (default), and `--features session` / `git` / `tunnel` / `--all-features` (the feature-gated cap rows run in the builds whose opens can be accepted), `cargo clippy --all-targets -- -D warnings` (+ `--all-features`), `cargo fmt --check`, `cargo doc --no-deps`: all green. ## Summary Landed the in-crate concurrent-channel cap at the handler surface: new private `channel_cap` module (count + admit/release semantics, unit-tested), wired into `SessionHandler`'s every open hook: the session accept path admits-and-counts at the accept, the tunnel rows cap-gate before the seam stub, x11 is unaffected. Counts decrement through the new `channel_close` hook (single release point, single writer). Cap-exceeded opens decline fail-closed with `AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`, OpenSSH `MaxSessions` parity). Contract rows in `tests/bounded_resources.rs`: cap reached → clean decline; close → open succeeds again (wire-order deterministic); zero cap refuses everything without poisoning; exhausted cap answers the tunnel row with the cap reason; the `max_auth_attempts` ceiling verified behaviorally through the harness. The per-identity policy layer stays with the wave-3 bridge; the per-length exec-parse cap stays the grammar's. All five feature-build test runs, clippy (both) and fmt green.