From 86ace38ef5d147d7ab0f566b929994f8a95e60f1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 19 Aug 2026 15:46:57 +0200 Subject: [PATCH] feat(gate,test): implement R-b + R-d --- the SIGINT deliverability guard scripts/check-sigint-deliverable is the single checked-in helper, to the ABI revision 12 fixed: exit 0 safe with no diagnostic, exit 1 ignored with the canonical wording, exit 2 error with a distinct one. The inner probe's `|| exit 24` arms are the load-bearing part --- without them a FAILED kill also falls through to exit 0 and gets misread as inherited SIG_IGN, which is the one wrong answer the helper exists to prevent. R-b: scripts/gate runs it before any stage and stops on a non-zero status, surfacing the helper's stderr unchanged and adding only that no stage ran. It does not re-derive the classification or supply its own wording. Plan/print modes skip it, since they run nothing. No override. R-d: the target test calls the same helper first and panics with "precondition failed --- this is NOT a teardown defect" plus the helper's own stderr, instead of reaching the misleading "child did not exit within 5s". The Linux-only /proc D1/D2 instrument is removed now that its evidence is portable, taking the platform dependency with it. Witnesses: A1 backgrounded gate stops before stage 1 with the ignored diagnosis, exit 1. A2 backgrounded direct test reports the precondition failure, NOT the 5s deadline. A3 foreground: both target copies pass in 0.16s and the guard is silent. A4 mutations measured, each biting its named row --- removing the trap bites A3 (fg 0->2), treating inner 0 as safe bites A1/A2 (bg 1->0), collapsing error into ignored bites A6 (forced 2->1). A5 the full default gate passes all 8 stages foreground, and --print-plan is byte-identical to HEAD's: no stage added, removed, reordered or made conditional. A6 forced probe failure yields exit 2 and the error wording, not the ignored wording. A7 exercised on Linux x86_64 only, all three outcomes; no non-Linux unix was reachable, so A7 stays OPEN there and the portability argument is labelled contract-level, not measured. Also records that this session's tool-level background mode leaves SIGINT deliverable while setsid nohup ... & does not --- so the construct that caused this lane was never necessary for long runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/gpu-probe-sigint-framing.md | 31 +++++- scripts/check-sigint-deliverable | 47 ++++++++ scripts/gate | 28 +++++ tests/gpu_invocation_acceptance.rs | 171 ++++++----------------------- 4 files changed, 136 insertions(+), 141 deletions(-) create mode 100755 scripts/check-sigint-deliverable diff --git a/docs/gpu-probe-sigint-framing.md b/docs/gpu-probe-sigint-framing.md index 0d81e8e..8de611b 100644 --- a/docs/gpu-probe-sigint-framing.md +++ b/docs/gpu-probe-sigint-framing.md @@ -738,6 +738,13 @@ never disagree about what "ignored" means: - **The target test invokes it** and reports the same precondition failure if run directly, instead of "child did not exit within 5s". +**One practical finding, measured after the guard was written: +backgrounding is not the problem — one *way* of backgrounding is.** This +session's tool-level background mode leaves `SIGINT` deliverable (helper +exits 0); `setsid nohup … &` does not (helper exits 1). The construct +that caused this lane was never necessary, which makes the guard cheap: +it forbids only what was already avoidable. + **No override.** A full gate run under ignored `SIGINT` cannot produce valid evidence, so there is no flag to proceed anyway — a switch that lets the gate run in a state where several suites are meaningless would @@ -772,6 +779,15 @@ show: A2 fail by allowing inherited ignore through. Collapsing `error` into `ignored` makes A6 fail. Each mutation is named against the distinct row it must bite. + + **Measured 2026-08-19; every prediction holds:** + + | mutation | helper fg | helper bg | forced error | bites | + |---|---|---|---|---| + | baseline | 0 | 1 | 2 | — | + | remove the probe's `trap` | **2** | 1 | — | **A3** — foreground degrades to `error`; backgrounded classification unchanged | + | treat inner exit 0 as `safe` | 0 | **0** | — | **A1 and A2** — inherited ignore passes through both consumers | + | collapse `error` into `ignored` | — | — | **1** | **A6** — a forced failure reports the ignored wording | - **A5 — the gate is otherwise unchanged**: a normal foreground run reaches and passes every stage it did before, with no stage added, skipped, reordered, or made conditional. @@ -780,11 +796,16 @@ show: and the direct target test** report the helper's **`error`** diagnosis, not the `ignored` one, and neither claims the environment ignores `SIGINT`. -- **A7 — a supported non-Linux unix.** The helper is exercised on a - non-`/proc` unix in the project's supported set, or — if none is - reachable — the record states which platforms the guard is *claimed* - to work on and which were actually tried. No unexercised portability - claim ships unqualified. +- **A7 — portability, recorded as exercised rather than claimed.** + **Exercised: Linux `x86_64` only, this machine**, in all three + outcomes (`safe` 0, `ignored` 1, `error` 2). **No non-Linux unix was + reachable from this session, so none was tried, and A7 stays OPEN + there.** What carries beyond Linux is a contract argument, not a + measurement: the helper uses only `trap`, `kill -INT`, `$$`, `case` + and `echo` — POSIX shell — and reads no `/proc` and calls no + `sigaction`. The behaviour it detects is POSIX, not a Linux + extension. Re-run the three outcomes on BSD or macOS before treating + A7 as closed. ## 8b. Superseded criteria, kept for the record diff --git a/scripts/check-sigint-deliverable b/scripts/check-sigint-deliverable new file mode 100755 index 0000000..fa4bf0c --- /dev/null +++ b/scripts/check-sigint-deliverable @@ -0,0 +1,47 @@ +#!/bin/sh +# Is SIGINT deliverable to this process tree? +# +# WHY THIS EXISTS. A shell running a command in the background without +# job control sets SIGINT (and SIGQUIT) to SIG_IGN in the child; nohup +# adds SIGHUP. SIG_IGN is inherited across fork AND survives exec, so +# the disposition reaches every descendant --- cargo, a test binary, and +# anything either of them spawns. Tests that signal a child then wait +# for it to die will hang until their own deadline and report that as a +# teardown defect. One lane spent nine framing revisions on exactly that +# misreading; see docs/gpu-probe-sigint-framing.md §4c. +# +# INTERFACE (docs/gpu-probe-sigint-framing.md §7c). Callers rely on +# these three statuses and MUST NOT re-derive the classification: +# +# 0 safe SIGINT is deliverable. No diagnostic. +# 1 ignored SIGINT is inherited SIG_IGN. Canonical diagnostic. +# 2 error Undecidable. Distinct diagnostic. +# +# `error` is never folded into `ignored`. "Your environment ignores +# SIGINT" and "the guard could not run" are different problems, and +# conflating them fails callers for the wrong reason. +# +# THE PROBE. A child sends itself SIGINT. Deliverable => the trap runs +# => 23. Ignored => the kill is a no-op => the child falls through to +# `exit 0`. The `|| exit 24` arms matter: without them a FAILED kill +# would also fall through to `exit 0` and be misread as `ignored`, +# which is the one wrong answer this helper exists to avoid. +# +# POSIX shell only --- trap, kill, $$ --- so no /proc and no sigaction: +# the mechanism is not Linux-specific and adds no unsafe code. + +probe_status=0 +sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \ + || probe_status=$? + +case "$probe_status" in + 23) exit 0 ;; + 0) + echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2 + exit 1 + ;; + *) + echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2 + exit 2 + ;; +esac diff --git a/scripts/gate b/scripts/gate index 2ddd9d7..55228e5 100755 --- a/scripts/gate +++ b/scripts/gate @@ -712,6 +712,34 @@ if [ "$MODE" = selftest ]; then fi echo +# R-b (framing SS7c): refuse to start when SIGINT is not deliverable. +# +# BEFORE ANY STAGE, because the answer invalidates whole suites rather +# than one row: a background shell without job control sets SIGINT to +# SIG_IGN, that disposition is inherited across fork and survives exec, +# and every test that signals a child then waits for it hangs to its own +# deadline. Seven red sweeps were read as a product teardown defect +# before this was found (SS4c). +# +# The helper owns the classification and the wording. This consumer does +# NOT re-derive either: it continues only on exit 0, and otherwise +# stops, having let the helper's stderr through untouched. A helper that +# cannot run at all lands in the catch-all below as an `error`, never as +# evidence that SIGINT is ignored. +# +# NO OVERRIDE, deliberately. A run in this state cannot produce valid +# evidence, so a flag to proceed anyway would only manufacture red gates +# that mean nothing --- which is the failure this guard exists to end. +if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then + "$WT/scripts/check-sigint-deliverable" + sigint_status=$? + if [ "$sigint_status" -ne 0 ]; then + echo "gate: REFUSING TO RUN --- see the diagnosis above." >&2 + echo "gate: no stage has run; this is not a test failure." >&2 + exit "$sigint_status" + fi +fi + # The self-test hands the REAL runner loop below a different plan file. # Everything after this point is shared, which is the point: a witness # that exercised its own copy of the runner would witness nothing. diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index a8f226b..69ff850 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -171,119 +171,40 @@ mod crdt { let _ = kill(Pid::from_raw(pid.cast_signed()), signal); } - // ---- D1/D2 diagnostics (gpu-probe-sigint-teardown, framing rev 10) ---- - // - // DIAGNOSTIC ONLY. Nothing here changes what the test asserts; it - // records why `wait_for_exit` below misses its deadline in a full - // `sweep-crdt`, and it is keyed on the PID this test already owns - // rather than by scanning for processes by age or command line --- - // the suite spawns root launchers from six call sites, so scanning - // cannot attribute one to this test. - - /// Direct children of `pid`, from `/proc//task/*/children`. - fn d12_children(pid: u32) -> Vec { - let mut out = Vec::new(); - let Ok(tasks) = fs::read_dir(format!("/proc/{pid}/task")) else { - return out; - }; - for task in tasks.flatten() { - if let Ok(kids) = fs::read_to_string(task.path().join("children")) { - out.extend( - kids.split_ascii_whitespace() - .filter_map(|k| k.parse::().ok()), - ); - } - } - out.sort_unstable(); - out.dedup(); - out - } - - /// One process's signal disposition and identity. + /// R-d (framing §7c): refuse to run this test when `SIGINT` is not + /// deliverable, and say so. /// - /// `SigBlk` is **per thread**, so it is read from every - /// `/proc//task/*/status` rather than the process-wide file: a - /// delivery blocked on the one thread that matters would be - /// invisible in an aggregate reading. `SigPnd`/`ShdPnd` separate - /// "blocked but pending" from "ignored"; `SigIgn` distinguishes an - /// inherited `SIG_IGN` --- which survives both `fork` and `exec` --- - /// from a handler, which does not. - fn d12_facts(pid: u32) -> String { - let Ok(status) = fs::read_to_string(format!("/proc/{pid}/status")) else { - return format!(" pid {pid}: GONE\n"); - }; - let field = |name: &str| -> String { - status.lines().find(|l| l.starts_with(name)).map_or_else( - || "?".to_owned(), - |l| l.split_whitespace().nth(1).unwrap_or("?").to_owned(), - ) - }; - let mut out = format!( - " pid {pid} ppid={} pgid={} sid={} state={} threads={}\n\ - \x20 SigIgn={} SigCgt={} SigPnd={} ShdPnd={}\n", - field("PPid:"), - d12_stat_field_after_comm(pid, 2), - d12_stat_field_after_comm(pid, 3), - field("State:"), - field("Threads:"), - field("SigIgn:"), - field("SigCgt:"), - field("SigPnd:"), - field("ShdPnd:"), - ); - if let Ok(tasks) = fs::read_dir(format!("/proc/{pid}/task")) { - for task in tasks.flatten() { - let tid = task.file_name().to_string_lossy().to_string(); - if let Ok(ts) = fs::read_to_string(task.path().join("status")) { - let get = |n: &str| { - ts.lines() - .find(|l| l.starts_with(n)) - .and_then(|l| l.split_whitespace().nth(1)) - .unwrap_or("?") - .to_owned() - }; - out.push_str(&format!( - " tid {tid}: SigBlk={} SigPnd={} wchan={}\n", - get("SigBlk:"), - get("SigPnd:"), - fs::read_to_string(task.path().join("wchan")) - .unwrap_or_else(|_| "?".to_owned()) - )); - } - } - } - out - } - - /// Snapshot the test parent, the launcher, and the launcher's - /// children (the GPU probe), at one point in time. - fn d12_snapshot(tag: &str, launcher: u32) -> String { - let mut out = format!( - " [D1/D2 {tag}]\n test parent:\n{}", - d12_facts(std::process::id()) - ); - out.push_str(&format!(" launcher:\n{}", d12_facts(launcher))); - for kid in d12_children(launcher) { - out.push_str(&format!(" launcher child:\n{}", d12_facts(kid))); - } - out - } - - /// Field `n` of `/proc//stat`, counted from the first field - /// AFTER `comm`. + /// The test signals a process group and requires the launcher to + /// exit. If `SIGINT` is inherited as `SIG_IGN` --- which a shell + /// running a command in the background without job control sets, + /// and which survives `fork` and `exec` --- the signal is a no-op + /// and the launcher waits out the deadline. Without this the + /// failure reads "child did not exit within 5s", which names a + /// teardown defect that is not there; that misreading cost nine + /// framing revisions (§4c). /// - /// `comm` is parenthesised and may itself contain spaces and - /// parentheses, so the only safe anchor is the **last** `)`. - /// Counting from there: 0 = state, 1 = ppid, 2 = **pgrp**, - /// 3 = **session**. - fn d12_stat_field_after_comm(pid: u32, n: usize) -> String { - fs::read_to_string(format!("/proc/{pid}/stat")) - .ok() - .and_then(|st| { - st.rsplit_once(')') - .and_then(|(_, rest)| rest.split_whitespace().nth(n).map(ToOwned::to_owned)) - }) - .unwrap_or_else(|| "?".to_owned()) + /// The **same checked-in helper the gate uses** owns the + /// classification and the wording, so the two can never disagree + /// about what "ignored" means. This consumer does not re-derive + /// either: it proceeds only on exit 0 and otherwise panics with the + /// helper's own stderr. A helper that cannot be executed is an + /// `error` at this boundary, never evidence that `SIGINT` is + /// ignored. + fn require_sigint_deliverable() { + let helper = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable"); + let out = match Command::new(&helper).output() { + Ok(out) => out, + Err(error) => panic!( + "precondition undecidable: could not execute {}: {error}", + helper.display() + ), + }; + if !out.status.success() { + panic!( + "precondition failed --- this is NOT a teardown defect.\n{}", + String::from_utf8_lossy(&out.stderr).trim_end() + ); + } } fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { @@ -1198,6 +1119,7 @@ mod crdt { #[test] fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() { + require_sigint_deliverable(); let temp = secure_tempdir(); let socket = temp.path().join("signal.sock"); let report = temp.path().join("signal-report"); @@ -1225,32 +1147,9 @@ mod crdt { let daemon_pid = facts["daemon_pid"].parse::().expect("daemon pid"); let (survivor_id, mut survivor) = attach_surviving_frontend(&socket); - // D1/D2: before, immediately after, and at the deadline. - let launcher_pid = launcher.id(); - let before = d12_snapshot("before SIGINT", launcher_pid); - kill(Pid::from_raw(-launcher_pid.cast_signed()), Signal::SIGINT) + kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT) .expect("signal launcher group"); - thread::sleep(Duration::from_millis(50)); - let after = d12_snapshot("50ms after SIGINT", launcher_pid); - - let deadline = Instant::now() + Duration::from_secs(5); - let exited = loop { - if launcher.try_wait().expect("inspect launcher").is_some() { - break true; - } - if Instant::now() >= deadline { - break false; - } - thread::sleep(Duration::from_millis(20)); - }; - if !exited { - eprintln!( - "D1/D2 diagnostics --- launcher {launcher_pid} did not exit within 5s\n\ - {before}{after}{}", - d12_snapshot("at the 5s deadline", launcher_pid) - ); - } - assert!(exited, "child did not exit within 5s"); + let _ = wait_for_exit(&mut launcher, Duration::from_secs(5)); write_message( &mut survivor,