diff --git a/docs/active-work.md b/docs/active-work.md index f4393ac..addce5b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -315,9 +315,28 @@ from #171 and #215. removed once its evidence is portable. A1–A7 witness guard bite, direct-test diagnosis, unaffected foreground success, mutation, an otherwise unchanged gate, a distinct error outcome in **both** - consumers, and qualified non-Linux-unix portability. The mechanism is - **known** and the only implementation so far is the diagnostic - instrument. Revisions 1, 2 and 3 were each rejected on findings, all + consumers, and qualified non-Linux-unix portability. +- **IMPLEMENTED.** `scripts/check-sigint-deliverable` is the shared + helper (0 safe / 1 ignored / 2 error); `scripts/gate` refuses before + any stage; the target test reports the precondition instead of the 5s + deadline; the Linux-only `/proc` instrument is removed. +- **Two bugs shipped in the first guard, both caught in review.** A bare + invocation under `set -eu` killed the shell at the helper's non-zero + exit, so the refusal never printed. Replacing it with + `if ! helper; then status=$?` captured the status of the **negated + condition** — always 0 — so the gate printed the diagnosis and then + ran the whole suite anyway. The working shape is + `helper || status=$?`, the idiom the helper uses internally. The + guard also moved to immediately after the worktree resolves, so a + refused run leaves no log dir, ambient root or tmpdir behind. +- **Four durable rows in `gate_script_acceptance`** (31, was 27): + helper safe / ignored / error, and gate refusal before stage 1. + **Verified to bite** — mutating the gate back to either shipped bug + fails `gate_refuses_to_start_when_sigint_is_ignored` and nothing + else. Their absence is why 27 passing tests missed both. +- **A7 satisfied by disclosure**: Linux `x86_64` only, all three + outcomes, no non-Linux unix reachable; the POSIX argument is labelled + a contract claim rather than a measurement. Revisions 1, 2 and 3 were each rejected on findings, all upheld; run provenance lives in `docs/probe-sigint-evidence.md`. - **D0a EXECUTED 2026-08-19 — verdict: difference NOT captured by the two commits.** 10 runs, counterbalanced, N=5 per endpoint, clean diff --git a/docs/gpu-probe-sigint-framing.md b/docs/gpu-probe-sigint-framing.md index 8de611b..04a1109 100644 --- a/docs/gpu-probe-sigint-framing.md +++ b/docs/gpu-probe-sigint-framing.md @@ -796,16 +796,21 @@ show: and the direct target test** report the helper's **`error`** diagnosis, not the `ignored` one, and neither claims the environment ignores `SIGINT`. -- **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. +- **A7 — SATISFIED BY DISCLOSURE**, which is the fallback this + criterion allows when no non-Linux unix is reachable. Revision 12 + wrote A7 as "exercised there, **or** state what is claimed versus + what was tried"; an earlier draft of this line said A7 "stays open", + which **contradicted the approved contract** and is withdrawn. + - **Tried:** Linux `x86_64`, this machine, all three outcomes + (`safe` 0, `ignored` 1, `error` 2), for the helper, the gate and + the direct test. + - **Not tried:** every non-Linux unix. None was reachable. + - **Claimed:** the mechanism is POSIX, not Linux-specific — the + helper uses only `trap`, `kill -INT`, `$$`, `case` and `echo`, and + reads no `/proc` and calls no `sigaction`; the disposition + behaviour it detects is POSIX inheritance across `fork` and `exec`. + That is a contract argument, disclosed as such. Anyone porting to + BSD or macOS should re-run the three outcomes rather than trust it. ## 8b. Superseded criteria, kept for the record diff --git a/scripts/gate b/scripts/gate index 55228e5..ec045ff 100755 --- a/scripts/gate +++ b/scripts/gate @@ -506,6 +506,60 @@ esac WT=$(canon "$(worktree_root)") cd "$WT" +# R-b (framing SS7c): refuse to start when SIGINT is not deliverable. +# +# HERE, immediately after the worktree resolves and BEFORE any log +# directory, ambient root or temporary dir exists: a refused run should +# leave nothing behind to clean up or mistake for evidence. +# +# WHY AT ALL. A shell running a command in the background without job +# control sets SIGINT to SIG_IGN; that survives fork AND exec, so every +# test which signals a child and waits for it hangs to its own +# deadline. Seven red sweeps were read as a product teardown defect +# before this was found (framing SS4c). +# +# The helper owns the classification and the wording; this consumer +# re-derives neither. It continues only on 0, and otherwise stops after +# letting the helper's stderr through untouched. +# +# `|| sigint_status=$?` IS LOAD-BEARING, and two wrong shapes were tried +# before this one: +# +# * a bare invocation dies under `set -e` at the helper's non-zero +# exit, so the refusal below never prints; +# * `if ! helper; then sigint_status=$?; fi` captures the status of +# the NEGATED condition --- always 0 --- so the guard printed the +# diagnosis and then ran the whole gate anyway. +# +# In a `cmd || assignment` list the failure is handled, so `set -e` does +# not fire and `$?` is the helper's own status. This is the same shape +# the helper uses internally. +# +# 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. +if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then + sigint_status=0 + "$WT/scripts/check-sigint-deliverable" || sigint_status=$? + # 1 and 2 are the helper's own verdicts and pass through unchanged. + # Anything else --- 126/127 for an unexecutable or missing helper, a + # signal death, any future status --- is an `error` AT THIS + # BOUNDARY, never evidence that SIGINT is ignored. + case "$sigint_status" in + 0) ;; + 1 | 2) + 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" + ;; + *) + echo "gate: could not run the SIGINT guard (status $sigint_status)" >&2 + echo "gate: REFUSING TO RUN --- no stage has run." >&2 + exit 2 + ;; + esac +fi + TARGET=$(ensure_target_dir "$WT") STAMP=$(date -u +%Y%m%dT%H%M%SZ) # $$ as well as the timestamp: two invocations in the same worktree @@ -712,34 +766,6 @@ 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/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index 15b36b5..e2827a8 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -35,6 +35,27 @@ fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +/// The SIGINT-deliverability helper the gate and the panel suite share +/// (`docs/gpu-probe-sigint-framing.md` §7c). +fn sigint_helper() -> PathBuf { + repo_root().join("scripts/check-sigint-deliverable") +} + +/// Run `cmd` with `SIGINT` set to `SIG_IGN`, the way a shell that +/// backgrounds a job without job control does. +/// +/// `trap "" INT` sets the ignore in the wrapper shell, and `SIG_IGN` is +/// inherited across `fork` **and survives `exec`** — which is the whole +/// mechanism under test, so simulating it this way exercises the real +/// thing rather than a stand-in. +fn under_ignored_sigint(cmd: &str) -> std::process::Output { + Command::new("sh") + .arg("-c") + .arg(format!("trap \"\" INT; {cmd}")) + .output() + .expect("spawn shell with SIGINT ignored") +} + fn gate() -> PathBuf { repo_root().join("scripts/gate") } @@ -93,6 +114,99 @@ fn run(root: &Path, args: &[&str]) -> (String, String, bool) { // §3, nothing else in the repository would notice. `--print-plan` // exists to make that checkable without executing anything. +/// §7c: the helper answers `safe` when `SIGINT` is deliverable. +#[test] +fn sigint_helper_reports_safe_when_the_signal_is_deliverable() { + let out = Command::new(sigint_helper()) + .output() + .expect("run the sigint helper"); + assert_eq!(out.status.code(), Some(0), "safe is exit 0"); + assert!( + out.stderr.is_empty(), + "the safe path is silent, so a clean run says nothing: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// §7c: the helper answers `ignored` — exit 1, canonical wording — when +/// `SIGINT` is inherited as `SIG_IGN`. +#[test] +fn sigint_helper_reports_ignored_when_the_signal_is_inherited_ignored() { + let out = under_ignored_sigint(&format!("{}", sigint_helper().display())); + assert_eq!(out.status.code(), Some(1), "ignored is exit 1"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("SIGINT is ignored"), + "the canonical ignored diagnosis is the helper's to own: {err}" + ); +} + +/// §7c: the helper answers `error` — exit 2, a DISTINCT diagnosis — when +/// the probe cannot decide. +/// +/// The probe shells out, so an empty `PATH` makes its inner `sh` +/// unfindable. This is the case a naive `exit 0` would misreport as +/// `ignored`, failing the caller for the wrong reason. +#[test] +fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() { + let out = Command::new(sigint_helper()) + .env("PATH", "") + .output() + .expect("run the sigint helper with no PATH"); + assert_eq!(out.status.code(), Some(2), "error is exit 2, never 1"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("could not determine"), + "error has its own wording: {err}" + ); + assert!( + !err.contains("SIGINT is ignored"), + "error must NOT be reported as ignored --- they are different \ + problems, and conflating them is the defect the helper exists \ + to avoid: {err}" + ); +} + +/// R-b: the gate refuses under ignored `SIGINT`, **before any stage**. +/// +/// This is the row whose absence let a real bug ship: the first +/// implementation ran the helper as a bare command under `set -e`, so +/// the shell died at the non-zero exit and the refusal never printed; +/// the second captured `$?` inside `if !`, which is the status of the +/// negated condition — always zero — so the gate printed the diagnosis +/// and then ran the whole suite anyway. Both passed every other test in +/// this file. +#[test] +fn gate_refuses_to_start_when_sigint_is_ignored() { + let root = tempfile::tempdir().expect("tempdir"); + let out = under_ignored_sigint(&format!( + "cd {} && PMACS_GATE_TARGET_ROOT={} {}", + repo_root().display(), + root.path().display(), + gate().display() + )); + assert_eq!( + out.status.code(), + Some(1), + "the helper's verdict passes through" + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("SIGINT is ignored"), + "the gate surfaces the helper's stderr unchanged rather than \ + inventing its own wording: {err}" + ); + assert!( + err.contains("no stage has run"), + "and says the run is not a test failure: {err}" + ); + let combined = format!("{}{err}", String::from_utf8_lossy(&out.stdout)); + assert!( + !combined.contains("[01]"), + "NO stage may run --- the guard sits before stage 1: {combined}" + ); +} + #[test] fn the_plan_sweeps_the_workspace_and_never_only_the_tests() { let root = tempfile::Builder::new()