test(gate): witness A6 in both consumers; bound the negative path
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
32064336ee
commit
167d830932
|
|
@ -279,6 +279,15 @@ from #171 and #215.
|
||||||
**`72da24a`**, worktree
|
**`72da24a`**, worktree
|
||||||
`/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with
|
`/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with
|
||||||
`git fetch githubsucks && git checkout gpu-probe-sigint-teardown`.
|
`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`,
|
- **No PR. Framing revision 12 at `docs/gpu-probe-sigint-framing.md`,
|
||||||
APPROVED 2026-08-19 at `1fc0df6`** — revision 10 was approved at
|
APPROVED 2026-08-19 at `1fc0df6`** — revision 10 was approved at
|
||||||
`4fba9f6` and revision 9 at `15c25ec`; neither approval covered the
|
`4fba9f6` and revision 9 at `15c25ec`; neither approval covered the
|
||||||
|
|
|
||||||
|
|
@ -2547,6 +2547,34 @@ its own step, never `&&`-chained.
|
||||||
|
|
||||||
**Run it with `scripts/gate`. Do not retype it.**
|
**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]
|
scripts/gate [--acceptance SUITE]... [--protocol]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# GPU launcher / probe SIGINT teardown — framing
|
# GPU launcher / probe SIGINT teardown — framing
|
||||||
|
|
||||||
Revision 12. Status: **APPROVED 2026-08-19 at `1fc0df6`.
|
Revision 12, approved 2026-08-19 at `7752bcb`.
|
||||||
MECHANISM FOUND (§4c), REMEDY SELECTED (§7c); implementation may
|
Status: **IMPLEMENTED — R-b + R-d landed and witnessed (§8). Mechanism
|
||||||
proceed under §8's A1–A7 contract.**
|
in §4c; no product change.**
|
||||||
|
|
||||||
Revision 10 was approved 2026-08-19 at `4fba9f6`, authorising
|
Revision 10 was approved 2026-08-19 at `4fba9f6`, authorising
|
||||||
diagnostic-only D1/D2. They ran, and found the mechanism on the first
|
diagnostic-only D1/D2. They ran, and found the mechanism on the first
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,57 @@ fn sigint_helper() -> PathBuf {
|
||||||
/// inherited across `fork` **and survives `exec`** — which is the whole
|
/// inherited across `fork` **and survives `exec`** — which is the whole
|
||||||
/// mechanism under test, so simulating it this way exercises the real
|
/// mechanism under test, so simulating it this way exercises the real
|
||||||
/// thing rather than a stand-in.
|
/// thing rather than a stand-in.
|
||||||
fn under_ignored_sigint(cmd: &str) -> std::process::Output {
|
fn under_ignored_sigint(
|
||||||
Command::new("sh")
|
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("-c")
|
||||||
.arg(format!("trap \"\" INT; {cmd}"))
|
.arg("trap \"\" INT; exec \"$@\"")
|
||||||
.output()
|
.arg("sh")
|
||||||
.expect("spawn shell with SIGINT ignored")
|
.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 {
|
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`
|
// §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.
|
||||||
|
|
||||||
|
/// 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.
|
/// §7c: the helper answers `safe` when `SIGINT` is deliverable.
|
||||||
#[test]
|
#[test]
|
||||||
fn sigint_helper_reports_safe_when_the_signal_is_deliverable() {
|
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`.
|
/// `SIGINT` is inherited as `SIG_IGN`.
|
||||||
#[test]
|
#[test]
|
||||||
fn sigint_helper_reports_ignored_when_the_signal_is_inherited_ignored() {
|
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");
|
assert_eq!(out.status.code(), Some(1), "ignored is exit 1");
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -179,12 +286,17 @@ fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() {
|
||||||
#[test]
|
#[test]
|
||||||
fn gate_refuses_to_start_when_sigint_is_ignored() {
|
fn gate_refuses_to_start_when_sigint_is_ignored() {
|
||||||
let root = tempfile::tempdir().expect("tempdir");
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
let out = under_ignored_sigint(&format!(
|
// `--self-test`, NOT the ordinary gate. If the guard ever regresses,
|
||||||
"cd {} && PMACS_GATE_TARGET_ROOT={} {}",
|
// this row must not launch eight real gate stages inside the gate
|
||||||
repo_root().display(),
|
// suite — the recursion constraint this file opens with. Self-test
|
||||||
root.path().display(),
|
// drives the same runner over a hardcoded synthetic plan, so the
|
||||||
gate().display()
|
// 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!(
|
assert_eq!(
|
||||||
out.status.code(),
|
out.status.code(),
|
||||||
Some(1),
|
Some(1),
|
||||||
|
|
|
||||||
|
|
@ -190,21 +190,93 @@ mod crdt {
|
||||||
/// helper's own stderr. A helper that cannot be executed is an
|
/// helper's own stderr. A helper that cannot be executed is an
|
||||||
/// `error` at this boundary, never evidence that `SIGINT` is
|
/// `error` at this boundary, never evidence that `SIGINT` is
|
||||||
/// ignored.
|
/// ignored.
|
||||||
fn require_sigint_deliverable() {
|
/// The diagnosis for one helper invocation: `Ok` to proceed, `Err`
|
||||||
let helper = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable");
|
/// with the message a caller should fail on.
|
||||||
let out = match Command::new(&helper).output() {
|
///
|
||||||
|
/// 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,
|
Ok(out) => out,
|
||||||
Err(error) => panic!(
|
// Failure to execute the helper is an `error` AT THIS
|
||||||
"precondition undecidable: could not execute {}: {error}",
|
// BOUNDARY, never evidence that SIGINT is ignored.
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"precondition undecidable --- could not execute {}: {error}",
|
||||||
helper.display()
|
helper.display()
|
||||||
),
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if !out.status.success() {
|
if out.status.success() {
|
||||||
panic!(
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
"precondition failed --- this is NOT a teardown defect.\n{}",
|
"precondition failed --- this is NOT a teardown defect.\n{}",
|
||||||
String::from_utf8_lossy(&out.stderr).trim_end()
|
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 {
|
fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue