feat(serve): land the spawn-and-return plumbing + exit/status mechanics (w2-spawn-return)

- src/serve.rs (new): the ADR-006 decision-4 mechanics in code —
  spawn_serving_path (the one choke point; nothing awaited inline)
  and report_exit_status_then_close (exit-status before close via
  the cloned Session::handle()), #[doc(hidden)] contract-test
  re-exports; a unit row pins spawn-returns-while-pending.
- src/handler.rs: the four session-row seam stubs became spawned
  serving paths (failure-then-close unchanged; stubs are plain fns —
  an async stub holding &Session across the spawn is non-Send); open-
  level declines and the tunnel seam stub stay inline by class, per
  the new module-doc section.
- tests/spawn_return.rs (new): two end-to-end wire rows — the client
  observes exit-status(7) strictly before close and after the handler
  returned; the handler-returns-fast row routes follow-up traffic
  while the serving path is still gated (POC-3 deadlock inversion).
- tasks/implementation/w2-spawn-return.md — completed with notes.

Verification (all green): cargo test; per-feature dispatch matrices
(--features git/session/tunnel/sftp, --all-features); cargo clippy
--all-targets/-all-features with -D warnings; cargo fmt --check;
cargo doc --no-deps (no warnings); taskgraph validate.
This commit is contained in:
glm-5.3-flash committed 2026-10-28 11:04:17 +00:00
1 parent 68d3723f6a
commit 2e5a7c9cee
5 files changed
+640 -36

No files matched your search

+68 -28
View File
@@ -69,7 +69,30 @@
//! the store-and-take handoff (`channels.remove(&id)` → `into_stream`
//! in the spawned serving task) is wave-3 mapping material; the
//! routing decisions around it are already this wave's, because the
//! routing stubs decline at the seam.
//! routing stubs spawn their decline at the seam.
//!
//! ## The spawn-and-return structural constraint (ADR-006 decision 4)
//!
//! russh at this pin runs handler callbacks **inline inside the
//! session's packet loop** — a blocking handler suspends all packet
//! routing and deadlocks against the peer's follow-up bytes (POC-3
//! proved by construction with receive-pack). Every serving path is
//! therefore spawned through [`crate::serve::spawn_serving_path`] and
//! the handler returns fast: the session-row seam stubs clone
//! `session.handle()`, spawn their decline through the clone
//! ([`crate::serve`]), and are gone from the hook's stack — no hook
//! awaits a serving path inline. The decline keeps its wire shape
//! (failure then close), riding the spawn. Exit/status reporting from
//! spawned tasks rides the same cloned handle
//! ([`crate::serve::report_exit_status_then_close`] — the mechanics;
//! the derivation rules are wave-3 `mapping.md` material), pinned
//! end-to-end by the mechanics rows in `tests/spawn_return.rs`.
//!
//! The open-level declines (unfeatured/never-feature rows, the tunnel
//! seam stub) stay inline by class: they answer the open *before* any
//! channel is owned — nothing is served yet, so nothing spawns. Wave
//! 3's accept-only-after-fabric-open flow is where the tunnel row
//! gains its spawned serving path, per the same constraint.
//!
//! Request-level declines: `subsystem_request` declines every name —
//! `sftp` is the OQ-13 placeholder (the feature exists, the route
@@ -101,7 +124,9 @@
//! usable (the POC-1 §5 session-survives-declines posture); a
//! *serving-path* decline (the wave-3 seam stubs) answers failure
//! and closes the channel — the vanilla-clean shape for a row that
//! will spawn its serving path next wave.
//! owns the channel. The serving-path decline now rides the spawned
//! serving task (ADR-006 decision 4, §The spawn-and-return
//! structural constraint above); the wire shape is unchanged.
//!
//! ## Test surface
//!
@@ -118,13 +143,16 @@ use std::sync::Arc;
use alkcall::core::auth::Identity;
use russh::keys::Algorithm;
use russh::server::{Auth, ChannelOpenHandle, Msg, PublicKeyOffer, Session, VerifiedPublicKey};
use russh::server::{
Auth, ChannelOpenHandle, Handle, Msg, PublicKeyOffer, Session, VerifiedPublicKey,
};
use russh::{Channel, ChannelId, Pty};
use crate::config::ServeConfig;
use crate::dispatch;
use crate::error::AdapterError;
use crate::fingerprint::fingerprint;
use crate::serve;
/// The per-connection handler (one per mounted stream): russh constructs
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted
@@ -403,14 +431,14 @@ impl russh::server::Handler for SessionHandler {
match dispatch::classify_exec(command) {
dispatch::ExecRoute::Git => {
if self.serves_git_row() {
git_row_stub(channel, session).await;
git_row_stub(channel, session);
} else {
decline_unfeatured_request(channel, session).await;
}
}
dispatch::ExecRoute::Pipe => {
if self.serves_session_rows() {
pipe_row_stub(channel, session).await;
pipe_row_stub(channel, session);
} else {
decline_unfeatured_request(channel, session).await;
}
@@ -433,7 +461,7 @@ impl russh::server::Handler for SessionHandler {
session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_session_rows() {
pty_row_stub(channel, session).await;
pty_row_stub(channel, session);
} else {
decline_unfeatured_request(channel, session).await;
}
@@ -450,7 +478,7 @@ impl russh::server::Handler for SessionHandler {
session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_session_rows() {
shell_row_stub(channel, session).await;
shell_row_stub(channel, session);
} else {
decline_unfeatured_request(channel, session).await;
}
@@ -571,45 +599,57 @@ async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) {
}
/// The family-seam decline shape for a **routed-but-not-yet-served**
/// row: fail the request and close the channel — the vanilla-clean
/// shape for a serving path that would have owned the channel (wave 3
/// replaces these stubs with the spawned serving paths; a declined
/// seam never leaks a half-served channel, since the decline IS the
/// row this wave). Decline-first-alloc-never holds structurally:
/// nothing is allocated before the failure lands.
async fn seam_stub_decline(channel: ChannelId, session: &mut Session) {
let _ = session.channel_failure(channel);
let _ = session.close(channel);
/// row — now the spawned serving task's body: fail the request and
/// close the channel, through the cloned handle the task was handed.
/// The vanilla-clean shape for a serving path that owns the channel
/// (wave 3 replaces these stubs with the real serving paths; a
/// declined seam never leaks a half-served channel, since the decline
/// IS the row this wave). Decline-first-alloc-never holds structurally:
/// nothing is allocated before the failure lands — the spawn's handle
/// clone is the only resource ever taken.
async fn seam_stub_decline(handle: Handle, channel: ChannelId) {
let _ = handle.channel_failure(channel).await;
let _ = handle.close(channel).await;
}
/// Spawns the seam-stub decline as the row's serving path (ADR-006
/// decision 4): the handle is cloned here, the decline runs on its own
/// task, and the hook returns — no serving path awaits inline.
fn spawn_seam_stub(channel: ChannelId, session: &Session) {
let handle = session.handle();
serve::spawn_serving_path(seam_stub_decline(handle, channel));
}
/// The git row's seam stub — wave 3 lands the alkgit seam here
/// (`parse command → Service + repo → serve_duplex_resolved`), spawned
/// per ADR-006 decision 4; the skeleton declines at the seam.
async fn git_row_stub(channel: ChannelId, session: &mut Session) {
seam_stub_decline(channel, session).await;
/// per ADR-006 decision 4; the skeleton declines at the seam, on its
/// own task.
fn git_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session);
}
/// The session pipe row's seam stub — wave 3 lands the alktty pipe
/// seam here (`wrapper ++ [cmd]`, the OQ-08 argv append), spawned per
/// ADR-006 decision 4; the skeleton declines at the seam.
async fn pipe_row_stub(channel: ChannelId, session: &mut Session) {
seam_stub_decline(channel, session).await;
/// ADR-006 decision 4; the skeleton declines at the seam, on its own
/// task.
fn pipe_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session);
}
/// The session pty row's seam stub — wave 3 lands the alktty
/// `NegotiateRequest.tty` construction here
/// (`drive_session_pre_negotiated`), spawned per ADR-006 decision 4;
/// the skeleton declines at the seam.
async fn pty_row_stub(channel: ChannelId, session: &mut Session) {
seam_stub_decline(channel, session).await;
/// the skeleton declines at the seam, on its own task.
fn pty_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session);
}
/// The session shell row's seam stub — wave 3 lands the alktty
/// `NegotiateRequest.cmd` = serve-config shell argv here (OQ-08, no
/// crate default), spawned per ADR-006 decision 4; the skeleton
/// declines at the seam.
async fn shell_row_stub(channel: ChannelId, session: &mut Session) {
seam_stub_decline(channel, session).await;
/// declines at the seam, on its own task.
fn shell_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session);
}
/// The tunnel row's seam stub: the explicit `ConnectFailed` reject
+7
View File
@@ -32,6 +32,7 @@ mod fingerprint;
mod handler;
mod host_key;
mod key_source;
mod serve;
pub use adapter::SshAdapter;
pub use config::{DialConfig, Limits, ServeConfig};
@@ -46,3 +47,9 @@ pub use fingerprint::fingerprint;
pub use handler::SessionHandler;
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
/// Contract-test surface (`#[doc(hidden)]`): the spawn-and-return
/// plumbing (`ADR-006 decision 4`, see `serve.rs`) — the mechanics the
/// mechanics-proof rows in `tests/spawn_return.rs` compose over the
/// real wire to pin the constraint as tests. Not documented API.
#[doc(hidden)]
pub use serve::{report_exit_status_then_close, spawn_serving_path};
+93
View File
@@ -0,0 +1,93 @@
//! The spawn-and-return plumbing (`w2-spawn-return`) — ADR-006
//! decision 4, the structural constraint of the whole spine, in code.
//!
//! russh at this pin runs server handler callbacks **inline inside the
//! session's packet loop** — a blocking handler suspends all packet
//! routing for the connection and deadlocks against the peer's
//! immediate follow-up bytes (POC-3 proved by construction with
//! receive-pack). The crate's answer is mechanical, not conventional:
//!
//! - **Every serving path is spawned** ([`spawn_serving_path`]) — the
//! handler hook clones the session handle
//! (`Session::handle()`), hands it into its serving future, spawns,
//! and returns. No serving path is ever awaited inline; the dispatch
//! seam stubs ([`crate::handler`]) hang off this helper, so the
//! wave-3 mappings inherit the pattern mechanically from the stubs.
//! - **Exit/status reporting rides the cloned handle** — the spawned
//! task's only report surface is the handle clone it was handed;
//! [`report_exit_status_then_close`] preserves the wire ordering the
//! `mapping.md` `exit_status` row pins: `exit-status` sent **before**
//! close, from the spawned task.
//!
//! The *derivation contract's rules* (which exit code → which SSH exit
//! shape, `mapping.md` §Exit chunk → SSH exit reporting rules 1–6) are
//! wave-3 session-mapping material; this module is the mechanics only.
//!
//! ## Test surface
//!
//! Both helpers are re-exported `#[doc(hidden)]` from the crate root —
//! the `handler.rs` §Test surface pattern: the mechanics-proof rows in
//! `tests/spawn_return.rs` compose them over the real wire (a minimal
//! proof handler standing in for the crate's own auth/dispatch hooks,
//! which have their own contract suites) to pin the constraint as
//! tests, not convention.
use std::future::Future;
use russh::ChannelId;
use russh::server::Handle;
/// Spawns one serving path on its own task (ADR-006 decision 4): the
/// caller — the handler hook — clones the session handle, hands it
/// into the serving future, calls this, and returns immediately.
/// Nothing here is ever awaited inline: an inline serve deadlocks
/// against the peer's follow-up bytes (POC-3 proved by construction).
pub fn spawn_serving_path(serving: impl Future<Output = ()> + Send + 'static) {
tokio::spawn(serving);
}
/// The spawned task's exit report through the cloned handle:
/// `exit-status(N)` then close — the ordering contract
/// (`mapping.md`'s `exit_status` row: sent **before** close, from the
/// spawned task). A failed send means the session is gone; nothing is
/// reportable then.
pub async fn report_exit_status_then_close(handle: &Handle, channel: ChannelId, exit_code: u32) {
let _ = handle.exit_status_request(channel, exit_code).await;
let _ = handle.close(channel).await;
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::spawn_serving_path;
/// - *Contract stamp:* ADR-006 decision 4 (spawn-and-return: the
/// handler call returns while the serving path is still running
/// — handler-returns-fast is structural).
#[tokio::test]
async fn spawn_returns_while_the_serving_path_is_still_pending() {
let (release, waited) = tokio::sync::oneshot::channel::<()>();
let done = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&done);
spawn_serving_path(async move {
let _ = waited.await;
flag.store(true, Ordering::Release);
});
assert!(
!done.load(Ordering::Acquire),
"the spawn call returned while the serving path was still gated — \
no serving path is awaited inline"
);
release
.send(())
.expect("the gate releases exactly once, to the still-running path");
while !done.load(Ordering::Acquire) {
tokio::task::yield_now().await;
}
}
}