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:
parent
86ace38ef5
commit
32064336ee
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
82
scripts/gate
82
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue