Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
156 lines
8.0 KiB
Markdown
156 lines
8.0 KiB
Markdown
---
|
|
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. |