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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-19 15:46:57 +02:00
parent 7752bcbf7a
commit 86ace38ef5
No known key found for this signature in database
4 changed files with 136 additions and 141 deletions

View File

@ -738,6 +738,13 @@ never disagree about what "ignored" means:
- **The target test invokes it** and reports the same precondition - **The target test invokes it** and reports the same precondition
failure if run directly, instead of "child did not exit within 5s". 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 **No override.** A full gate run under ignored `SIGINT` cannot produce
valid evidence, so there is no flag to proceed anyway — a switch that 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 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 A2 fail by allowing inherited ignore through. Collapsing `error` into
`ignored` makes A6 fail. Each mutation is named against the distinct `ignored` makes A6 fail. Each mutation is named against the distinct
row it must bite. 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 - **A5 — the gate is otherwise unchanged**: a normal foreground run
reaches and passes every stage it did before, with no stage added, reaches and passes every stage it did before, with no stage added,
skipped, reordered, or made conditional. skipped, reordered, or made conditional.
@ -780,11 +796,16 @@ show:
and the direct target test** report the helper's **`error`** and the direct target test** report the helper's **`error`**
diagnosis, not the `ignored` one, and neither claims the environment diagnosis, not the `ignored` one, and neither claims the environment
ignores `SIGINT`. ignores `SIGINT`.
- **A7 — a supported non-Linux unix.** The helper is exercised on a - **A7 — portability, recorded as exercised rather than claimed.**
non-`/proc` unix in the project's supported set, or — if none is **Exercised: Linux `x86_64` only, this machine**, in all three
reachable — the record states which platforms the guard is *claimed* outcomes (`safe` 0, `ignored` 1, `error` 2). **No non-Linux unix was
to work on and which were actually tried. No unexercised portability reachable from this session, so none was tried, and A7 stays OPEN
claim ships unqualified. 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 ## 8b. Superseded criteria, kept for the record

View File

@ -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

View File

@ -712,6 +712,34 @@ if [ "$MODE" = selftest ]; then
fi fi
echo 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. # 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 # Everything after this point is shared, which is the point: a witness
# that exercised its own copy of the runner would witness nothing. # that exercised its own copy of the runner would witness nothing.

View File

@ -171,119 +171,40 @@ mod crdt {
let _ = kill(Pid::from_raw(pid.cast_signed()), signal); let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
} }
// ---- D1/D2 diagnostics (gpu-probe-sigint-teardown, framing rev 10) ---- /// R-d (framing §7c): refuse to run this test when `SIGINT` is not
// /// deliverable, and say so.
// 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/<pid>/task/*/children`.
fn d12_children(pid: u32) -> Vec<u32> {
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::<u32>().ok()),
);
}
}
out.sort_unstable();
out.dedup();
out
}
/// One process's signal disposition and identity.
/// ///
/// `SigBlk` is **per thread**, so it is read from every /// The test signals a process group and requires the launcher to
/// `/proc/<pid>/task/*/status` rather than the process-wide file: a /// exit. If `SIGINT` is inherited as `SIG_IGN` --- which a shell
/// delivery blocked on the one thread that matters would be /// running a command in the background without job control sets,
/// invisible in an aggregate reading. `SigPnd`/`ShdPnd` separate /// and which survives `fork` and `exec` --- the signal is a no-op
/// "blocked but pending" from "ignored"; `SigIgn` distinguishes an /// and the launcher waits out the deadline. Without this the
/// inherited `SIG_IGN` --- which survives both `fork` and `exec` --- /// failure reads "child did not exit within 5s", which names a
/// from a handler, which does not. /// teardown defect that is not there; that misreading cost nine
fn d12_facts(pid: u32) -> String { /// framing revisions (§4c).
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/<pid>/stat`, counted from the first field
/// AFTER `comm`.
/// ///
/// `comm` is parenthesised and may itself contain spaces and /// The **same checked-in helper the gate uses** owns the
/// parentheses, so the only safe anchor is the **last** `)`. /// classification and the wording, so the two can never disagree
/// Counting from there: 0 = state, 1 = ppid, 2 = **pgrp**, /// about what "ignored" means. This consumer does not re-derive
/// 3 = **session**. /// either: it proceeds only on exit 0 and otherwise panics with the
fn d12_stat_field_after_comm(pid: u32, n: usize) -> String { /// helper's own stderr. A helper that cannot be executed is an
fs::read_to_string(format!("/proc/{pid}/stat")) /// `error` at this boundary, never evidence that `SIGINT` is
.ok() /// ignored.
.and_then(|st| { fn require_sigint_deliverable() {
st.rsplit_once(')') let helper = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable");
.and_then(|(_, rest)| rest.split_whitespace().nth(n).map(ToOwned::to_owned)) let out = match Command::new(&helper).output() {
}) Ok(out) => out,
.unwrap_or_else(|| "?".to_owned()) 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 { fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
@ -1198,6 +1119,7 @@ mod crdt {
#[test] #[test]
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() { fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
require_sigint_deliverable();
let temp = secure_tempdir(); let temp = secure_tempdir();
let socket = temp.path().join("signal.sock"); let socket = temp.path().join("signal.sock");
let report = temp.path().join("signal-report"); let report = temp.path().join("signal-report");
@ -1225,32 +1147,9 @@ mod crdt {
let daemon_pid = facts["daemon_pid"].parse::<u32>().expect("daemon pid"); let daemon_pid = facts["daemon_pid"].parse::<u32>().expect("daemon pid");
let (survivor_id, mut survivor) = attach_surviving_frontend(&socket); let (survivor_id, mut survivor) = attach_surviving_frontend(&socket);
// D1/D2: before, immediately after, and at the deadline. kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT)
let launcher_pid = launcher.id();
let before = d12_snapshot("before SIGINT", launcher_pid);
kill(Pid::from_raw(-launcher_pid.cast_signed()), Signal::SIGINT)
.expect("signal launcher group"); .expect("signal launcher group");
thread::sleep(Duration::from_millis(50)); let _ = wait_for_exit(&mut launcher, Duration::from_secs(5));
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");
write_message( write_message(
&mut survivor, &mut survivor,