From 167d8309325f85d0e982af793a6ed2066120398c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 19 Aug 2026 16:43:37 +0200 Subject: [PATCH] test(gate): witness A6 in both consumers; bound the negative path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all upheld. 1. A6 was witnessed only for the helper. Both consumers now have real -path rows. Gate side, driven through a stub worktree --- a temp git repo holding a copy of scripts/gate and a controlled helper --- so the gate's own code path runs against each verdict without touching the checked-in helper: a stub exiting 2 refuses with the ERROR wording and never "SIGINT is ignored"; a NON-EXECUTABLE stub maps 126 to boundary error 2 with its own wording. That second case is what the original guard got wrong twice. R-d side: the precondition is split into sigint_diagnosis() -> Result, so the message is testable rather than reachable only through a panic in a test that cannot run under the condition it describes. The new row asserts safe proceeds, ignored says so and says "NOT a teardown defect", error says "could not determine" and never "ignored", and an unrunnable helper is undecidable at the boundary. 2. The refusal row violated this suite's no-recursion constraint: it invoked the ordinary gate, so a regression of the exact `if !` bug would have launched eight real gate stages inside the gate suite. It now uses --self-test, which drives the same runner over a hardcoded synthetic plan, so the negative path stays bounded whatever the guard does. under_ignored_sigint() also takes the program and arguments POSITIONALLY --- `exec "$@"` --- instead of interpolating them into script text, which broke for any path containing a space or shell metacharacter, and every path here comes from a tempdir or CARGO_MANIFEST_DIR. 3. The portable checkpoint is recorded: implementation at 3206433, pushed, signed, clean, full default gate green 8/8 foreground. The framing header no longer says implementation "may proceed" --- it reports IMPLEMENTED. And docs/agent-handoff.md §3 gains the durable rule: never start the gate or cargo test from a shell that ignores SIGINT, `setsid nohup ... &` is forbidden, SIG_IGN is inherited across fork and survives exec, the gate refuses with no override, and scripts/check-sigint-deliverable answers the question directly. 35 gate-acceptance rows, 16 gpu_invocation_acceptance rows, full gate green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 9 ++ docs/agent-handoff.md | 28 ++++++ docs/gpu-probe-sigint-framing.md | 6 +- tests/gate_script_acceptance.rs | 136 ++++++++++++++++++++++++++--- tests/gpu_invocation_acceptance.rs | 96 +++++++++++++++++--- 5 files changed, 248 insertions(+), 27 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index addce5b..af2c771 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -279,6 +279,15 @@ from #171 and #215. **`72da24a`**, worktree `/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with `git fetch githubsucks && git checkout gpu-probe-sigint-teardown`. +- **CHECKPOINT: implementation landed at `3206433`; this entry's own + commit adds the A6 consumer rows on top.** The branch head is the + authority — a literal SHA naming a branch's own tip goes stale the + moment the next commit lands, which this ledger has already recorded + once. State at `3206433`: pushed, signed `G`, worktree clean, + `git diff --check` clean, **full default gate green (8/8) in the + foreground**, 31 gate-acceptance rows passing. After this commit: + **35 gate-acceptance rows and 16 `gpu_invocation_acceptance` rows**, + with the full gate re-run. - **No PR. Framing revision 12 at `docs/gpu-probe-sigint-framing.md`, APPROVED 2026-08-19 at `1fc0df6`** — revision 10 was approved at `4fba9f6` and revision 9 at `15c25ec`; neither approval covered the diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b852072..553f130 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -2547,6 +2547,34 @@ its own step, never `&&`-chained. **Run it with `scripts/gate`. Do not retype it.** +**NEVER start the gate — or `cargo test` — from a shell that ignores +`SIGINT`.** A shell backgrounding a job without job control sets +`SIGINT` (and `SIGQUIT`) to `SIG_IGN`; `nohup` adds `SIGHUP`. `SIG_IGN` +is **inherited across `fork` and survives `exec`**, so it reaches +`cargo`, the test binary, and everything they spawn. Any test that +signals a child and waits for it then hangs to its own deadline and +reports a *product* defect that is not there. + +Concretely: `setsid nohup ./scripts/gate … &` is **forbidden**. This +cost one lane seven red full sweeps and nine framing revisions chasing +a GPU teardown bug that never existed +(`docs/gpu-probe-sigint-framing.md` §4c). Long runs do not need it — +measured, an ordinary tool-level background launch leaves `SIGINT` +deliverable. + +`scripts/gate` now refuses to start in that state, before any stage, +and **there is no override**: a run under ignored `SIGINT` cannot +produce valid evidence. If you see + +``` +pmacs: SIGINT is ignored; run this command with SIGINT deliverable +gate: REFUSING TO RUN --- see the diagnosis above. +``` + +the fix is to re-run it in the foreground, not to work around the +guard. `scripts/check-sigint-deliverable` answers the question on its +own: exit **0** deliverable, **1** ignored, **2** undecidable. + ``` scripts/gate [--acceptance SUITE]... [--protocol] ``` diff --git a/docs/gpu-probe-sigint-framing.md b/docs/gpu-probe-sigint-framing.md index 04a1109..5d8a570 100644 --- a/docs/gpu-probe-sigint-framing.md +++ b/docs/gpu-probe-sigint-framing.md @@ -1,8 +1,8 @@ # GPU launcher / probe SIGINT teardown — framing -Revision 12. Status: **APPROVED 2026-08-19 at `1fc0df6`. -MECHANISM FOUND (§4c), REMEDY SELECTED (§7c); implementation may -proceed under §8's A1–A7 contract.** +Revision 12, approved 2026-08-19 at `7752bcb`. +Status: **IMPLEMENTED — R-b + R-d landed and witnessed (§8). Mechanism +in §4c; no product change.** Revision 10 was approved 2026-08-19 at `4fba9f6`, authorising diagnostic-only D1/D2. They ran, and found the mechanism on the first diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index e2827a8..3190fb3 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -48,12 +48,57 @@ fn sigint_helper() -> PathBuf { /// 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") +fn under_ignored_sigint( + program: &Path, + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], +) -> std::process::Output { + // `exec "$@"` with the program and arguments passed POSITIONALLY. + // Interpolating them into the script text would break on any path + // containing a space or a shell metacharacter, and every path here + // comes from a `tempdir` or `CARGO_MANIFEST_DIR` — neither of which + // this test controls. + let mut command = Command::new("sh"); + command .arg("-c") - .arg(format!("trap \"\" INT; {cmd}")) - .output() - .expect("spawn shell with SIGINT ignored") + .arg("trap \"\" INT; exec \"$@\"") + .arg("sh") + .arg(program) + .args(args) + .current_dir(cwd); + for (key, value) in env { + command.env(key, value); + } + command.output().expect("spawn shell with SIGINT ignored") +} + +/// A minimal git worktree holding a copy of `scripts/gate` and a +/// **stub** `check-sigint-deliverable`, so the gate's handling of each +/// helper status can be driven on its real path without touching the +/// checked-in helper. +fn gate_with_stub_helper(stub_body: &str, executable: bool) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let scripts = dir.path().join("scripts"); + std::fs::create_dir_all(&scripts).expect("scripts dir"); + std::fs::copy(gate(), scripts.join("gate")).expect("copy gate"); + let helper = scripts.join("check-sigint-deliverable"); + std::fs::write(&helper, stub_body).expect("write stub helper"); + let mode = if executable { 0o755 } else { 0o644 }; + std::fs::set_permissions(&helper, std::os::unix::fs::PermissionsExt::from_mode(mode)) + .expect("chmod stub helper"); + std::fs::set_permissions( + scripts.join("gate"), + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .expect("chmod gate copy"); + let ok = Command::new("git") + .args(["init", "-q"]) + .current_dir(dir.path()) + .status() + .expect("git init"); + assert!(ok.success(), "the stub worktree must be a git worktree"); + dir } fn gate() -> PathBuf { @@ -114,6 +159,68 @@ 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. +/// A6, gate consumer: a helper verdict of `error` (2) refuses the run +/// with the ERROR wording, and never claims `SIGINT` is ignored. +/// +/// Driven through a stub worktree so the gate's real code path runs +/// against a controlled helper status; the helper's own classification +/// is covered by its own rows above. +#[test] +fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper( + "#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n", + true, + ); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .output() + .expect("run the stub-worktree gate"); + assert_eq!( + out.status.code(), + Some(2), + "an error verdict exits 2, not 1" + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("could not determine"), "error wording: {err}"); + assert!( + !err.contains("SIGINT is ignored"), + "an undecidable probe must never be reported as ignored: {err}" + ); + assert!(err.contains("no stage has run"), "and no stage ran: {err}"); +} + +/// A6, gate boundary: a helper that cannot be EXECUTED is an `error` at +/// the call boundary — mapped to 2 — never evidence that `SIGINT` is +/// ignored. +/// +/// This is the case the original guard got wrong twice: under `set -e` +/// a bare invocation died before any mapping, and 126/127 would have +/// escaped raw. +#[test] +fn gate_maps_an_unexecutable_helper_to_error_not_ignored() { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper("#!/bin/sh\nexit 0\n", false); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .output() + .expect("run the stub-worktree gate"); + assert_eq!(out.status.code(), Some(2), "boundary failures map to 2"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("could not run the SIGINT guard"), + "the boundary has its own wording: {err}" + ); + assert!( + !err.contains("SIGINT is ignored"), + "an unrunnable guard is not evidence about the signal: {err}" + ); +} + /// §7c: the helper answers `safe` when `SIGINT` is deliverable. #[test] fn sigint_helper_reports_safe_when_the_signal_is_deliverable() { @@ -132,7 +239,7 @@ fn sigint_helper_reports_safe_when_the_signal_is_deliverable() { /// `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())); + let out = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]); assert_eq!(out.status.code(), Some(1), "ignored is exit 1"); let err = String::from_utf8_lossy(&out.stderr); assert!( @@ -179,12 +286,17 @@ fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() { #[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() - )); + // `--self-test`, NOT the ordinary gate. If the guard ever regresses, + // this row must not launch eight real gate stages inside the gate + // suite — the recursion constraint this file opens with. Self-test + // drives the same runner over a hardcoded synthetic plan, so the + // negative path stays bounded whatever the guard does. + let out = under_ignored_sigint( + &gate(), + &["--self-test"], + &repo_root(), + &[("PMACS_GATE_TARGET_ROOT", &root.path().display().to_string())], + ); assert_eq!( out.status.code(), Some(1), diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 69ff850..5fb159d 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -190,21 +190,93 @@ mod crdt { /// 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() { + /// The diagnosis for one helper invocation: `Ok` to proceed, `Err` + /// with the message a caller should fail on. + /// + /// Split from the assertion so the message itself is testable. + /// A6 requires this consumer to distinguish `error` from `ignored`, + /// and a diagnosis reachable only through a panic in a test that + /// cannot run under the condition it describes is not a witness. + fn sigint_diagnosis(helper: &Path) -> Result<(), String> { + let out = match Command::new(helper).output() { Ok(out) => out, - Err(error) => panic!( - "precondition undecidable: could not execute {}: {error}", - helper.display() - ), + // Failure to execute the helper is an `error` AT THIS + // BOUNDARY, never evidence that SIGINT is ignored. + Err(error) => { + return Err(format!( + "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() - ); + if out.status.success() { + return Ok(()); } + Err(format!( + "precondition failed --- this is NOT a teardown defect.\n{}", + String::from_utf8_lossy(&out.stderr).trim_end() + )) + } + + fn sigint_helper_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable") + } + + fn require_sigint_deliverable() { + if let Err(diagnosis) = sigint_diagnosis(&sigint_helper_path()) { + panic!("{diagnosis}"); + } + } + + /// A6, R-d consumer: the direct test distinguishes `error` from + /// `ignored`, and neither message claims a teardown defect. + #[test] + fn rd_precondition_distinguishes_ignored_from_error() { + // `safe` proceeds silently. + assert!( + sigint_diagnosis(&sigint_helper_path()).is_ok(), + "the foreground case must proceed" + ); + + let dir = tempfile::tempdir().expect("tempdir"); + let stub = |name: &str, body: &str, mode: u32| { + let path = dir.path().join(name); + fs::write(&path, body).expect("write stub"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("chmod stub"); + path + }; + + let ignored = stub( + "ignored", + "#!/bin/sh\necho 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\nexit 1\n", + 0o755, + ); + let message = sigint_diagnosis(&ignored).expect_err("exit 1 must be refused"); + assert!(message.contains("SIGINT is ignored"), "{message}"); + assert!( + message.contains("NOT a teardown defect"), + "the whole point is not to read as a teardown defect: {message}" + ); + + let erroring = stub( + "erroring", + "#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n", + 0o755, + ); + let message = sigint_diagnosis(&erroring).expect_err("exit 2 must be refused"); + assert!(message.contains("could not determine"), "{message}"); + assert!( + !message.contains("SIGINT is ignored"), + "an undecidable probe is not evidence that SIGINT is ignored: {message}" + ); + + // Not executable at all: an `error` at the boundary. + let unrunnable = stub("unrunnable", "#!/bin/sh\nexit 0\n", 0o644); + let message = sigint_diagnosis(&unrunnable).expect_err("an unrunnable helper must refuse"); + assert!( + message.contains("undecidable") && !message.contains("SIGINT is ignored"), + "boundary failure is undecidable, never ignored: {message}" + ); } fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {