- ADR-014 accepted (review 002 C-2): the pre-command handshake, the BIND quiet wait, and the association lifetime are all teardown-governed; the protocol layer never times out (option 1) - the BIND relationship recorded as same regime (ADR-007 decided one phase's ends by designing BIND; ADR-014 records the inherited resolution — one principle, not two) - the doors' accept-path obligations recorded pre-batch-2 (accept-stage idle bound, per-credential/per-peer caps, accept-queue bounds); revisitable additively (an optional producer-side idle seam would be config, not wire) - spec lines carry the decided posture: wire.md (association-lifetime bullet, no-in-band-control bullet, preamble, frontmatter); producer.md (the open handler, the local-backend section, decision table, frontmatter); overview.md ADR index - one upstream-fidelity correction: upstream's dial timeout maps to 0x06 (TTL expired), not 0x01 as the review sketch suggested - open-questions.md unchanged (C-2 was never tracked there) Verification: cargo test green (357 passed), clippy -D warnings clean, fmt clean, cargo doc clean, wasm check + clippy (wasm32) clean
8.2 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level |
|---|---|---|---|---|---|---|---|
| architecture/adr-014-idle-timeout-posture | ADR-014 — the idle-timeout posture for the pre-command handshake and the association (review-002 C-2) | completed | narrow | medium | project | implementation |
Description
Review 002's C-2 (major): the pre-command RFC handshake and the
association have no timeout surface — a silent channel holds a
spawned task and reader state indefinitely (bounded memory,
unbounded time). The alkcall 10s establishment bound governs the
open-op call, not the in-stream conversation
(src/producer.rs:183-191 parks on accept_bi → accept_no_auth
→ read_command with no timeout; the datagram stage's loop exits
only on EOF/error/data). BIND's quiet wait got an explicit decision
(ADR-007 — teardown-governed, "FTP-length waits stay legal"); the
handshake and the association lifetime got "inherited from the
channel" — implicit, never decided.
The mitigations are real (the 256-channel cap bounds the count,
memory per idle channel is bounded, teardown fires on channel
close — nothing leaks, nothing grows), but the aggregate is: any
identity with socks5:open can hold 256 parked tasks + 256 channel
entries indefinitely without sending a byte. And batch 2's
providers (local, composed) are about to route vanilla
clients through these same phases via the front doors, where the
peer is a kernel socket often outside alkcall's channel-cap regime
entirely — slowloris-shaped holds become the assembly layer's
problem, and the protocol layer is where a timeout seam would live.
The deliverable is a decision, not code — ADR-014
(docs/architecture/decisions/014-*.md), landing before the
local doors exist. Decide between the review's two options:
- Idle timeout is the substrate's/assembly's job — the protocol layer never times out (consistent with "no in-band control vocabulary" and association-lifetime-equals-channel); record the DoS posture explicitly (the cap bounds it; doors apply their own accept timeouts). Cheapest, defensible.
- A producer-side idle bound — an optional per-session
inactivity timeout at the pump level (config on
Socks5ProducerBuilder), enforced by racing the handshake/stage reads with a sleep timer; a timeout = clean EOF-shaped teardown, no reply (the RFC has no timeout reply code; 0x01GeneralFailureon a pre-reply timeout is the closest and is what upstream's connect-timeout path surfaces). More work; touches no wire format.
The ADR must state, whichever option wins: the BIND quiet wait's
existing decision (ADR-007) and how the new decision relates to it
(same regime or different — and why the difference is principled);
the DoS posture in the doors' terms (what a local door's accept
path must do if the protocol layer does not); and whether the
decision is revisitable post-batch-2 or is itself a one-way door
(it should not be — a timeout seam is additive — but say so).
Also update docs/architecture/open-questions.md if C-2 was
tracked there, and the affected spec lines (wire.md §UDP
ASSOCIATE's association-lifetime bullet; producer.md's pump
description) to carry the decided posture rather than the implicit
one.
Acceptance Criteria
- ADR-014 exists at
docs/architecture/decisions/014-<slug>.md, status accepted, deciding explicitly between the two options (or a reasoned third) - The BIND quiet wait's ADR-007 relationship is stated (same regime or a principled difference)
- The doors' DoS posture is recorded (what the assembly layer must do if the protocol layer does not)
- The affected spec lines (wire.md, producer.md) carry the decided posture — no implicit residue
- The decision lands before any
local/composeddoor work (the batch-2 tasks may depend on this one) cargo doc --no-depsclean if any doc-comment carries the posture; the full verification sweep green
References
- docs/reviews/002-protocol-core-implementation-review.md §C-2 (the finding — the task's source of truth)
- docs/architecture/decisions/007-bind-in-base.md (the quiet wait's existing decision — the contrast case)
- docs/architecture/decisions/008-provider-defaults.md, 009-feature-gates.md (the batch-2 provider posture the decision precedes)
- docs/architecture/wire.md §UDP ASSOCIATE (association lifetime = channel lifetime — the contract the decision qualifies)
- The POC findings (the stage's measured shapes — the "sufficient for relay postures" lineage)
Notes
Filled by the implementation agent (2026-09-24):
- Option 1 won — the protocol layer never times out; every phase (pre-command handshake, BIND quiet wait, association lifetime) is teardown-governed (EOF / garbage / error sentinels), never idle-expired. No code changed; the posture is recorded.
- The sharpening argument (why a handshake-only bound is incoherent): every post-reply phase legally parks indefinitely on a silent peer — an idle CONNECT tunnel, a quiet association, BIND's quiet wait (ADR-007). The pre-command hold costs exactly what those holds cost (one parked task + one channel entry); a handshake-only bound protects a sliver while the dominant hold stays contractually open, and a bound on both is a full inactivity timeout, which contradicts the landed contracts (association lifetime = channel lifetime; "FTP-length waits stay legal," ADR-007). The unbounded-time aggregate is a property of every idle session — bounded by the cap regime (ADR-040/041), not by a timer.
- One upstream-fidelity correction to the review's sketch:
upstream's connect-timeout path maps its dial timeout to
ReplyError::ConnectionTimeout→ 0x06 (TTL expired), not 0x01 — verified in the reference checkout (util/stream.rs) and the forked table (src/rfc1928/mod.rs). Recorded in the ADR; relevant only if option 2 is ever built. - open-questions.md needed no update — C-2 was tracked as a review finding (tasks/), not as an OQ; no OQ entry exists for it.
- The doors' obligations are recorded pre-batch-2 (the "inherits this bound knowingly" pattern, wire.md §UDP ASSOCIATE's): an accept-stage idle bound (the slowloris sliver — an accepted socket is not a channel, the cap regime does not bound it), per-credential/per-peer session caps (the cred-bearing door's config table, ADR-005, is the knob), and accept-queue/ relay-task bounds. Same obligations for the producer-side local backend's accept loop and a composed bind-first listener's front.
- Revisitability: not a one-way door — a producer-side idle seam
is additive (builder config defaulting to
None= this posture;tokio::timealready in the wasm-clean subset; timeout = EOF- shaped teardown). Revisit trigger recorded: a deployment with a peer class outside both the cap regime and any door policy, with measured evidence.
Summary
ADR-014 landed
(docs/architecture/decisions/014-idle-timeout-posture.md, status
accepted, 2026-09-24), deciding review 002 C-2: option 1 — the
protocol layer never times out. The pre-command handshake, the
BIND quiet wait, and the association lifetime are all
teardown-governed; the BIND relationship is recorded as same
regime (ADR-007 decided one phase's ends by designing BIND; ADR-014
decides the rest by recording the inherited resolution — one
principle: the protocol layer cannot distinguish malicious silence
from a slow legitimate peer, so timeout policy belongs to the
deployment-facing layer). The DoS posture is explicit and layered
(ACL → per-connection cap → per-identity cap → door/dial policy —
the ADR-010 pattern), with the doors' accept-path obligations
recorded before any batch-2 door exists. The decision is revisitable
additively (not a one-way door). Spec lines updated — wire.md §UDP
ASSOCIATE's association-lifetime bullet + the phase-model
"no in-band control vocabulary" bullet + the preamble's ADR list +
frontmatter; producer.md §The Open Handler's pump description + the
local-backend section + the decision table + frontmatter;
overview.md's ADR index. No code changes; no OQ update needed
(C-2 was never tracked there). cargo doc --no-deps clean; full
verification sweep green (cargo test 348+ green, clippy, fmt,
wasm check).