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

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

View File

@ -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/<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.
/// 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/<pid>/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/<pid>/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::<u32>().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,