- Gate reading recorded line by line: client.md (dial surface, op rows, acceptor, lifecycle primitives), ADR-003 symmetry, ADR-005/ OQ-05/OQ-07, bounded resources, tracing, smokes, docs honesty — all confirmed - Wave-3 gate booked items 1-4 verified discharged; item 5 verified still booked with its owners (nothing absorbed) - Inline fix: the accepted-open handoff counter now releases on completion or unwind (HandoffCapGuard panic-safety; a panicking assembly acceptor can no longer strand a held slot) + unit row - Inline fix: lib.rs crate-level wave-state doc sentence (stale over w4-lifecycle) - w4-reverse-listen / w4-lifecycle Summary placeholder lines filled Verification: cargo test (default / --all-features / --features tunnel) all green; clippy x2 -D warnings clean; cargo fmt --check clean; cargo doc --no-deps 0 warnings; taskgraph validate (35 tasks)
10 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| w4-lifecycle | Wave 4 — lifecycle primitives (cancellable handles + clean disconnect) | completed |
|
narrow | low | component | implementation |
Description
Land the OQ-12 resolution in code (client.md §Lifecycle
primitives): the primitives that make assembly-side reconnect/restore
policy possible — and nothing more:
- Explicitly cancellable handles — every listener/handle the
client direction returns (the
-Rlistener handles fromw4-reverse-listen, the connection handle fromw4-dial) carries an explicit cancellation/clean-close path; no handle relies on drop timing alone for wire-visible state. - Clean disconnect —
Disconnect::ByApplication(russh's clientHandle::disconnect): the explicit-close path leaves no half-open channels, observed clean by the peer (row-pinned through the server double). - No reconnect or restore policy owned by the crate — the module-doc posture: reconnect-and-restore (rebinding persisted tunnel ports — the alkminer flow, reverse-runner fact 4) is assembly policy; the crate provides bound ports, handles, cancellation, clean close. The docs state the boundary explicitly so no later task drifts into baking policy in.
- Contract rows through the server double — cancel a listener mid-connection (the server's forward is torn down, the bound port released); clean disconnect observed by the peer; the connection-handle cancellation path; the no-policy posture is a docs-honesty item for the gate, not a row.
Acceptance Criteria
- Every client-direction listener/handle is explicitly cancellable; no drop-timing-only wire state
Disconnect::ByApplicationclean-close lands; the peer observes a clean disconnect, no half-open channels (row-pinned)- The no-reconnect-policy boundary stated in module docs (the primitives-not-policy posture)
- Contract rows green through the server double
cargo test(+ per-feature +--all-features), clippy ×2 (-D warnings), fmt,cargo doc --no-depspass
References
- docs/architecture/client.md §Lifecycle primitives (the OQ-12 resolution), docs/architecture/open-questions.md (OQ-12 — resolved: primitives only)
- docs/research/reverse-runner.md (fact 4 — reconnect-and-restore is assembly policy, kept in view so the crate never bakes it in)
- docs/research/russh.md §Client surface (
Handle::disconnect, the forward cancel forms) - src/tunnel_map.rs (the server-side teardown posture — the cancel semantics mirror)
Notes
What landed (2026-10-29)
src/client_accept.rs: the listener handles — [ReverseListener] + [ReverseEndpoint] (the handles' surface thew4-reverse-listenblock deferred to this task):reverse_listen/reverse_listen_unixreturn a handle carrying the actually-bound endpoint (thebound_port()accessor,Nonefor the unix shape) with the explicitcancel(); the shared cancel helpers (cancel_reverse_tcp/cancel_reverse_unix) — both the handles'cancel()and the stateless wire forms compose into the same paths; the confirmed cancel releases the connection's listener count exactly once (the repeat is the wire's unknown-listener refusal, no release). The recorded decisions (module docs): noDrophides a wire op (async cancel cannot ride a syncDrop); the handle shares the connection's internals through the arc so itscancel()works against a live stack regardless of the connection handle's own holders; the stateless forms ride beside 1:1.src/client.rs: theSshConnectionrestructure — the russh handle, limits/labels, acceptor registry and listener budget ride oneConnectionSharedbehind theArc(the concurrency-safe sharing the handles' independentcancel()s need; the russh client request methods all take&selfat this pin, so the arc is the sharing shape — no mutex),SshConnection's public surface unchanged (close()consumes the handle and sendsDisconnect::ByApplication), and the module docs' new §Lifecycle posture (the primitives-not-policy boundary, reverse-runner fact 4; the explicit-vs-drop facts, row-pinned).src/lib.rs: theReverseListener/ReverseEndpointexports (undertunnel).docs/architecture/client.md: the §Lifecycle primitives landing block (the handle shape + the abrupt-drop/clean-close boundary + the modules-docs posture anchors).tests/client_dial.rs: the every-build rowdropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path(thenext_session_outcomeobservable: drop → the peer's session ends with an error wire class, never the cleanOk— the boundary pinned both ways: the cleanOkis onlyclose()'s).tests/client_reverse_listen.rs: three new lifecycle rows —dropping_the_listener_handle_leaves_no_drop_timing_wire_state(drop → the peer's listen still serves a round-tripping data leg; only the explicit cancel tears down),the_listener_handle_cancel_tears_the_forward_down_and_releases_the_budget(ceiling → refuse pre-wire; the handle's cancel confirms and releases; the next listen admits; the repeat rides the wire and refuses — no budget manufactured),cancel_mid_data_leg_tears_the_forward_down_mid_connection(the cancel lands while a data leg is mid-pump; the forward tears down, the connection stays usable, ends clean); the existing rows adapted to the handle returns (the endpoint assertions ride the handles now); the unix row's cancel exercises the handle'scancel()(the streamlocal flavor).
Decided + recorded
- The handles' shape — single
ReverseListener, oneReverseEndpointenum, not two handle types. The endpoint enum mirrors the wire's two bind shapes (an actually-bound address+port / a socket path); the tcp-flavoredbound_port()accessor returnsOption(the unix shape carries no port — honest, no panic path). A second handle type would have duplicated the cancel plumbing for a shape the cancel forms already treat as raw strings. - The abrupt-drop fact (row-pinned, corrected mid-task): at this
russh pin dropping the last wire-stack handle closes the
Handle's sender; the client runner'sreceiver.recv()→Nonesetsdisconnectedand the transport tears down abruptly — the peer's session outcome is an error wire class (Err(Disconnect)observed through the double). So the honest boundary is not "drop does nothing" but "drop is never the clean path" — the cleanOksession end is onlyclose()'s (Disconnect::ByApplication), row-pinned in both directions (abrupt-on-drop, clean-on-close) through the server double. No handle relies on drop timing for clean wire state, and the listener handles' drop is a pure local no-op (noDrophook; a cancellation is async and cannot hide in one — the listen lives at the peer until an explicit cancel or the connection's end). - The
SshConnectionrestructure is a shape change, not a surface change — the public surface (the wrapped methods,limits()/address()/username()/is_closed()/acceptors()/close()) is unchanged; only the internals moved behind the arc (the handles' independent cancel). NoClonewas added — not in the contract, and the arc means a cloned handle'sclose()semantics would need their own contract; wave-5 review can add it if an assembly shape needs it. - A live
ReverseListenerkeeps the wire stack alive after the connection handle's drop — the arc's ownership side effect, recorded (the alkminer flow's shape: the listener handle outlives the connection slot; itscancel()composes on the still-live stack).
Verification
cargo test (default), --features session, --features git,
--features tunnel, --all-features — all green (16 test binaries
each); cargo clippy --all-targets -- -D warnings and
cargo clippy --all-features --all-targets -- -D warnings clean;
cargo fmt --check clean; cargo doc --no-deps warning-free in the
default build (the pre-existing client_ops.rs private-link warnings
remain under --all-features, unchanged).
Summary
The OQ-12 primitives landed: the ReverseListener handles carrying
the actually-bound endpoint with the explicit wire-honest cancel()
(releases the listener count exactly once per confirmed cancel; the
stateless cancel forms compose into the same shared paths), the
SshConnection internals restructured behind the arc so handles
cancel against a live stack independently, the public surface
unchanged, the abrupt-drop/clean-close boundary row-pinned both ways
(drop is never the clean path; close() is), and the
primitives-not-policy posture stated in the module docs and
client.md's landing block. No reconnect/restore policy in the crate.
Details in the Landed block below.
Landed (feat(client): w4-lifecycle):
src/client_accept.rs— the listener handles:ReverseListener(+ReverseEndpoint) —reverse_listen/reverse_listen_unixnow return them; the actually-bound endpoint carried, the explicitcancel()the wire-honest teardown, the listener count released exactly once per confirmed cancel through the shared cancel paths the stateless wire forms compose into too; noDrophides a wire op (recorded, row-pinned no-drop-timing wire state).src/client.rs— theSshConnectioninternals behind the arc (ConnectionShared) + the §Lifecycle posture module docs (the primitives-not-policy boundary, the abrupt-drop/clean-close facts); the public surface unchanged (close()is the clean path).src/lib.rs— the handle exports;docs/architecture/ client.md— the §Lifecycle primitives landing block.- Rows — 3 new tunnel-build rows through the server double
(
tests/client_reverse_listen.rs: the drop-handle no-wire-state row, the handle-cancel + budget-release row, the cancel-mid-data-leg row) + 1 new every-build row (tests/client_dial.rs: the abrupt- drop row); the existing reverse rows adapted to the handles; the unix cancel row exercises the handle flavor.
Verification: cargo test / --features <each> / --all-features
all green; clippy ×2 (-D warnings) clean; cargo fmt --check
clean; cargo doc --no-deps clean (default build).