Files
alkssh/tasks/implementation/w4-lifecycle.md
T
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
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.
2026-11-04 20:28:00 +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, 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

  • 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, 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 <each> / --all-features all green; clippy ×2 (-D warnings) clean; cargo fmt --check clean; cargo doc --no-deps clean (default build).