Files
alkssh/tasks/implementation/w4-lifecycle.md
T
glm-5.3-flash 671a779ce3 docs(review): close the wave-4 review gate — verdict green (review-wave-4)
- 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)
2026-10-29 17:59:54 +00:00

10 KiB
Raw Blame History

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
w4-op-rows
w4-reverse-listen
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 -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 — 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 — 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::ByApplication clean-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-deps pass

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 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) — 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 — 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 — 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 — 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 — 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 — 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.
  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" — 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 — 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 — 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 — the arc's 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) — reverse_listen / reverse_listen_unix now return them; 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 — 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).