feat(gate,test): implement revision 13 --- the validated (status, token) pair

The helper now emits its verdict token on stdout with diagnostics on
stderr, and both consumers validate the PAIR rather than the status
alone. This closes the macOS defect CI found: a shell that cannot
execute the helper exits 1, which the status-only ABI read as
`ignored`, so a broken guard told the operator their environment
ignores SIGINT.

Gate (shell consumer):
  - guard-local capture directory, created before the gate's own
    temporary roots exist, with cleanup armed BEFORE the helper runs and
    disarmed on the safe path so the gate's later trap is undisturbed;
  - `|| sigint_status=$?` retained --- a bare invocation dies under
    `set -eu` before the status is read, which was the original bug;
  - `expected_token` selected by an explicit status case before any
    `set -u`-sensitive use, since an out-of-range status has none;
  - byte comparison via `cmp` against both permitted encodings, because
    a shell variable neither preserves NUL nor carries the child status;
  - the helper's stderr is surfaced ONLY for validated verdicts; a
    boundary failure prints the gate's own wording and withholds the
    untrusted child output;
  - every refusing branch prints status= and token=.

R-d (Rust consumer) validates the same pair from Command::output()
bytes. It needs no capture files, and its spawn-error path has no status
at all --- the boundary the shell cannot represent.

Conformance: 45 shared cases generated as a cross-product over token
class, encoding and status, run by BOTH validators so they cannot
diverge, plus Rust's X2 for 46 overall. 34 gate rows, 16 GPU rows, full
gate green.

Mutations, each biting its row: accepting any status 2 regardless of
token; surfacing child stderr on a boundary failure; emitting the token
to stderr. The first is caught by the dedicated error row rather than
the conformance set --- most of the set's boundary cases have empty
stderr, so they cannot tell which branch produced the exit 2 --- and
that limitation is recorded rather than left implicit.

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 21:14:30 +02:00
parent 2a6625ddb9
commit bc7d776569
No known key found for this signature in database
5 changed files with 478 additions and 77 deletions

View File

@ -279,6 +279,27 @@ from #171 and #215.
**`72da24a`**, worktree
`/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with
`git fetch githubsucks && git checkout gpu-probe-sigint-teardown`.
- **REVISION 13 IMPLEMENTED (this commit).** Helper emits the token on
stdout; both consumers validate the `(status, token)` pair. The gate
owns a guard-local capture dir, keeps `|| status=$?` under `set -eu`,
selects `expected_token` before any `set -u`-sensitive use, compares
bytes with `cmp` against both permitted encodings, surfaces the
helper's stderr **only** for validated verdicts, and prints
`status=`/`token=` on every refusing branch. R-d validates the same
pair from `Command::output()` bytes — no capture files, since only
the shell needs them.
- **Conformance: 45 shared cases, generated as a cross-product, run by
BOTH validators** (`gate_validates_the_whole_shared_conformance_set`,
`rd_precondition_validates_the_whole_conformance_set`), plus Rust's
X2 no-status spawn error = 46. **34 gate rows, 16 GPU rows, full gate
green.**
- **A4 mutations, each biting:** accepting any status 2 regardless of
token → `gate_refuses_on_helper_error_…`; surfacing child stderr on a
boundary failure → the conformance row **and** that row; token to
stderr → `sigint_helper_reports_safe_…`. Noted for the record: the
first mutation is caught by the dedicated row rather than by the
conformance set, because the set's boundary cases mostly have empty
stderr and so cannot see which branch produced the exit 2.
- **CI ON `916007b`: 12 GREEN, 2 RED — both macOS `Test` jobs**, and it
is the **pre-declared A7 portability finding**, not an environment
excuse. Exactly one row:

View File

@ -10,12 +10,23 @@
# teardown defect. One lane spent nine framing revisions on exactly that
# misreading; see docs/gpu-probe-sigint-framing.md §4c.
#
# INTERFACE (docs/gpu-probe-sigint-framing.md §7c). Callers rely on
# these three statuses and MUST NOT re-derive the classification:
# INTERFACE (docs/gpu-probe-sigint-framing.md §7c) --- a validated
# (status, token) PAIR. Callers rely on both halves and MUST NOT
# re-derive the classification:
#
# 0 safe SIGINT is deliverable. No diagnostic.
# 1 ignored SIGINT is inherited SIG_IGN. Canonical diagnostic.
# 2 error Undecidable. Distinct diagnostic.
# 0 safe stdout: pmacs-sigint-v1:safe no diagnostic
# 1 ignored stdout: pmacs-sigint-v1:ignored canonical diagnostic
# 2 error stdout: pmacs-sigint-v1:error distinct diagnostic
#
# The token is the ONLY thing on stdout; diagnostics go to stderr. Any
# other pair --- including a plausible status with no token --- is a
# BOUNDARY error for the caller, mapped to 2.
#
# WHY A TOKEN AND NOT A STATUS ALONE. CI proved a status-only ABI
# unsound: on macOS a shell that cannot execute this file exits 1,
# which the old ABI read as `ignored`, so a broken guard told the
# operator their environment ignores SIGINT. No exit status can prove
# this script ran; a token it must have printed can.
#
# `error` is never folded into `ignored`. "Your environment ignores
# SIGINT" and "the guard could not run" are different problems, and
@ -35,12 +46,17 @@ sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \
|| probe_status=$?
case "$probe_status" in
23) exit 0 ;;
23)
echo 'pmacs-sigint-v1:safe'
exit 0
;;
0)
echo 'pmacs-sigint-v1:ignored'
echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2
exit 1
;;
*)
echo 'pmacs-sigint-v1:error'
echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2
exit 2
;;

View File

@ -539,22 +539,78 @@ cd "$WT"
# evidence, so a flag to proceed anyway would only manufacture red gates
# that mean nothing.
if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then
# The guard owns its capture directory: it runs BEFORE the log dir,
# ambient root and GATE_TMPDIR exist, and must still leave nothing
# behind (A8).
capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || {
echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2
echo 'gate: REFUSING TO RUN --- no stage has run.' >&2
exit 2
}
cleanup_sigint_capture() { rm -rf "$capture"; }
trap cleanup_sigint_capture EXIT HUP INT TERM
# `|| sigint_status=$?` IS LOAD-BEARING under `set -eu`: a bare
# invocation dies at the helper's non-zero exit and never reaches
# the assignment. That was the originally shipped bug.
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.
"$WT/scripts/check-sigint-deliverable" \
>"$capture/out" 2>"$capture/err" || sigint_status=$?
# Select an expected token only for public statuses. This MUST
# precede any use of expected_token: `set -u` is on, and an
# out-of-range status has no expected token.
expected_token=
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
0) expected_token=pmacs-sigint-v1:safe ;;
1) expected_token=pmacs-sigint-v1:ignored ;;
2) expected_token=pmacs-sigint-v1:error ;;
esac
# Byte comparison against both permitted encodings. Files preserve
# every byte including NUL; a shell variable would not, and command
# substitution's NUL handling differs between sh and zsh.
sigint_token_ok=0
if [ -n "$expected_token" ]; then
printf '%s' "$expected_token" >"$capture/want"
printf '%s\n' "$expected_token" >"$capture/want_lf"
if cmp -s "$capture/out" "$capture/want" ||
cmp -s "$capture/out" "$capture/want_lf"; then
sigint_token_ok=1
fi
fi
if [ ! -s "$capture/out" ]; then
sigint_token_state=missing
elif [ "$sigint_token_ok" -eq 1 ]; then
sigint_token_state=valid
else
sigint_token_state=unexpected
fi
case "$sigint_status:$sigint_token_ok" in
0:1)
# The sole continuing path. Tidy up and disarm before the
# gate installs its own, unrelated cleanup trap.
cleanup_sigint_capture
trap - EXIT HUP INT TERM
;;
1:1 | 2:1)
# A VALIDATED verdict: the helper's stderr IS the diagnosis
# and is surfaced unchanged.
cat "$capture/err" >&2
printf 'gate: REFUSING TO RUN (status=%s token=%s) --- no stage has run.\n' \
"$sigint_status" "$sigint_token_state" >&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
# BOUNDARY error. The captured stderr is UNTRUSTED and is
# deliberately not surfaced: a helper exiting 1 with no
# token but the canonical ignored wording would otherwise
# tell the operator their environment ignores SIGINT (A6b).
printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \
"$sigint_status" "$sigint_token_state" >&2
printf 'gate: REFUSING TO RUN --- no stage has run.\n' >&2
exit 2
;;
esac

View File

@ -184,7 +184,17 @@ fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() {
"an error verdict exits 2, not 1"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("could not determine"), "error wording: {err}");
// This stub emits the error TEXT but no token, so under the pair
// ABI it is a BOUNDARY error --- and its stderr is untrusted, hence
// deliberately not surfaced.
assert!(
err.contains("SIGINT guard boundary error"),
"an unvalidated pair is a boundary error: {err}"
);
assert!(
!err.contains("could not determine"),
"and the untrusted child stderr is NOT shown: {err}"
);
assert!(
!err.contains("SIGINT is ignored"),
"an undecidable probe must never be reported as ignored: {err}"
@ -220,15 +230,189 @@ fn gate_maps_an_unexecutable_helper_to_error_not_ignored() {
"boundary failures map to 2; gate said:\n{err}"
);
assert!(
err.contains("could not run the SIGINT guard"),
err.contains("SIGINT guard boundary error"),
"the boundary has its own wording: {err}"
);
assert!(
err.contains("token=missing"),
"and names the token state, not just the status: {err}"
);
assert!(
!err.contains("SIGINT is ignored"),
"an unrunnable guard is not evidence about the signal: {err}"
);
}
/// One stub-worktree gate run against a controlled `(status, stdout)`
/// pair, returning the gate's own exit code and stderr.
///
/// The gate is the **shell consumer** of the pair ABI. Driving it
/// through a stub worktree exercises its real code path — including
/// the `set -eu` handling and the capture directory — against inputs
/// no real helper would produce.
fn gate_sees_pair(status: i32, stdout: &[u8], stderr_line: &str) -> (Option<i32>, String, PathBuf) {
let root = tempfile::tempdir().expect("tempdir");
let octal = stdout.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "\\{b:03o}");
acc
});
let repo = gate_with_stub_helper(
&format!("#!/bin/sh\nprintf '{octal}'\n{stderr_line}exit {status}\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())
.env("TMPDIR", root.path())
.output()
.expect("run the stub-worktree gate");
let code = out.status.code();
let err = String::from_utf8_lossy(&out.stderr).into_owned();
// Keep `root` alive for the residue check by returning its path
// after leaking the handle: the caller inspects it, then it is
// dropped with the TempDir at end of test.
let path = root.keep();
(code, err, path)
}
/// A6/A6b/A6c, shell consumer: the same 45 shared conformance cases the
/// Rust validator runs, so the two cannot diverge.
///
/// X2 is absent by construction — a shell `exec` failure becomes a
/// shell status, so the shell boundary cannot present a status-less
/// spawn error.
#[test]
#[allow(
clippy::too_many_lines,
reason = "the bulk is the generated case list, which is data; splitting \
it would put the cases and the expectations they encode in \
different places"
)]
fn gate_validates_the_whole_shared_conformance_set() {
const SAFE: &[u8] = b"pmacs-sigint-v1:safe";
const IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
const ERROR: &[u8] = b"pmacs-sigint-v1:error";
let toks: [(&str, &[u8]); 3] = [("safe", SAFE), ("ignored", IGNORED), ("error", ERROR)];
let mut cases: Vec<(String, i32, Vec<u8>, &str, bool)> = Vec::new();
for (idx, (name, correct)) in toks.iter().enumerate() {
let status = i32::try_from(idx).expect("0..=2");
let mut lf = correct.to_vec();
lf.push(b'\n');
// Only status 0's correct token is `safe`; the correct token at
// 1 and 2 is a VALIDATED verdict, which refuses with its own
// status rather than continuing.
let safe_here = status == 0;
cases.push((
format!("{status}/V/{name}/bare"),
status,
correct.to_vec(),
"",
safe_here,
));
cases.push((format!("{status}/V/{name}/lf"), status, lf, "", safe_here));
for (other, bytes) in &toks {
if other == name {
continue;
}
let mut olf = bytes.to_vec();
olf.push(b'\n');
cases.push((
format!("{status}/M/{other}/bare"),
status,
bytes.to_vec(),
"",
false,
));
cases.push((format!("{status}/M/{other}/lf"), status, olf, "", false));
}
let mut leading = vec![b'\n'];
leading.extend_from_slice(correct);
let mut extra = correct.to_vec();
extra.extend_from_slice(b"\n\n");
let mut spaces = b" ".to_vec();
spaces.extend_from_slice(correct);
spaces.push(b' ');
let mut crlf = correct.to_vec();
crlf.extend_from_slice(b"\r\n");
let mut doubled = correct.to_vec();
doubled.extend_from_slice(correct);
let mut nul = correct.to_vec();
nul.push(0);
for (cls, bytes) in [
("E/empty", Vec::new()),
("U/unknown", b"pmacs-sigint-v2:safe".to_vec()),
("L/leading-lf", leading),
("X/extra-lf", extra),
("S/spaces", spaces),
("C/crlf", crlf),
("D/doubled", doubled),
("N/nul", nul),
] {
cases.push((format!("{status}/{cls}"), status, bytes, "", false));
}
}
cases.push(("X1/status-126".to_owned(), 126, SAFE.to_vec(), "", false));
cases.push((
"X3/ignored-text-no-token".to_owned(),
1,
Vec::new(),
"echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\n",
false,
));
cases.push((
"X4/stderr-noise".to_owned(),
0,
SAFE.to_vec(),
"echo 'chatter on stderr' >&2\n",
true,
));
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
for (name, status, stdout, stderr_line, expect_pass) in cases {
let (code, err, dir) = gate_sees_pair(status, &stdout, stderr_line);
if expect_pass {
// `safe` continues into the self-test plan, which exits
// non-zero ON PURPOSE — what matters is that the guard did
// not refuse.
assert!(
!err.contains("REFUSING TO RUN"),
"case {name}: the guard must not refuse a validated safe pair: {err}"
);
} else {
assert!(
err.contains("REFUSING TO RUN"),
"case {name}: the guard must refuse: {err}"
);
let validated = name.contains("/V/") && (status == 1 || status == 2);
if validated {
assert_eq!(
code,
Some(status),
"case {name}: validated verdicts pass their status through"
);
} else {
assert_eq!(code, Some(2), "case {name}: boundary errors map to 2");
assert!(
!err.contains("SIGINT is ignored"),
"case {name}: a boundary failure must not speak with the \
helper's voice: {err}"
);
}
}
// A8: no capture directory survives, on any path.
let residue: Vec<_> = std::fs::read_dir(&dir)
.expect("read tmpdir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("pmacs-sigint."))
.collect();
assert!(residue.is_empty(), "case {name}: capture residue survived");
std::fs::remove_dir_all(&dir).ok();
}
}
/// §7c: the helper answers `safe` when `SIGINT` is deliverable.
#[test]
fn sigint_helper_reports_safe_when_the_signal_is_deliverable() {

View File

@ -190,32 +190,78 @@ mod crdt {
/// helper's own stderr. A helper that cannot be executed is an
/// `error` at this boundary, never evidence that `SIGINT` is
/// ignored.
/// The diagnosis for one helper invocation: `Ok` to proceed, `Err`
/// with the message a caller should fail on.
const SIGINT_TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
const SIGINT_TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
const SIGINT_TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error";
/// Does `stdout` carry exactly `token`, in one of the two permitted
/// encodings? Grammar is `TOKEN | TOKEN LF` **as bytes** — no
/// trimming, so a leading newline, a second newline, surrounding
/// spaces, CRLF, a doubled token or a trailing NUL all fail.
fn sigint_token_matches(stdout: &[u8], token: &[u8]) -> bool {
stdout == token
|| (stdout.len() == token.len() + 1
&& stdout.starts_with(token)
&& stdout[token.len()] == b'\n')
}
/// The diagnosis for one helper invocation, validating the
/// `(status, token)` pair rather than the status alone.
///
/// 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.
/// A status arriving without its token did not come from this
/// helper — not hypothetical: on macOS a shell that cannot execute
/// the helper exits 1, which a status-only ABI read as `ignored`
/// (§4d). Rust compares `Command::output()` bytes directly; only
/// the shell consumer needs capture files.
fn sigint_diagnosis(helper: &Path) -> Result<(), String> {
let out = match Command::new(helper).output() {
Ok(out) => out,
// Failure to execute the helper is an `error` AT THIS
// BOUNDARY, never evidence that SIGINT is ignored.
// Rust's boundary differs from the shell's: a spawn error
// has NO status, where a shell turns the same failure into
// one. Conformance X2, Rust-only.
Err(error) => {
return Err(format!(
"precondition undecidable --- could not execute {}: {error}",
"precondition undecidable --- SIGINT guard boundary error \
(status=unavailable token=missing): could not execute {}: {error}",
helper.display()
));
}
Ok(out) => out,
};
if out.status.success() {
return Ok(());
let expected: &[u8] = match out.status.code() {
Some(0) => SIGINT_TOKEN_SAFE,
Some(1) => SIGINT_TOKEN_IGNORED,
Some(2) => SIGINT_TOKEN_ERROR,
_ => b"",
};
let token_ok = !expected.is_empty() && sigint_token_matches(&out.stdout, expected);
let token_state = if out.stdout.is_empty() {
"missing"
} else if token_ok {
"valid"
} else {
"unexpected"
};
let status = out
.status
.code()
.map_or_else(|| "signal".to_owned(), |c| c.to_string());
match (out.status.code(), token_ok) {
(Some(0), true) => Ok(()),
// A VALIDATED verdict: the helper's stderr is the diagnosis.
(Some(1) | Some(2), true) => Err(format!(
"precondition failed --- this is NOT a teardown defect. \
(status={status} token={token_state})\n{}",
String::from_utf8_lossy(&out.stderr).trim_end()
)),
// BOUNDARY: the child's stderr is UNTRUSTED and is not shown,
// or a helper exiting 1 with no token but the canonical
// ignored wording would still mislead the reader (A6b).
_ => Err(format!(
"precondition undecidable --- SIGINT guard boundary error \
(status={status} token={token_state}). The helper's own \
output is not trusted here and is not shown."
)),
}
Err(format!(
"precondition failed --- this is NOT a teardown defect.\n{}",
String::from_utf8_lossy(&out.stderr).trim_end()
))
}
fn sigint_helper_path() -> PathBuf {
@ -228,54 +274,132 @@ mod crdt {
}
}
/// A6, R-d consumer: the direct test distinguishes `error` from
/// `ignored`, and neither message claims a teardown defect.
/// The shared conformance set, generated rather than listed
/// (§7c): ten classes, two encodings where applicable, three
/// statuses = 42, plus X1/X3/X4. Only the diagonal validates.
fn sigint_conformance_cases() -> Vec<(String, i32, Vec<u8>, bool)> {
let toks: [(&str, &[u8]); 3] = [
("safe", SIGINT_TOKEN_SAFE),
("ignored", SIGINT_TOKEN_IGNORED),
("error", SIGINT_TOKEN_ERROR),
];
let mut out = Vec::new();
for (idx, (name, correct)) in toks.iter().enumerate() {
let status = i32::try_from(idx).expect("0..=2");
let ok = status == 0;
let mut lf = correct.to_vec();
lf.push(b'\n');
out.push((
format!("{status}/V/{name}/bare"),
status,
correct.to_vec(),
ok,
));
out.push((format!("{status}/V/{name}/lf"), status, lf, ok));
// Every OTHER valid token, both encodings: enumerated, not
// sampled, since sampling one leaves half untested.
for (other, bytes) in &toks {
if other == name {
continue;
}
let mut olf = bytes.to_vec();
olf.push(b'\n');
out.push((
format!("{status}/M/{other}/bare"),
status,
bytes.to_vec(),
false,
));
out.push((format!("{status}/M/{other}/lf"), status, olf, false));
}
let mut leading = vec![b'\n'];
leading.extend_from_slice(correct);
let mut extra = correct.to_vec();
extra.extend_from_slice(b"\n\n");
let mut spaces = b" ".to_vec();
spaces.extend_from_slice(correct);
spaces.push(b' ');
let mut crlf = correct.to_vec();
crlf.extend_from_slice(b"\r\n");
let mut doubled = correct.to_vec();
doubled.extend_from_slice(correct);
let mut nul = correct.to_vec();
nul.push(0);
for (cls, bytes) in [
("E/empty", Vec::new()),
("U/unknown", b"pmacs-sigint-v2:safe".to_vec()),
("L/leading-lf", leading),
("X/extra-lf", extra),
("S/spaces", spaces),
("C/crlf", crlf),
("D/doubled", doubled),
("N/nul", nul),
] {
out.push((format!("{status}/{cls}"), status, bytes, false));
}
}
out.push((
"X1/status-126".to_owned(),
126,
SIGINT_TOKEN_SAFE.to_vec(),
false,
));
out.push(("X3/ignored-text-no-token".to_owned(), 1, Vec::new(), false));
out.push((
"X4/stderr-noise".to_owned(),
0,
SIGINT_TOKEN_SAFE.to_vec(),
true,
));
out
}
/// A6/A6b/A6c, R-d consumer: the whole shared set, plus Rust's X2.
#[test]
fn rd_precondition_distinguishes_ignored_from_error() {
// `safe` proceeds silently.
assert!(
sigint_diagnosis(&sigint_helper_path()).is_ok(),
"the foreground case must proceed"
);
fn rd_precondition_validates_the_whole_conformance_set() {
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 cases = sigint_conformance_cases();
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
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}"
);
for (name, status, stdout, expect_ok) in cases {
let path = dir.path().join(name.replace('/', "_"));
let extra = if name.starts_with("X3") {
"echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\n"
} else if name.starts_with("X4") {
"echo 'chatter on stderr' >&2\n"
} else {
""
};
let octal = stdout.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "\\{b:03o}");
acc
});
fs::write(
&path,
format!("#!/bin/sh\nprintf '{octal}'\n{extra}exit {status}\n"),
)
.expect("write stub");
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
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}"
);
let got = sigint_diagnosis(&path);
assert_eq!(got.is_ok(), expect_ok, "case {name}: got {got:?}");
if let Err(message) = got {
let validated = name.contains("/V/") && (status == 1 || status == 2);
assert!(
validated || !message.contains("SIGINT is ignored"),
"case {name}: a boundary failure must not speak with the \
helper's voice: {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");
// X2 — Rust only: a shell exec failure becomes a status, so the
// shell consumer cannot present this input at all.
let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate");
assert!(
message.contains("undecidable") && !message.contains("SIGINT is ignored"),
"boundary failure is undecidable, never ignored: {message}"
message.contains("status=unavailable") && !message.contains("SIGINT is ignored"),
"X2: {message}"
);
}