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:
parent
2a6625ddb9
commit
bc7d776569
|
|
@ -279,6 +279,27 @@ 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`.
|
||||||
|
- **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
|
- **CI ON `916007b`: 12 GREEN, 2 RED — both macOS `Test` jobs**, and it
|
||||||
is the **pre-declared A7 portability finding**, not an environment
|
is the **pre-declared A7 portability finding**, not an environment
|
||||||
excuse. Exactly one row:
|
excuse. Exactly one row:
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,23 @@
|
||||||
# teardown defect. One lane spent nine framing revisions on exactly that
|
# teardown defect. One lane spent nine framing revisions on exactly that
|
||||||
# misreading; see docs/gpu-probe-sigint-framing.md §4c.
|
# misreading; see docs/gpu-probe-sigint-framing.md §4c.
|
||||||
#
|
#
|
||||||
# INTERFACE (docs/gpu-probe-sigint-framing.md §7c). Callers rely on
|
# INTERFACE (docs/gpu-probe-sigint-framing.md §7c) --- a validated
|
||||||
# these three statuses and MUST NOT re-derive the classification:
|
# (status, token) PAIR. Callers rely on both halves and MUST NOT
|
||||||
|
# re-derive the classification:
|
||||||
#
|
#
|
||||||
# 0 safe SIGINT is deliverable. No diagnostic.
|
# 0 safe stdout: pmacs-sigint-v1:safe no diagnostic
|
||||||
# 1 ignored SIGINT is inherited SIG_IGN. Canonical diagnostic.
|
# 1 ignored stdout: pmacs-sigint-v1:ignored canonical diagnostic
|
||||||
# 2 error Undecidable. Distinct 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
|
# `error` is never folded into `ignored`. "Your environment ignores
|
||||||
# SIGINT" and "the guard could not run" are different problems, and
|
# 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=$?
|
|| probe_status=$?
|
||||||
|
|
||||||
case "$probe_status" in
|
case "$probe_status" in
|
||||||
23) exit 0 ;;
|
23)
|
||||||
|
echo 'pmacs-sigint-v1:safe'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
0)
|
0)
|
||||||
|
echo 'pmacs-sigint-v1:ignored'
|
||||||
echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2
|
echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
echo 'pmacs-sigint-v1:error'
|
||||||
echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2
|
echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
78
scripts/gate
78
scripts/gate
|
|
@ -539,22 +539,78 @@ cd "$WT"
|
||||||
# evidence, so a flag to proceed anyway would only manufacture red gates
|
# evidence, so a flag to proceed anyway would only manufacture red gates
|
||||||
# that mean nothing.
|
# that mean nothing.
|
||||||
if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then
|
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
|
sigint_status=0
|
||||||
"$WT/scripts/check-sigint-deliverable" || sigint_status=$?
|
"$WT/scripts/check-sigint-deliverable" \
|
||||||
# 1 and 2 are the helper's own verdicts and pass through unchanged.
|
>"$capture/out" 2>"$capture/err" || sigint_status=$?
|
||||||
# Anything else --- 126/127 for an unexecutable or missing helper, a
|
|
||||||
# signal death, any future status --- is an `error` AT THIS
|
# Select an expected token only for public statuses. This MUST
|
||||||
# BOUNDARY, never evidence that SIGINT is ignored.
|
# 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
|
case "$sigint_status" in
|
||||||
0) ;;
|
0) expected_token=pmacs-sigint-v1:safe ;;
|
||||||
1 | 2)
|
1) expected_token=pmacs-sigint-v1:ignored ;;
|
||||||
echo "gate: REFUSING TO RUN --- see the diagnosis above." >&2
|
2) expected_token=pmacs-sigint-v1:error ;;
|
||||||
echo "gate: no stage has run; this is not a test failure." >&2
|
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"
|
exit "$sigint_status"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "gate: could not run the SIGINT guard (status $sigint_status)" >&2
|
# BOUNDARY error. The captured stderr is UNTRUSTED and is
|
||||||
echo "gate: REFUSING TO RUN --- no stage has run." >&2
|
# 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
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,17 @@ fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() {
|
||||||
"an error verdict exits 2, not 1"
|
"an error verdict exits 2, not 1"
|
||||||
);
|
);
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
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!(
|
assert!(
|
||||||
!err.contains("SIGINT is ignored"),
|
!err.contains("SIGINT is ignored"),
|
||||||
"an undecidable probe must never be reported as ignored: {err}"
|
"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}"
|
"boundary failures map to 2; gate said:\n{err}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("could not run the SIGINT guard"),
|
err.contains("SIGINT guard boundary error"),
|
||||||
"the boundary has its own wording: {err}"
|
"the boundary has its own wording: {err}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
err.contains("token=missing"),
|
||||||
|
"and names the token state, not just the status: {err}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!err.contains("SIGINT is ignored"),
|
!err.contains("SIGINT is ignored"),
|
||||||
"an unrunnable guard is not evidence about the signal: {err}"
|
"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.
|
/// §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() {
|
||||||
|
|
|
||||||
|
|
@ -190,32 +190,78 @@ 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.
|
||||||
/// The diagnosis for one helper invocation: `Ok` to proceed, `Err`
|
const SIGINT_TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
|
||||||
/// with the message a caller should fail on.
|
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.
|
/// A status arriving without its token did not come from this
|
||||||
/// A6 requires this consumer to distinguish `error` from `ignored`,
|
/// helper — not hypothetical: on macOS a shell that cannot execute
|
||||||
/// and a diagnosis reachable only through a panic in a test that
|
/// the helper exits 1, which a status-only ABI read as `ignored`
|
||||||
/// cannot run under the condition it describes is not a witness.
|
/// (§4d). Rust compares `Command::output()` bytes directly; only
|
||||||
|
/// the shell consumer needs capture files.
|
||||||
fn sigint_diagnosis(helper: &Path) -> Result<(), String> {
|
fn sigint_diagnosis(helper: &Path) -> Result<(), String> {
|
||||||
let out = match Command::new(helper).output() {
|
let out = match Command::new(helper).output() {
|
||||||
Ok(out) => out,
|
// Rust's boundary differs from the shell's: a spawn error
|
||||||
// Failure to execute the helper is an `error` AT THIS
|
// has NO status, where a shell turns the same failure into
|
||||||
// BOUNDARY, never evidence that SIGINT is ignored.
|
// one. Conformance X2, Rust-only.
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(format!(
|
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()
|
helper.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Ok(out) => out,
|
||||||
};
|
};
|
||||||
if out.status.success() {
|
let expected: &[u8] = match out.status.code() {
|
||||||
return Ok(());
|
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 {
|
fn sigint_helper_path() -> PathBuf {
|
||||||
|
|
@ -228,54 +274,132 @@ mod crdt {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A6, R-d consumer: the direct test distinguishes `error` from
|
/// The shared conformance set, generated rather than listed
|
||||||
/// `ignored`, and neither message claims a teardown defect.
|
/// (§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]
|
#[test]
|
||||||
fn rd_precondition_distinguishes_ignored_from_error() {
|
fn rd_precondition_validates_the_whole_conformance_set() {
|
||||||
// `safe` proceeds silently.
|
|
||||||
assert!(
|
|
||||||
sigint_diagnosis(&sigint_helper_path()).is_ok(),
|
|
||||||
"the foreground case must proceed"
|
|
||||||
);
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let stub = |name: &str, body: &str, mode: u32| {
|
let cases = sigint_conformance_cases();
|
||||||
let path = dir.path().join(name);
|
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
|
||||||
fs::write(&path, body).expect("write stub");
|
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("chmod stub");
|
|
||||||
path
|
|
||||||
};
|
|
||||||
|
|
||||||
let ignored = stub(
|
for (name, status, stdout, expect_ok) in cases {
|
||||||
"ignored",
|
let path = dir.path().join(name.replace('/', "_"));
|
||||||
"#!/bin/sh\necho 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\nexit 1\n",
|
let extra = if name.starts_with("X3") {
|
||||||
0o755,
|
"echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2\n"
|
||||||
);
|
} else if name.starts_with("X4") {
|
||||||
let message = sigint_diagnosis(&ignored).expect_err("exit 1 must be refused");
|
"echo 'chatter on stderr' >&2\n"
|
||||||
assert!(message.contains("SIGINT is ignored"), "{message}");
|
} else {
|
||||||
assert!(
|
""
|
||||||
message.contains("NOT a teardown defect"),
|
};
|
||||||
"the whole point is not to read as a teardown defect: {message}"
|
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(
|
let got = sigint_diagnosis(&path);
|
||||||
"erroring",
|
assert_eq!(got.is_ok(), expect_ok, "case {name}: got {got:?}");
|
||||||
"#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n",
|
if let Err(message) = got {
|
||||||
0o755,
|
let validated = name.contains("/V/") && (status == 1 || status == 2);
|
||||||
);
|
assert!(
|
||||||
let message = sigint_diagnosis(&erroring).expect_err("exit 2 must be refused");
|
validated || !message.contains("SIGINT is ignored"),
|
||||||
assert!(message.contains("could not determine"), "{message}");
|
"case {name}: a boundary failure must not speak with the \
|
||||||
assert!(
|
helper's voice: {message}"
|
||||||
!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.
|
// X2 — Rust only: a shell exec failure becomes a status, so the
|
||||||
let unrunnable = stub("unrunnable", "#!/bin/sh\nexit 0\n", 0o644);
|
// shell consumer cannot present this input at all.
|
||||||
let message = sigint_diagnosis(&unrunnable).expect_err("an unrunnable helper must refuse");
|
let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate");
|
||||||
assert!(
|
assert!(
|
||||||
message.contains("undecidable") && !message.contains("SIGINT is ignored"),
|
message.contains("status=unavailable") && !message.contains("SIGINT is ignored"),
|
||||||
"boundary failure is undecidable, never ignored: {message}"
|
"X2: {message}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue