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

204 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 <each>` / `--all-features`
all green; clippy ×2 (`-D warnings`) clean; `cargo fmt --check`
clean; `cargo doc --no-deps` clean (default build).