test(mapping): land the channel-not-held guard's wire rows on all three served rows (w4-channel-not-held-rows)

- git row: a second exec on a served git channel answers channel_failure
  inline — the serve rides on, no second serving path; race-free by
  construction (advertisement drained, serve parked at the gated list_refs)
- tty rows: the pipe row (second exec) and the shell row (second shell
  request) — the same inline decline shape, channel still usable
- the rows assert the decline event's exact request vocabulary (class
  request, reason channel-not-held, route git/pipe/absent, debug level,
  self-describing build) — the class's vocabulary coverage
- tests/common/event_capture.rs: the observe-suite's capture convention
  shared across the mapping binaries
- test-only: no production change; the guard held its documented shape
  in every featured build (verified default / git / session /
  session,git / tunnel / sftp / --all-features)

Verified: cargo test (+ per-feature + --all-features) all green ·
cargo clippy --all-targets -- -D warnings ✓ · cargo clippy
--all-features --all-targets -- -D warnings ✓ · cargo fmt --check ✓ ·
cargo doc --no-deps 0 warnings · taskgraph validate ✓ (35 tasks)
This commit is contained in:
glm-5.3-flash committed 2026-10-29 07:27:59 +00:00
1 parent 9cf5bfbaf3
commit 3d3bb5e9bf
5 files changed
+470 -9

No files matched your search

@@ -1,7 +1,7 @@
---
id: w4-channel-not-held-rows
name: Wave 4 — channel-not-held guard wire rows (wave-3 gate booked item 2)
status: pending
status: completed
depends_on: [w4-git-exit-close]
scope: narrow
risk: low
@@ -40,12 +40,13 @@ than silently widening scope):
## Acceptance Criteria
- [ ] Wire rows for the channel-not-held decline class on all three
- [x] Wire rows for the channel-not-held decline class on all three
rows (git / tty pipe / shell), per build, through the harness
- [ ] The rows assert the wire shape + the tracing event class/reason
- [ ] No production code change (or a stop-and-booked finding
recorded for the gate — nothing silent)
- [ ] `cargo test` (+ per-feature + `--all-features`), clippy ×2
- [x] The rows assert the wire shape + the tracing event class/reason
- [x] No production code change (no guard gap exposed — nothing to
book; the guard's shape held on the wire in every featured
build)
- [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2
(`-D warnings`), fmt, `cargo doc --no-deps` pass
## References
@@ -59,8 +60,57 @@ than silently widening scope):
## Notes
> To be filled by implementation agent
- **Test-only, as planned** — no production change; no guard gap
surfaced (the landing verified the guard's documented shape on the
wire in every build that carries each row).
- **Rows landed** (one per served row, each with the inline-decline
wire shape — `channel_failure` while the first serving path rides
on — plus the channel-stays-usable witness and the tracing event's
exact request-decline vocabulary `src/observe.rs` carries):
- `tests/git_mapping.rs` (cfg `git`): a second exec on a served git
channel — the decline event carries `exec_route = "git"`, and the
declined re-exec never spawns a second serving path (the doubles'
resolve count stays at one). Race-free by construction: the
advertisement drains first and the serve parks at the doubles'
gated `list_refs` behind it, so the wire is quiescent for the
decline.
- `tests/tty_mapping.rs` (cfg `session`), two rows:
the pipe row (second exec on a served pipe channel — event
carries `exec_route = "pipe"`) and the shell row (second shell
request on a served shell channel — the event carries **no**
`exec_route` field, pinning the absent-field shape on the wire).
The exit-`None` shim script keeps the serve live and the wire
quiescent.
- **The per-build matrix**: the rows join the cfg-pinned convention —
git-build rows run in every build carrying `git`, session rows in
every build carrying `session` (verified across default / `git` /
`session` / `session,git` / `tunnel` / `sftp` / `--all-features`).
- **Shared capture helper**: `tests/common/event_capture.rs` (new) —
the observe-suite's recorded hand-rolled global-subscriber
convention shared across the mapping binaries; `await_event`
field-matching (parallel rows never order against each other) and
the exact-field-set assertion (`assert_request_decline`) — the
rows double as this class's vocabulary coverage, including the
`debug` level and the build self-description.
- **No `dispatch_core.rs` rows added** — that binary builds no seam,
so its git row declines at the config gate *before* the store-take;
the guard is unreachable there (the reference names the per-build
convention the rows join, which the mapping binaries carry).
## Summary
> To be filled on completion
The channel-not-held guard's wire rows landed on all three served
rows: `second_exec_on_a_served_git_channel_declines_channel_not_held`
(`tests/git_mapping.rs`), and the pipe/shell pair
(`tests/tty_mapping.rs`) — each pinning the inline decline shape
(`channel_failure`, no close, the first serve rides on, the channel
stays usable, no second serving path) plus the decline event's exact
vocabulary (class `request`, reason `channel-not-held`, route
`git`/`pipe`/absent, `debug` level, self-describing build). No
production change was needed — the guard held its documented shape in
every featured build. `tests/common/event_capture.rs` shares the
observe-suite's capture convention across the mapping binaries.
Verified: full suite + per-feature builds (default, `git`, `session`,
`session,git`, `tunnel`, `sftp`) + `--all-features` all green;
clippy ×2 (`-D warnings`), `cargo fmt --check`, `cargo doc --no-deps`
clean.
+195
View File
@@ -0,0 +1,195 @@
//! The decline-row tracing capture (`w4-channel-not-held-rows`): a
//! minimal hand-rolled global [`tracing::Subscriber`] recording every
//! event's level + field pairs, the mapping binaries' decline-class
//! rows assert the [`crate`'s event vocabulary](alkssh's observe
//! module) against. The recorded test convention (see
//! `tests/observe.rs`'s module docs) applies verbatim: no
//! `tracing-subscriber` dependency (the hand-rolled shape needs none),
//! and the **global** default is the only capture shape that works —
//! the server side runs on spawned tasks, so a thread-local default
//! never sees their events.
//!
//! Rows install once per binary with [`install`], then match events by
//! field pairs ([`await_event`]) — parallel rows never order against
//! each other — and pin the decline event's exact vocabulary with
//! [`assert_request_decline`] (the exact-field-set assertion is the
//! safety posture: no dynamic extra fields exist).
#![allow(dead_code)]
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
/// One captured event: the level and the field pairs, in emit order.
#[derive(Debug, Clone)]
pub struct Captured {
pub level: String,
pub fields: Vec<(String, String)>,
}
impl Captured {
fn field(&self, name: &str) -> Option<&str> {
self.fields
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
}
fn has(&self, want: &[(&str, &str)]) -> bool {
want.iter()
.all(|(n, v)| self.field(n).is_some_and(|got| got == *v))
}
}
struct FieldVisitor(Vec<(String, String)>);
impl tracing::field::Visit for FieldVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.push((field.name().to_owned(), value.to_owned()));
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.push((field.name().to_owned(), format!("{value:?}")));
}
}
#[derive(Default)]
struct GlobalCapture(Mutex<Vec<Captured>>);
impl tracing::Subscriber for GlobalCapture {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id {
tracing::Id::from_u64(1)
}
fn record(&self, _span: &tracing::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
let mut visitor = FieldVisitor(Vec::new());
event.record(&mut visitor);
self.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(Captured {
level: event.metadata().level().to_string(),
fields: visitor.0,
});
}
fn enter(&self, _span: &tracing::Id) {}
fn exit(&self, _span: &tracing::Id) {}
fn clone_span(&self, _id: &tracing::Id) -> tracing::Id {
tracing::Id::from_u64(1)
}
}
/// The installed capture — one per binary (the [`OnceLock`] makes the
/// exactly-once shape explicit).
fn capture() -> &'static Arc<GlobalCapture> {
static CAPTURE: OnceLock<Arc<GlobalCapture>> = OnceLock::new();
CAPTURE.get_or_init(|| {
let capture = Arc::new(GlobalCapture::default());
tracing::subscriber::set_global_default(Arc::clone(&capture))
.expect("the capture suite installs the global capture exactly once");
capture
})
}
/// Installs the global capture once per binary. Call it before a row's
/// first wire exchange, so no event is emitted below the
/// not-yet-installed global.
pub fn install() {
let _ = capture();
}
/// Waits until an event matching every field pair appears, then
/// returns it — the spawned-task surfaces land their events
/// asynchronously (the deadline loop is the observe-suite convention).
pub async fn await_event(want: &[(&str, &str)]) -> Captured {
let capture = capture();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let events = capture.0.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(event) = events.into_iter().find(|e| e.has(want)) {
return event;
}
if Instant::now() > deadline {
panic!("no tracing event matching {want:?} within the window");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
/// The expected `feature_build` value, derived from `cfg!`
/// independently of the crate's match — divergence breaks this loudly.
pub fn expected_build() -> &'static str {
match (
cfg!(feature = "session"),
cfg!(feature = "git"),
cfg!(feature = "tunnel"),
cfg!(feature = "sftp"),
) {
(false, false, false, false) => "none",
(true, false, false, false) => "session",
(false, true, false, false) => "git",
(false, false, true, false) => "tunnel",
(false, false, false, true) => "sftp",
(true, true, false, false) => "session+git",
(true, false, true, false) => "session+tunnel",
(true, false, false, true) => "session+sftp",
(false, true, true, false) => "git+tunnel",
(false, true, false, true) => "git+sftp",
(false, false, true, true) => "tunnel+sftp",
(true, true, true, false) => "session+git+tunnel",
(true, true, false, true) => "session+git+sftp",
(true, false, true, true) => "session+tunnel+sftp",
(false, true, true, true) => "git+tunnel+sftp",
(true, true, true, true) => "session+git+tunnel+sftp",
}
}
/// The request-decline vocabulary assertion: a captured request-level
/// decline carries exactly the vocabulary (sorted-name-set comparison —
/// nothing more; the dynamic-free shape is the safety posture), the
/// build self-describing, the message shape, and the `debug` level (a
/// decline is the door's normal, honest posture). `exec_route` present
/// iff the row was a routed exec.
pub fn assert_request_decline(event: &Captured, exec_route: bool) {
assert_eq!(
event.level, "DEBUG",
"a decline emits at debug — the vocabulary's level posture"
);
assert_eq!(
event.field("feature_build"),
Some(expected_build()),
"every event is self-describing per build"
);
assert_eq!(
event.field("message"),
Some("decline: channel request rejected"),
"the request-level decline message shape"
);
let mut names: Vec<&str> = event.fields.iter().map(|(n, _)| n.as_str()).collect();
names.sort_unstable();
let mut expected: Vec<&str> = vec![
"channel_id",
"decline_class",
"feature_build",
"message",
"reason",
"row",
];
if exec_route {
expected.push("exec_route");
}
expected.sort_unstable();
assert_eq!(
names, expected,
"a request decline carries exactly the vocabulary, nothing else"
);
}
+6
View File
@@ -66,6 +66,12 @@ pub mod git_doubles;
#[cfg(feature = "session")]
pub mod tty_doubles;
/// The decline-row tracing capture (`w4-channel-not-held-rows`) — the
/// mapping binaries' decline-class rows assert the event vocabulary
/// through it (the module's own doc records the convention:
/// `tests/common/event_capture.rs`).
pub mod event_capture;
use std::collections::HashMap;
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
+87
View File
@@ -21,6 +21,7 @@ mod serving {
use russh::client::Msg;
use russh::{Channel, ChannelMsg};
use super::common::event_capture as ec;
use super::common::git_doubles as gd;
use super::common::harness::{
DataGate, LoopbackSession, authenticate_publickey, await_close, drive,
@@ -864,6 +865,92 @@ mod serving {
}
session.unmount().await;
}
// ------------------------------------------------------------------
// The channel-not-held guard (`w4-channel-not-held-rows`): a second
// exec on an already-served channel declines request-level — the
// store-and-take handoff holds a channel exactly once, the wave-3
// gate's booked finding 2.
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §Request-level rows (the git
/// row's store-and-take handoff: the `channel-not-held` class —
/// a second exec on an already-served channel answers
/// `channel_failure`, inline, the first serve rides on — no
/// second serving path ever spawns) + the event vocabulary
/// (`src/observe.rs`: class `request`, reason
/// `channel-not-held`, route `git`; the advertisement drains
/// first and the serve parks at the gated `list_refs` behind it,
/// so the wire is quiescent for the decline — race-free by
/// construction).
#[tokio::test]
async fn second_exec_on_a_served_git_channel_declines_channel_not_held() {
ec::install();
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let serve_gate = doubles.install_gate("owner/demo");
let session = git_session(&doubles).await;
let mut channel = route_git(&session, "git-upload-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
loop {
match next_packet(&mut channel, &mut framer, "the advertisement").await {
Packet::Flush => break,
Packet::Payload(_) => {}
}
}
for _ in 0..200 {
if doubles.resolved_ids().len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert_eq!(
doubles.resolved_ids(),
vec!["owner/demo".to_owned()],
"the first serve resolved and parks at the gated list_refs behind \
its advertisement — the stored channel is taken, the wire quiescent"
);
request_exec(&channel, "git-upload-pack '/owner/demo'")
.await
.expect("the second exec rides the wire");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => {
panic!("a second exec on a served channel answers channel_failure, got {other:?}")
}
}
let event = ec::await_event(&[
("row", "exec_request"),
("decline_class", "request"),
("reason", "channel-not-held"),
("exec_route", "git"),
])
.await;
ec::assert_request_decline(&event, true);
assert_eq!(
doubles.resolved_ids().len(),
1,
"the declined re-exec never spawned a second serving path"
);
request_subsystem(&channel, "sftp")
.await
.expect("the alive channel carries another request");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("the inline decline leaves the channel usable, got {other:?}"),
}
serve_gate.release();
session.unmount().await;
}
}
#[cfg(unix)]
+124 -1
View File
@@ -25,9 +25,11 @@ mod serving {
use russh::client::Msg;
use russh::{Channel, ChannelMsg, Sig};
use super::common::event_capture as ec;
use super::common::harness::{
LoopbackSession, authenticate_publickey, drive, next_message, open_session_channel,
request_exec, request_pty, request_shell, serve_config as harness_serve_config,
request_exec, request_pty, request_shell, request_subsystem,
serve_config as harness_serve_config,
};
use super::common::tty_doubles as td;
use super::common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
@@ -405,6 +407,127 @@ mod serving {
session.unmount().await;
}
// ------------------------------------------------------------------
// The channel-not-held guard (`w4-channel-not-held-rows`): a second
// request on an already-served channel declines request-level — the
// store-and-take handoff holds a channel exactly once, the wave-3
// gate's booked finding 2.
// ------------------------------------------------------------------
/// - *Contract stamp:* mapping.md §Request-level rows (the exec
/// row's store-and-take handoff: the `channel-not-held` class —
/// a second exec on an already-served pipe channel answers
/// `channel_failure`, inline, the first serve rides on) + the
/// event vocabulary (`src/observe.rs`: class `request`, reason
/// `channel-not-held`, route `pipe`; the exit-`None` script
/// keeps the serve live and the wire quiescent, so the row is
/// race-free by construction).
#[tokio::test]
async fn second_exec_on_a_served_pipe_channel_declines_channel_not_held() {
ec::install();
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, "uptime")
.await
.expect("the first exec routed");
expect_success(&mut channel, "the served pipe exec").await;
request_exec(&channel, "uptime")
.await
.expect("the second exec rides the wire");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => {
panic!("a second exec on a served channel answers channel_failure, got {other:?}")
}
}
let event = ec::await_event(&[
("row", "exec_request"),
("decline_class", "request"),
("reason", "channel-not-held"),
("exec_route", "pipe"),
])
.await;
ec::assert_request_decline(&event, true);
request_subsystem(&channel, "sftp")
.await
.expect("the alive channel carries another request");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("the inline decline leaves the channel usable, got {other:?}"),
}
session.unmount().await;
}
/// - *Contract stamp:* mapping.md §Request-level rows (the
/// `shell_request` row's store-and-take handoff: the
/// `channel-not-held` class — a second shell request on an
/// already-served shell channel answers `channel_failure`,
/// inline, the first serve rides on) + the event vocabulary
/// (`src/observe.rs`: class `request`, reason
/// `channel-not-held`; the lifecycle row carries no
/// `exec_route` — absent-field shape pinned on the wire).
#[tokio::test]
async fn second_shell_request_on_a_served_shell_channel_declines_channel_not_held() {
ec::install();
let shim = Arc::new(td::TtyShim::new());
shim.set_script(td::ShimScript {
stdout: vec![],
stderr: vec![],
exit: None,
});
let session = session_with(shim_seam(&shim)).await;
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_shell(&channel)
.await
.expect("the first shell request routed");
expect_success(&mut channel, "the served shell row").await;
request_shell(&channel)
.await
.expect("the second shell request rides the wire");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!(
"a second shell request on a served channel answers channel_failure, \
got {other:?}"
),
}
let event = ec::await_event(&[
("row", "shell_request"),
("decline_class", "request"),
("reason", "channel-not-held"),
])
.await;
ec::assert_request_decline(&event, false);
request_subsystem(&channel, "sftp")
.await
.expect("the alive channel carries another request");
match next_message(&mut channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("the inline decline leaves the channel usable, got {other:?}"),
}
session.unmount().await;
}
#[tokio::test]
async fn stdin_bytes_and_the_eof_sentinel_reach_the_backend() {
let shim = Arc::new(td::TtyShim::new());