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.
204 lines
10 KiB
Markdown
204 lines
10 KiB
Markdown
---
|
||
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). |