--- id: w2-spawn-return name: "Wave 2: spawn-and-return plumbing + exit/status reporting mechanics" status: completed depends_on: [w2-dispatch-decline-core] scope: narrow risk: medium impact: phase level: implementation --- ## Description Land the structural constraint in code (ADR-006 decision 4): russh at this pin runs server handler callbacks **inline inside the session's packet loop**, so a blocking handler suspends all packet routing connection and deadlocks against the peer's immediate follow-up bytes (POC-3 proved by construction with receive-pack). Therefore: - **Every serving path is `tokio::spawn`ed; handlers return fast.** The dispatch's routing stubs become spawned serving paths (still declining stubs, since the seams are wave 3), so the pattern is in place before any real mapping hangs on it. - **Exit/status reporting mechanics**: reporting from spawned tasks rides the cloned `Session::handle()`: `exit-status` sent **before** channel close, from the spawned task. The *derivation contract's rules* (which code → which SSH exit shape) are wave-3 session-mapping material (`mapping.md` rules 1–6); this task lands the mechanics (the handle clone, the exit-status send, the ordering-before-close), proven end to end. - **The proof**: a stub serving path that spawns, completes asynchronously (after the handler has returned), and reports `exit-status(N)` + close through the cloned handle; the loopback harness client observes the exit-status arriving after the handler already returned. A handler-returns-fast assertion (the handler completes while the spawned task is still running) pins the constraint as a test, not a convention. This is a load-bearing correctness constraint of the whole spine, not an implementation choice; the wave-3 mappings inherit the pattern mechanically from the stubs this task leaves behind. ## Acceptance Criteria - [x] Every serving path (the dispatch stubs) runs in a `tokio::spawn`; no handler hook awaits a serving path inline - [x] Exit/status reporting from spawned tasks rides the cloned `Session::handle()`; `exit-status` is sent before channel close - [x] A harness test proves the mechanics end to end: the client observes `exit-status(N)` from a spawned stub that completed after the handler returned - [x] A handler-returns-fast assertion pins the structural constraint as a test - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4, the structural constraint) - docs/architecture/mapping.md §The structural constraint: spawn-and-return; §exit_status row (sent before close, from the spawned task) - docs/plans/implementation.md §Wave 2 (the spawn-and-return bullet) - docs/research/poc-3-mapping-bakeoff.md (the deadlock-by-construction evidence) ## Notes - `src/serve.rs` (new): the spawn-and-return plumbing as *mechanics*, not convention: `spawn_serving_path(serving future)` (the one choke point for the ADR-006 decision-4 spawn; nothing it takes is ever awaited inline) and `report_exit_status_then_close(&Handle, channel, N)` (the cloned-handle report with the `mapping.md` `exit_status` row's ordering baked in: exit-status **before** close). Both are re-exported `#[doc(hidden)]` from the crate root, the `SessionHandler` §Test-surface pattern, as the contract-test surface the mechanics rows compose over the real wire. A unit row (`spawn_returns_while_the_serving_path_is_still_pending`, latch-gated future, spawn call returns first) pins spawn-then-return directly. The derivation contract's *rules* (which exit code → which SSH exit shape) stay wave-3 `mapping.md` material; no rule logic landed here. - `src/handler.rs`: the four session-row seam stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`, `shell_row_stub`) are now spawned serving paths: `spawn_seam_stub` clones `session.handle()`, `seam_stub_decline(handle, channel)` runs the failure-then-close decline on the spawned task, and the hooks return without awaiting anything. A compile-level fact coded around: the row stubs are plain `fn`s (not `async`), because an `async fn` holding `&Session` across the spawn makes the handler future non-`Send` (russh requires `+ Send` on handler futures; `Session` is not `Sync`), so the clone is taken synchronously and only the handle moves into the spawned future. Wire shapes are unchanged (all `w2-dispatch-decline-core` rows pass unmodified with the spawned decline); the tunnel seam stub and every open-level/capability decline stay inline by class, as they answer the open request *before* any channel is owned, so nothing spawns yet (documented in the new handler module-doc section §The spawn-and-return structural constraint; wave 3's accept-only-after-fabric-open flow is where the tunnel row gains its spawn). - `tests/spawn_return.rs` (new): the end-to-end mechanics proof over the real wire, using the russh server stack (duplex transport, harness client/harness transport posture) mounted over a minimal proof handler that is *not* the crate's `SessionHandler` (the auth and dispatch hooks have their own suites; the proof subject is the mechanics) and that serves execs by composing the exported plumbing: `spawn_serving_path` of a release-gated serving future reporting `exit-status(7)` + close through the cloned handle. Two rows, each with a `Contract stamp:`: - `handler_returns_fast_while_the_spawned_serving_path_is_pending`, the structural constraint as a wire test: after the exec hook has provably returned (a bounded wait on the handler-side timestamp; the client's exec returns when the request is on the wire, not when the hook ran) and with the serving path still gated, the client's *follow-up traffic* (a second session channel + second exec) is routed and dispatched normally, with POC-3's follow-up-bytes deadlock shape inverted, and the still-gated no-report state is asserted before the release. - `spawned_stub_reports_exit_status_before_close_after_the_handler_returned` with the client observing `ExitStatus{7}` strictly before `Close` (message-by-message, no draining that could hide ordering), and the recorded timestamps prove the exit-status left *after* the handler had already returned (the literal criterion). - **The proof-handler design note:** the gate is `tokio::sync::watch`- based (no lost-wakeup race; a late subscriber sees the release, which a `Notify`-with-`notify_waiters` shape could miss against multiple spawned paths); the handler-side timestamps ride `parking_lot::Mutex`. - Feature independence: the mechanics are feature-independent (the helpers exist in every build and the proof rows run in every build they were verified under (default, `git`, `session`, `tunnel`, `sftp`, `--all-features`); no feature-gated code was added, so no new decline-path coverage obligations arise. ## Summary Landed the spawn-and-return plumbing + exit/status reporting mechanics (`w2-spawn-return`) in three files: - **`src/serve.rs`** with `cargo test`-pinned mechanics: the `spawn_serving_path` choke point (the ADR-006 decision-4 rule, in code, never awaited inline) and `report_exit_status_then_close` (exit-status before close through the cloned `Session::handle()`), both `#[doc(hidden)]` contract-test re-exports; a unit row pins spawn-returns-while-pending directly. - **`src/handler.rs`**: the four session-row seam stubs became spawned serving paths (handle clone taken synchronously, given the non-`Send` shape that forcing `async fn` stubs would produce is coded around and recorded); failure-then-close wire shapes unchanged (all dispatch-core rows pass unmodified); open-level declines and the tunnel seam stub stay inline by class, documented; a new module-doc section carries the constraint and the wave-3 inherit-the-pattern note. - **`tests/spawn_return.rs`**: the end-to-end mechanics proof, with two `Contract stamp:` rows over the real wire (handler-returns-fast under follow-up traffic with the spawn still pending; exit-status before close, timestamps proving the report outlived the handler's return), driven by a proof handler composing the exported plumbing. Verification (all green): `cargo test` · `cargo test --features {git,session,tunnel,sftp} --test dispatch_core` · `cargo test --all-features` · `cargo clippy --all-targets -- -D warnings` · `cargo clippy --all-features --all-targets -- -D warnings` · `cargo fmt --check` · `cargo doc --no-deps` (no warnings). 93 lib/unit rows + the per-build dispatch matrices + the two mechanics rows across 7 test binaries.