--- id: w4-lifecycle name: "Wave 4: lifecycle primitives (cancellable handles + clean disconnect)" status: completed depends_on: [w4-op-rows, w4-reverse-listen] scope: narrow risk: low impact: component level: 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**, so every listener/handle the client direction returns (the `-R` listener handles from `w4-reverse-listen`, the connection handle from `w4-dial`) carries an explicit cancellation/clean-close path; no handle relies on drop timing alone for wire-visible state. - **Clean disconnect**, namely `Disconnect::ByApplication` (russh's client `Handle::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**, hence the module-doc posture: reconnect-and-restore (rebinding persisted tunnel ports, the alkminer flow, per 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 - [x] Every client-direction listener/handle is explicitly cancellable; no drop-timing-only wire state - [x] `Disconnect::ByApplication` clean-close lands; the peer observes a clean disconnect, no half-open channels (row-pinned) - [x] The no-reconnect-policy boundary stated in module docs (the primitives-not-policy posture) - [x] Contract rows green through the server double - [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2 (`-D warnings`), fmt, `cargo doc --no-deps` pass ## References - docs/architecture/client.md §Lifecycle primitives (the OQ-12 resolution), docs/architecture/open-questions.md (OQ-12, which resolved as primitives only) - docs/research/reverse-runner.md (fact 4, where 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**, namely the [`ReverseListener`] + [`ReverseEndpoint`] (the handles' surface the `w4-reverse-listen` block deferred to this task): `reverse_listen` / `reverse_listen_unix` return a handle carrying the *actually-bound* endpoint (the `bound_port()` accessor, `None` for the unix shape) with the explicit `cancel()`; the shared cancel helpers (`cancel_reverse_tcp` / `cancel_reverse_unix`), and 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): no `Drop` hides a wire op (async cancel cannot ride a sync `Drop`); the handle shares the connection's internals through the arc so its `cancel()` works against a live stack regardless of the connection handle's own holders; the stateless forms ride beside 1:1. - `src/client.rs`: the **`SshConnection` restructure**, where the russh handle, limits/labels, acceptor registry and listener budget ride one `ConnectionShared` behind the `Arc` (the concurrency-safe sharing the handles' independent `cancel()`s need; the russh client request methods all take `&self` at this pin, so the arc is the sharing shape, with no mutex), `SshConnection`'s public surface unchanged (`close()` consumes the handle and sends `Disconnect::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`: the `ReverseListener` / `ReverseEndpoint` exports (under `tunnel`). - `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 row `dropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path` (the `next_session_outcome` observable: drop → the peer's session ends with an **error wire class, never the clean `Ok`**, the boundary pinned both ways: the clean `Ok` is only `close()`'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, with 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's `cancel()` (the streamlocal flavor). ### Decided + recorded 1. **The handles' shape, a single `ReverseListener`, one `ReverseEndpoint` enum, not two handle types.** The endpoint enum mirrors the wire's two bind shapes (an actually-bound address+port / a socket path); the tcp-flavored `bound_port()` accessor returns `Option` (the unix shape carries no port, honestly with no panic path). A second handle type would have duplicated the cancel plumbing for a shape the cancel forms already treat as raw strings. 2. **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's `receiver.recv()` → `None` sets `disconnected` and 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"**, since the clean `Ok` session end is only `close()`'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 (no `Drop` hook; a cancellation is async and cannot hide in one, and the listen lives at the peer until an explicit cancel or the connection's end). 3. **The `SshConnection` restructure 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). No `Clone` was added, which is not in the contract, and the arc means a cloned handle's `close()` semantics would need their own contract; wave-5 review can add it if an assembly shape needs it. 4. **A live `ReverseListener` keeps the wire stack alive after the connection handle's drop**, an arc ownership side effect recorded (the alkminer flow's shape: the listener handle outlives the connection slot; its `cancel()` 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`), which `reverse_listen` / `reverse_listen_unix` now return; the actually-bound endpoint carried, the explicit `cancel()` 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; no `Drop` hides a wire op (recorded, row-pinned no-drop-timing wire state). - **`src/client.rs`**, the `SshConnection` internals 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`** for the handle exports; **`docs/architecture/ client.md`** for the §Lifecycle primitives landing block. - **Rows**, with 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 ` / `--all-features` all green; clippy ×2 (`-D warnings`) clean; `cargo fmt --check` clean; `cargo doc --no-deps` clean (default build).