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:
1 parent
68d3723f6a
commit
2e5a7c9cee
5 files changed
+640
-36
No files matched your search
+68
-28
@@ -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
|
||||
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user