fix(gate): the guard never fired --- two shell bugs, now covered by tests

Four findings, all upheld, and the first was a live bug I shipped.

1. R-b's non-zero handling was unreachable. scripts/gate runs under
   `set -eu`, so the bare helper invocation killed the shell at exit 1
   or 2 and neither `sigint_status=$?` nor the refusal messages ever
   ran; an unexecutable helper would have escaped as raw 126/127 rather
   than boundary error 2. Reproduced before fixing.

   The first repair was ALSO wrong, and worse: `if ! helper; then
   sigint_status=$?; fi` captures the status of the NEGATED condition,
   which is always 0, so the gate printed the ignored diagnosis and
   then ran the entire suite. The working shape is `helper ||
   sigint_status=$?` --- failure handled, so `set -e` does not fire and
   `$?` is the helper's own --- which is the idiom the helper already
   uses internally. Statuses 1 and 2 pass through unchanged; everything
   else, including 126/127, maps to 2 at the boundary and is never
   reported as "SIGINT is ignored".

   The guard also moved to immediately after the worktree resolves,
   before any log directory, ambient root or tmpdir exists, so a
   refused run leaves nothing behind.

2. The behaviour had no durable coverage, which is exactly why 27
   passing gate tests missed both bugs. Four rows added: helper safe,
   helper ignored, helper error (and never ignored), and gate refusal
   before stage 1. Ignored-SIGINT is simulated with `trap "" INT`,
   which is the real mechanism --- SIG_IGN inherited across fork and
   surviving exec --- not a stand-in. Verified to bite: mutating the
   gate back to either shipped bug fails
   gate_refuses_to_start_when_sigint_is_ignored and nothing else.

3. The ledger now records the implementation, both bugs, the four rows
   and their mutation check.

4. A7 is recorded SATISFIED BY DISCLOSURE, which is the fallback
   revision 12 allows when no non-Linux unix is reachable. The earlier
   "stays open" contradicted the approved contract and is withdrawn.
   Tried: Linux x86_64, all three outcomes, all consumers. Not tried:
   every non-Linux unix. Claimed: POSIX shell only, no /proc, no
   sigaction --- labelled a contract argument, not a measurement.

The full default gate passes all eight stages foreground; it caught a
rustfmt violation in the new test code on the first attempt, which is
the guard-and-gate arrangement working as intended.

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 16:19:06 +02:00
parent 86ace38ef5
commit 32064336ee
No known key found for this signature in database
4 changed files with 205 additions and 41 deletions

View File

@ -315,9 +315,28 @@ from #171 and #215.
removed once its evidence is portable. A1–A7 witness guard bite, removed once its evidence is portable. A1–A7 witness guard bite,
direct-test diagnosis, unaffected foreground success, mutation, an direct-test diagnosis, unaffected foreground success, mutation, an
otherwise unchanged gate, a distinct error outcome in **both** otherwise unchanged gate, a distinct error outcome in **both**
consumers, and qualified non-Linux-unix portability. The mechanism is consumers, and qualified non-Linux-unix portability.
**known** and the only implementation so far is the diagnostic - **IMPLEMENTED.** `scripts/check-sigint-deliverable` is the shared
instrument. Revisions 1, 2 and 3 were each rejected on findings, all 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`. upheld; run provenance lives in `docs/probe-sigint-evidence.md`.
- **D0a EXECUTED 2026-08-19 — verdict: difference NOT captured by the - **D0a EXECUTED 2026-08-19 — verdict: difference NOT captured by the
two commits.** 10 runs, counterbalanced, N=5 per endpoint, clean two commits.** 10 runs, counterbalanced, N=5 per endpoint, clean

View File

@ -796,16 +796,21 @@ 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 — portability, recorded as exercised rather than claimed.** - **A7 — SATISFIED BY DISCLOSURE**, which is the fallback this
**Exercised: Linux `x86_64` only, this machine**, in all three criterion allows when no non-Linux unix is reachable. Revision 12
outcomes (`safe` 0, `ignored` 1, `error` 2). **No non-Linux unix was wrote A7 as "exercised there, **or** state what is claimed versus
reachable from this session, so none was tried, and A7 stays OPEN what was tried"; an earlier draft of this line said A7 "stays open",
there.** What carries beyond Linux is a contract argument, not a which **contradicted the approved contract** and is withdrawn.
measurement: the helper uses only `trap`, `kill -INT`, `$$`, `case` - **Tried:** Linux `x86_64`, this machine, all three outcomes
and `echo` — POSIX shell — and reads no `/proc` and calls no (`safe` 0, `ignored` 1, `error` 2), for the helper, the gate and
`sigaction`. The behaviour it detects is POSIX, not a Linux the direct test.
extension. Re-run the three outcomes on BSD or macOS before treating - **Not tried:** every non-Linux unix. None was reachable.
A7 as closed. - **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 ## 8b. Superseded criteria, kept for the record

View File

@ -506,6 +506,60 @@ esac
WT=$(canon "$(worktree_root)") WT=$(canon "$(worktree_root)")
cd "$WT" 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") TARGET=$(ensure_target_dir "$WT")
STAMP=$(date -u +%Y%m%dT%H%M%SZ) STAMP=$(date -u +%Y%m%dT%H%M%SZ)
# $$ as well as the timestamp: two invocations in the same worktree # $$ as well as the timestamp: two invocations in the same worktree
@ -712,34 +766,6 @@ 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

@ -35,6 +35,27 @@ fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) 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 { fn gate() -> PathBuf {
repo_root().join("scripts/gate") 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` // §3, nothing else in the repository would notice. `--print-plan`
// exists to make that checkable without executing anything. // 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] #[test]
fn the_plan_sweeps_the_workspace_and_never_only_the_tests() { fn the_plan_sweeps_the_workspace_and_never_only_the_tests() {
let root = tempfile::Builder::new() let root = tempfile::Builder::new()