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:
parent
7752bcbf7a
commit
86ace38ef5
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
28
scripts/gate
28
scripts/gate
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue