Merge pull request #241 from levineuwirth/gpu-probe-sigint-teardown
gate: refuse to run when SIGINT is not deliverable
This commit is contained in:
commit
f8033bc245
|
|
@ -270,6 +270,413 @@ hazard in a shape that looks committed. **A documented error message
|
||||||
that never appears is worse than no documentation**, because the reader
|
that never appears is worse than no documentation**, because the reader
|
||||||
waits for a signal that is not coming.
|
waits for a signal that is not coming.
|
||||||
|
|
||||||
|
## GPU launcher / probe SIGINT teardown — PR #241 OPEN
|
||||||
|
|
||||||
|
**Written with the branch's FIRST commit**, per the standing correction
|
||||||
|
from #171 and #215.
|
||||||
|
|
||||||
|
- **Branch `gpu-probe-sigint-teardown`**, base `githubsucks/main` @
|
||||||
|
**`72da24a`**, worktree
|
||||||
|
`/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with
|
||||||
|
`git fetch githubsucks && git checkout gpu-probe-sigint-teardown`.
|
||||||
|
- **REVISION 13 IMPLEMENTED at `bc7d776`.** 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 vectors live in `tests/common/sigint_conformance.rs`**
|
||||||
|
and are consumed by BOTH validators, so the two copies cannot drift
|
||||||
|
while each still reports "45 cases". The **branch-discriminating** cases emit a
|
||||||
|
sentinel on stderr, which is what separates `ValidatedError` from
|
||||||
|
`Boundary` —
|
||||||
|
they share exit 2, so comparing codes alone let a validator that
|
||||||
|
accepted every status 2 pass the whole matrix. An `Outcome` enum
|
||||||
|
(Safe / ValidatedIgnored / ValidatedError / Boundary) is asserted
|
||||||
|
branch-exact in both suites, and each helper arm's exact stdout token
|
||||||
|
is asserted too.
|
||||||
|
- **A8 is complete**: a bounded row points `TMPDIR` at a missing
|
||||||
|
directory so `mktemp -d` fails, and asserts boundary error 2, no
|
||||||
|
stage, and no residue. Temporary directories are RAII throughout;
|
||||||
|
the `keep()`-plus-manual-cleanup shape is gone.
|
||||||
|
- **36 gate rows, 16 GPU rows.**
|
||||||
|
- **HEAD-EXACT GATE on `8802d6a`: green, all 8 stages**, log
|
||||||
|
`20260820T072102Z-3009434`. The first attempt on the same head
|
||||||
|
(`…-2931214`) failed `07-sweep` on
|
||||||
|
`composition_overhead_under_ten_percent` — a perf budget unrelated to
|
||||||
|
this lane, green in isolation, and a signature the
|
||||||
|
`panel-mapping-generation` ledger already records recurring. **Both
|
||||||
|
runs are recorded; no causal attribution is made** for the first,
|
||||||
|
only that the second is the head-exact evidence.
|
||||||
|
- **The earlier `…-2647615` run is NOT head-exact evidence**: it
|
||||||
|
finished about 30 seconds before `bc7d776` was committed, so it
|
||||||
|
describes the implementation tree rather than a committed head.
|
||||||
|
- **A4 mutations, each biting the MATRIX now**, not a dedicated row:
|
||||||
|
accepting any status 2 regardless of token, and surfacing child
|
||||||
|
stderr on a boundary failure, both fail
|
||||||
|
`gate_validates_the_whole_shared_conformance_set`; token-to-stderr
|
||||||
|
fails `sigint_helper_reports_safe_…`. An earlier entry said the
|
||||||
|
status-2 mutation was caught only by the dedicated row — that was
|
||||||
|
true of the pre-sentinel matrix and is **superseded**: the
|
||||||
|
branch-discriminating cases now carry a sentinel, so the matrix can
|
||||||
|
see which branch produced the exit 2.
|
||||||
|
- **X3 and X4 deliberately carry their OWN stderr payloads**, not the
|
||||||
|
sentinel — X3 the canonical ignored wording with no token, X4 noise —
|
||||||
|
which is what makes them distinct inputs. `shared_cases()` asserts
|
||||||
|
uniqueness over `(status, stdout, stderr)`, so the earlier
|
||||||
|
45-entries-over-43-inputs collapse cannot recur silently; reverting
|
||||||
|
either payload now fails by name.
|
||||||
|
- **Two PRE-EXISTING crdt-only failures found while gating properly:**
|
||||||
|
`m4_24_bare_string_glob_stays_relative` and
|
||||||
|
`m4_24_d3_fallback_base_is_the_smallest_attachment_dir`. They
|
||||||
|
reproduce in isolation (so not load) and **fail identically at
|
||||||
|
`72da24a`**, so they are not this lane's. They are crdt-only — the
|
||||||
|
plain gate's `05-m4` stage runs without `crdt` and passes. Recorded,
|
||||||
|
not attributed; whether they are environment-specific is for CI to
|
||||||
|
say.
|
||||||
|
- **My local gate did NOT cover what CI covers, and CI caught it.**
|
||||||
|
Plain `./scripts/gate` omits `sweep-crdt`, which is the only stage
|
||||||
|
that compiles the nested `gpu_initial_target_acceptance` under
|
||||||
|
`crdt`; `04-lib-crdt` builds the lib alone. A `crate::common` path
|
||||||
|
that cannot resolve when nested, and a clippy lint, both shipped
|
||||||
|
green locally. **This lane gates with `--protocol`.**
|
||||||
|
- **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:
|
||||||
|
`gate_maps_an_unexecutable_helper_to_error_not_ignored`,
|
||||||
|
`left: Some(1) right: Some(2)`. The other five SIGINT rows pass on
|
||||||
|
macOS, including both `error` cases, so helper and gate consumers are
|
||||||
|
otherwise exercised there.
|
||||||
|
- **The gate returned 1 = `ignored` for a helper it could not
|
||||||
|
execute** — the exact conflation §7c forbids.
|
||||||
|
- **ESTABLISHED, not a hypothesis.** The macOS log shows the shell's
|
||||||
|
`Permission denied` followed by the gate's `1 | 2)` message — so
|
||||||
|
`sigint_status` was **1**: macOS `/bin/sh` returns **1** for an
|
||||||
|
exec failure where Linux returns **126**. The status-only ABI
|
||||||
|
cannot separate that from the helper's own `ignored`.
|
||||||
|
- **The raw status was NOT printed.** An earlier entry here said the
|
||||||
|
stderr carries it; the gate interpolates the number only in its
|
||||||
|
catch-all branch, and this failure took the `1 | 2)` branch. The
|
||||||
|
path was identified by **which message text appeared**, not by a
|
||||||
|
number.
|
||||||
|
- **A proposed repair was REJECTED in review and is recorded so it is
|
||||||
|
not retried:** moving `ignored` from 1 to 3 relocates the collision
|
||||||
|
without closing it, because an exec failure can return **any**
|
||||||
|
nonzero status. The generalisation: **no exit status can prove the
|
||||||
|
helper ran.**
|
||||||
|
- **Framing revision 13 — APPROVED 2026-08-19 at `5dece3e` —
|
||||||
|
replaces the ABI with a validated `(status, token)` pair**:
|
||||||
|
`0`/`1`/`2` with
|
||||||
|
`pmacs-sigint-v1:safe|ignored|error`, token alone on stdout,
|
||||||
|
diagnostics on stderr, and any other pair — including macOS's
|
||||||
|
status 1 with no token — a boundary error mapped to 2. Every
|
||||||
|
refusing branch must print the observed status and token state as
|
||||||
|
diagnostic context, never as the classifier. A shared **conformance
|
||||||
|
matrix** replaces revision 12's withdrawn "both consumers use the
|
||||||
|
same helper so they can never disagree", which stopped being true
|
||||||
|
once each consumer validates the pair independently.
|
||||||
|
- **A6a is scoped to the GATE.** R-d never sees a shell status: Rust's
|
||||||
|
`Command` returns a spawn error with no exit status, and R-d's test
|
||||||
|
is crdt-gated so macOS CI does not compile it. R-d on macOS is
|
||||||
|
**unexercised**, recorded as a gap.
|
||||||
|
- **A7 is no longer "satisfied by disclosure"** — macOS was reached
|
||||||
|
and measured: five of six helper/gate rows pass, one defect
|
||||||
|
(`gate_maps_an_unexecutable_helper_to_error_not_ignored`), R-d
|
||||||
|
Linux-only.
|
||||||
|
- **PR #241** (`https://github.com/levineuwirth/pmacs/pull/241`), opened
|
||||||
|
2026-08-19 from `gpu-probe-sigint-teardown` into `main`. **Not merged;
|
||||||
|
awaiting review rounds.**
|
||||||
|
- **CHECKPOINT — immutable event SHAs, because "this entry's own
|
||||||
|
commit" goes stale exactly the way a tip SHA does:** implementation
|
||||||
|
landed at **`3206433`**; the A6 both-consumer rows and the bounded
|
||||||
|
negative path at **`167d830`**; the two factual corrections at
|
||||||
|
**`c9cc8dd`**; the gate-run record at **`7cef9ca`**; the PR record at
|
||||||
|
**`d64d300`**. Only the **branch tip** stays symbolic — 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:
|
||||||
|
**33 gate-acceptance rows and 16 `gpu_invocation_acceptance` rows**.
|
||||||
|
- **FULL GATE GREEN ON THE COMMITTED HEAD `c9cc8dd`** — all 8 stages,
|
||||||
|
log `20260819T160220Z-2339958`, started at load 3.90. Recorded at
|
||||||
|
`7cef9ca`, which is docs-only on top of the gated tree; that
|
||||||
|
exemption is what stops gate-result records recursing forever.
|
||||||
|
- **The preceding attempt on the same head was RED, and is kept.**
|
||||||
|
Log `20260819T152209Z-2073040`: `04-lib-crdt` and `07-sweep` failed
|
||||||
|
on four wall-clock rows —
|
||||||
|
`composition_overhead_under_ten_percent`,
|
||||||
|
`full_buffer_summary_flatten_scales_on_large_grammar_file`,
|
||||||
|
`dired_renders_10k_entries_within_200ms`,
|
||||||
|
`file_progress_notification_is_recorded_for_its_document` — none
|
||||||
|
touching this lane's change. Load average was **49.6**, and
|
||||||
|
`./verify_task_state.sh review/my-ruff-task golden` was running under
|
||||||
|
a separate toolchain at `/usr/local/rustup` with four `rustc`
|
||||||
|
processes, having started about three minutes into the run and
|
||||||
|
overlapping exactly the two stages that failed.
|
||||||
|
**That is evidence of WHEN, not proof of WHY**, and it was recorded
|
||||||
|
as "not valid evidence" rather than "environmental" — this lane has
|
||||||
|
already retracted one confident environmental attribution. The green
|
||||||
|
run on the same commit is what settles it; had any of the four failed
|
||||||
|
again on a quiet machine, it would have been a real finding here. (An earlier
|
||||||
|
draft said 35: that figure was the `git_status_stage1_acceptance`
|
||||||
|
result line immediately below `gate_script_acceptance`'s in the sweep
|
||||||
|
log, misread as this suite's.)
|
||||||
|
- **Framing revision 12 at `docs/gpu-probe-sigint-framing.md`,
|
||||||
|
approved 2026-08-19 at `1fc0df6` and IMPLEMENTED; **superseded by
|
||||||
|
revision 13, approved 2026-08-19 at `5dece3e`** — revision 10 was
|
||||||
|
approved at `4fba9f6` and revision 9 at `15c25ec`; neither approval covered the
|
||||||
|
later mechanism finding and remedy selection. **D1/D2 HAVE RUN and
|
||||||
|
found the mechanism: `SIGINT` was ignored group-wide
|
||||||
|
(`SigIgn=0x1007`) because
|
||||||
|
the test runner was launched in the background — `SIG_IGN` is
|
||||||
|
inherited across `fork` and survives `exec`, so it reached the
|
||||||
|
launcher and probe, and `kill(-pgid, SIGINT)` was a no-op.**
|
||||||
|
Controlled arms re-run on committed head `77b623c` with full SHA-256
|
||||||
|
captured per arm and byte-identical
|
||||||
|
binaries: foreground both copies ok, `setsid nohup … &` both FAILED.
|
||||||
|
**I caused this** by adopting background launches on 08-16 to evade
|
||||||
|
the Bash tool's ten-minute cap — that is the "onset", and the
|
||||||
|
subset-vs-full matrix was confounded with it throughout. A3's
|
||||||
|
subset/full obligation is discharged by explanation, so D0b is not
|
||||||
|
needed. **Bet 1 withdrawn by scope and A5 retired by scope — D4 was
|
||||||
|
never executed**, so no claim is made that a real session behaves
|
||||||
|
correctly, only that no evidence of a user-facing defect survives.
|
||||||
|
**A3/D0b are SATISFIED by that explanation** — D0b is not owed and
|
||||||
|
will not run. The approved revision 12 **selects the remedy**: R-b +
|
||||||
|
R-d via one checked-in helper,
|
||||||
|
`scripts/check-sigint-deliverable`. Its preserved-status inner probe
|
||||||
|
maps to one complete ABI: helper exit **0** = `safe`, **1** =
|
||||||
|
`ignored`, **2** = probe `error`; the helper owns the two failure
|
||||||
|
diagnostics, and both consumers surface its stderr rather than
|
||||||
|
interpreting raw probe statuses. Inability to execute the helper is
|
||||||
|
`error`, never evidence of `SIG_IGN`. POSIX shell only: no `/proc`,
|
||||||
|
so not Linux-only; no `sigaction`, so no `unsafe`. `scripts/gate`
|
||||||
|
fails immediately with the explicit diagnosis; the target test
|
||||||
|
reports the same precondition failure if run directly;
|
||||||
|
**no override**, because a gate under ignored `SIGINT` cannot produce
|
||||||
|
valid evidence. R-c rejected. The Linux-only D1/D2 instrumentation is
|
||||||
|
removed once its evidence is portable. A1–A7 witness guard bite,
|
||||||
|
direct-test diagnosis, unaffected foreground success, mutation, an
|
||||||
|
otherwise unchanged gate, a distinct error outcome in **both**
|
||||||
|
consumers, and qualified non-Linux-unix portability.
|
||||||
|
- **IMPLEMENTED.** `scripts/check-sigint-deliverable` is the shared
|
||||||
|
helper (0 safe / 1 ignored / 2 error); `scripts/gate` refuses before
|
||||||
|
any stage; the target test reports the precondition instead of the 5s
|
||||||
|
deadline; the Linux-only `/proc` instrument is removed.
|
||||||
|
- **Two bugs shipped in the first guard, both caught in review.** A bare
|
||||||
|
invocation under `set -eu` killed the shell at the helper's non-zero
|
||||||
|
exit, so the refusal never printed. Replacing it with
|
||||||
|
`if ! helper; then status=$?` captured the status of the **negated
|
||||||
|
condition** — always 0 — so the gate printed the diagnosis and then
|
||||||
|
ran the whole suite anyway. The working shape is
|
||||||
|
`helper || status=$?`, the idiom the helper uses internally. The
|
||||||
|
guard also moved to immediately after the worktree resolves, so a
|
||||||
|
refused run leaves no log dir, ambient root or tmpdir behind.
|
||||||
|
- **Four durable rows in `gate_script_acceptance`** (31, was 27):
|
||||||
|
helper safe / ignored / error, and gate refusal before stage 1.
|
||||||
|
**Verified to bite** — mutating the gate back to either shipped bug
|
||||||
|
fails `gate_refuses_to_start_when_sigint_is_ignored` and nothing
|
||||||
|
else. Their absence is why 27 passing tests missed both.
|
||||||
|
- **A7 — revision-12 HISTORY, superseded.** It then read: satisfied by
|
||||||
|
disclosure, Linux `x86_64` only, no non-Linux unix reachable. **That
|
||||||
|
is no longer true** — macOS CI reached it and measured it red. The
|
||||||
|
live record is the CI entry above: five of six helper/gate rows pass
|
||||||
|
on macOS, one defect, R-d Linux-only because its test is crdt-gated.
|
||||||
|
- **D0a EXECUTED 2026-08-19 — verdict: difference NOT captured by the
|
||||||
|
two commits.** 10 runs, counterbalanced, N=5 per endpoint, clean
|
||||||
|
detached worktrees with isolated target dirs, `dirty=0` per run, zero
|
||||||
|
voids, zero splits. **A (`7599661`) uniform-red; B (`724b785`)
|
||||||
|
uniform-red.** So `7599661`, which passed inside `sweep-crdt` on
|
||||||
|
08-15, fails 5/5 clean today: **the two commits do not discriminate
|
||||||
|
under current conditions, so no bisect will run.** That is the whole
|
||||||
|
claim — "source hypothesis eliminated" and "unreachable by source"
|
||||||
|
are **withdrawn**, since a historical regression could be masked by a
|
||||||
|
later environmental effect or a source/environment interaction.
|
||||||
|
Failing to discriminate is not the same as not differing. The onset
|
||||||
|
window is deprioritised, not excluded. No package activity in the
|
||||||
|
window (`pacman.log`) — a cheap negative, not pursued further.
|
||||||
|
- **The useful product is a RELIABLE REPRODUCTION** — 10/10 across two
|
||||||
|
commits at ~4 min/run — which is what let D1/D2 run at once. **D1/D2
|
||||||
|
are DONE**; the mechanism entry above supersedes this. Per-run provenance in `docs/probe-sigint-evidence.md`
|
||||||
|
§D0a, transcribed in full into that committed document — after the
|
||||||
|
first transcription corrupted every log digest by one hex character
|
||||||
|
and dropped `/tmp` and `MemAvailable`. **`uptime` was never captured**
|
||||||
|
and is `UNKNOWN` for all ten runs: §7 names it, the harness kept only
|
||||||
|
the load averages, so that condition list was **not fully
|
||||||
|
satisfied**. The classifications stand; D1/D2's harness must capture
|
||||||
|
the whole list.
|
||||||
|
- **Why it exists.** `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`
|
||||||
|
fails in gate stage `sweep-crdt` with "child did not exit within 5s".
|
||||||
|
**Pre-existing on `main`** — `72da24a` fails it in a clean worktree
|
||||||
|
with its own target dir. The correct count is **119 green result
|
||||||
|
summaries and TWO red binaries**: `gpu_initial_target_acceptance`
|
||||||
|
includes the suite as a module, so a reproducing sweep reds twice
|
||||||
|
(log `…-2144707/09-sweep-crdt.log:3097` and `:3131`). While it reds,
|
||||||
|
**no branch can present a green sixteen-stage gate, `main`
|
||||||
|
included.**
|
||||||
|
- **`panel-mapping-generation` (§5b) is HELD BEHIND THIS LANE** by
|
||||||
|
explicit instruction. That lane is code-complete at `5174f73` with
|
||||||
|
its own fifteen stages green; its sixteenth stage is this defect.
|
||||||
|
- **Reproduction is 7/7 across full sweeps (F1–F7); every reduction
|
||||||
|
R1–R10 is green.** Each run is enumerated with exact command, worktree, HEAD,
|
||||||
|
cleanliness and log digest in `docs/probe-sigint-evidence.md` — "0/N"
|
||||||
|
is not a record.
|
||||||
|
- **But R9 did NOT run the same compilations as the sweep.** It
|
||||||
|
executed `…-91f51d0b…` / `…-6b4b8223…`; the sweeps executed
|
||||||
|
`…-5d9105cb…` / `…-d4dae4f0…`. **Differing Cargo suffixes mean
|
||||||
|
differing metadata hashes — different compilations.** Historical byte
|
||||||
|
identity is **UNKNOWN** and is never claimed: target dirs have been
|
||||||
|
overwritten, so a hash computed today is the current occupant's. R9
|
||||||
|
establishes **same target names and order**, not same compilations. What the evidence is **consistent
|
||||||
|
with**, not what it isolates: prior targets alone (R9) green,
|
||||||
|
workspace selection alone (R10) green, both together (F1–F7) red.
|
||||||
|
That is an observation, not a finding. `--workspace` selection
|
||||||
|
is **not sufficient by itself and not ruled out** — later-selected
|
||||||
|
packages can affect the build graph before their tests ever run, so
|
||||||
|
"their targets execute after the failure" does not exonerate them.
|
||||||
|
- **Ruled out by measurement — do not re-run:** machine load; tmpfs
|
||||||
|
starving RAM (settled by experiment, not argument — `/tmp` 21G→1.2G,
|
||||||
|
available 27G→45G, still red); leaked daemons; inotify.
|
||||||
|
- **NOT ruled out, contrary to earlier entries here:** `--workspace`
|
||||||
|
artifact selection, and the preceding tests. R9 appeared to clear
|
||||||
|
them but ran **different Cargo compilations**, so the comparison was
|
||||||
|
never made. Both are open.
|
||||||
|
- **Ground truth, and what it does NOT establish.** `run_gpu`'s own
|
||||||
|
path installs no handler: `run_gpu` (`src/main.rs:324`) blocks
|
||||||
|
in `command.status()` with no handler, and grepping all of
|
||||||
|
`pmacs-gpu/src` for signal machinery returns nothing. **But the
|
||||||
|
`pmacs` binary DOES contain signal machinery** —
|
||||||
|
`install_signal_handlers` (`src/daemon.rs:628`) registers `SIGINT`
|
||||||
|
and `SIGTERM`; it is simply not on `run_gpu`'s path. A source grep
|
||||||
|
also cannot exclude a runtime or dependency installing a disposition.
|
||||||
|
So the established fact is only: **no explicit installation on
|
||||||
|
`run_gpu`'s path**, and "whatever disposition they hold was
|
||||||
|
inherited" stays a **hypothesis** until D2 measures it. The probe's
|
||||||
|
**event loop** wakes at least every 50ms
|
||||||
|
(`pmacs-gpu/src/main.rs:1065`) — but the process is **not** bounded:
|
||||||
|
its stdin reader blocks in `read_to_end` (`:1109`) and, once ready,
|
||||||
|
the loop leaves only when stdin closes (`:1212`).
|
||||||
|
**No claim is made that either process holds the DEFAULT
|
||||||
|
disposition** — absence of handler code cannot establish that, and
|
||||||
|
inherited ignore is the leading hypothesis precisely because the
|
||||||
|
source is silent.
|
||||||
|
- **TWO retracted claims, both mine.** (a) "Mechanism located" —
|
||||||
|
launcher in `do_wait`, probe child in `futex_do_wait`. (b) The
|
||||||
|
retraction of (a), which argued the failing launcher "must live ≥8s".
|
||||||
|
**(b)'s arithmetic is false**: both reproducing binaries finish in
|
||||||
|
~5.19s *including* the 5s timeout, so the failing launcher lives
|
||||||
|
about **5.1s** — inside what the sampler saw, and a ">6s" selector
|
||||||
|
would have captured nothing, repeating the error it was meant to
|
||||||
|
correct. (a) is therefore not refuted by (b); it stays **unproven for
|
||||||
|
a different reason** — under `--features crdt` the suite spawns root
|
||||||
|
launchers from **six** call sites, so command line alone cannot
|
||||||
|
attribute one to this test. The six (`:509, :534, :544,
|
||||||
|
:574, :725, :1097`, all inside `#[cfg(feature = "crdt")] mod crdt`);
|
||||||
|
the other two `--gpu` arguments sit under `#[cfg(not(…))]` and are
|
||||||
|
compiled out. **Do not key on process age. Key on the PID the test
|
||||||
|
records.**
|
||||||
|
- **Diagnostics must DISCRIMINATE** blocked delivery, inherited ignore,
|
||||||
|
and an escaped process group: snapshots **before and after** the
|
||||||
|
signal, for test parent / launcher / probe; **per-thread** `SigBlk`
|
||||||
|
from `/proc/<pid>/task/*/status`; `SigPnd`/`ShdPnd`; and
|
||||||
|
`PID`/`PPID`/`PGID`/`SID`. A post-failure snapshot cannot prove
|
||||||
|
inheritance.
|
||||||
|
- **Why that matters:** `SIG_IGN` is inherited across `fork` and
|
||||||
|
survives `exec`, while handlers do not. So a runtime disposition can
|
||||||
|
arrive from the test harness, `cargo`, or the invoking shell without
|
||||||
|
appearing anywhere in the source. Revision 1's "two processes with
|
||||||
|
default disposition" contradicted its own hypothesis and is
|
||||||
|
withdrawn; the assertion no longer appears above it either.
|
||||||
|
- **Run provenance is a pushed document**, `docs/probe-sigint-evidence.md`:
|
||||||
|
exact command, worktree, HEAD, cleanliness, the Cargo suffixes
|
||||||
|
executed, result and log digest per physical run. Three caveats stated there rather
|
||||||
|
than smoothed over — **R1 and R2 have no preserved log** (revision 2
|
||||||
|
double-counted one log as both R2 and R6), **cleanliness is UNKNOWN**
|
||||||
|
for every pre-manifest run, and **R1–R10 ran in the
|
||||||
|
`panel-mapping-generation` worktree**, not at `main`. Log bodies are
|
||||||
|
machine-local under
|
||||||
|
`/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/`; `/tmp`
|
||||||
|
is a tmpfs and they were nearly lost to a cleanup mid-lane.
|
||||||
|
- **THE ONSET IS DATABLE, and it reframes the lane.** `sweep-crdt`
|
||||||
|
has **17** logs here. Counted per test copy: **13** with both copies
|
||||||
|
`... ok`, **1** where neither executed (stage died compiling `pmacs`,
|
||||||
|
`error[E0308]`, `…-708693`), **3** with both `FAILED`. Last observed
|
||||||
|
green `20260815T185708Z`, first observed red `20260816T063330Z`; boot
|
||||||
|
began 08-14 09:30, so no reboot between. "Pre-existing on `main`"
|
||||||
|
holds (F1 at `72da24a`) but **"always broken" is contradicted**.
|
||||||
|
- **The onset is NOT a source boundary.** Reflog/commit times put HEAD
|
||||||
|
at `7599661` during the last green (`3c06176` landed 40s after it
|
||||||
|
finished) and `724b785` during the first red (`5174f73` landed
|
||||||
|
08:45:41, after that run ended 08:42:01). **Cleanliness captured for
|
||||||
|
neither.** `72da24a` is an **ancestor** of `7599661` yet fails today
|
||||||
|
while `7599661` passed on 08-15 — but those two observations differ
|
||||||
|
in commit AND environment AND time, so they are **non-comparable and
|
||||||
|
support no causal conclusion of any kind**. Earlier wordings here
|
||||||
|
("no source-monotonic cause does that", "outcome is not determined by
|
||||||
|
commit alone") are both **withdrawn**: different commits can
|
||||||
|
deterministically produce different outcomes, so the pair says
|
||||||
|
nothing about determinism either.
|
||||||
|
- **D0a is a decision procedure with no predicted outcome**, and one
|
||||||
|
run per endpoint decides nothing. That the failure has appeared only
|
||||||
|
in the full sweep is **what has been observed so far**, not a
|
||||||
|
property established of the defect.
|
||||||
|
**N = 5 full `sweep-crdt` runs per endpoint, counterbalanced
|
||||||
|
`AB BA AB BA AB`** — not strict `A/B/A/B`, which leaves B always
|
||||||
|
following A and owning the final slot; counterbalancing removes
|
||||||
|
systematic order confounding, and the residual last-slot asymmetry is
|
||||||
|
accepted and stated. Same captured conditions as D0b plus `uptime`,
|
||||||
|
`free`, `/tmp` usage and leaked-daemon count.
|
||||||
|
- **The run classifier is TOTAL**, and reads only the **two copies** of
|
||||||
|
the target test: **green** (both `... ok`), **red** (both `FAILED`),
|
||||||
|
**split** (copies disagree → stop; that is its own defect), **void**
|
||||||
|
(either copy never executed → discard and re-run, budget 3, then
|
||||||
|
D0a stops). A sweep red only on **unrelated** rows is a `green` run;
|
||||||
|
both outcomes occur in the historical logs — `…-708693` is a void
|
||||||
|
(compile failure), and `…-2839374`/`…-830195` are unrelated-red with
|
||||||
|
both copies passing. A bisect of `7599661..724b785` is permitted
|
||||||
|
**only on the expected-direction clean split** — all N green at
|
||||||
|
`7599661`, all N red at `724b785`. The inverted split is a real
|
||||||
|
difference but contradicts the onset reading, so it is recorded and
|
||||||
|
that reading is re-examined before any bisect. A mixed result means
|
||||||
|
the failure is intermittent under fixed source and **no bisect is
|
||||||
|
justified**.
|
||||||
|
- **Red full-sweep count is SEVEN, not five** (F1–F7 in the manifest),
|
||||||
|
each with its own log digest; revision 3 said 5/5 while the framing
|
||||||
|
separately cited a gate run the manifest never listed.
|
||||||
|
- **D0 precedes every other diagnostic**, in two parts: **(a)
|
||||||
|
reproduce the onset endpoints `7599661` and `724b785` clean, in
|
||||||
|
isolated target dirs**, under the N = 5 interleaved clean-split
|
||||||
|
contract below — a bare difference decides nothing. **(a) is DONE.**
|
||||||
|
**(b) is RETIRED as a precondition** (2026-08-19) because D0a yielded
|
||||||
|
a reliable direct reproduction and D1/D2 measure the mechanism
|
||||||
|
itself. **Its obligation is now SATISFIED** by §4c's controlled
|
||||||
|
explanation of the subset/full difference, so **D0b is not owed and
|
||||||
|
will not run**. As originally written it said: its obligation
|
||||||
|
survives under A3 — if D1/D2 do not
|
||||||
|
account for the subset-vs-full difference, D0b runs before the lane
|
||||||
|
closes. It read: re-run
|
||||||
|
the matrix at `main` under a harness capturing provenance **and the
|
||||||
|
artifact hashes executed at run time**, since command shape silently
|
||||||
|
changed the binary once already and a hash computed later reflects
|
||||||
|
only what occupies that path now.
|
||||||
|
- **Coherence: journey steps touched: NONE** (framing §9). Earlier
|
||||||
|
entries here assigned 12(a) "closing is clean" on the premise that
|
||||||
|
the lane repairs Ctrl-C teardown; §4c withdraws that premise, because
|
||||||
|
no product behaviour changes. What the lane affects is **gate
|
||||||
|
trustworthiness**.
|
||||||
|
- **Gates:** `./scripts/gate --protocol --acceptance
|
||||||
|
gpu_invocation_acceptance` at minimum. **The old three-consecutive-
|
||||||
|
run A2 contract is SUPERSEDED** — it was written for a teardown fix
|
||||||
|
whose flakiness was unexplained. The mechanism is now known and
|
||||||
|
deterministic, so the acceptance set is framing §8's A1–A7: guard
|
||||||
|
bite, direct-test diagnosis, unaffected foreground success, mutation,
|
||||||
|
an otherwise unchanged gate, a distinct `error` outcome, and a
|
||||||
|
non-Linux-unix statement.
|
||||||
|
|
||||||
## `scripts/gate` TMPDIR isolation — PR #240 OPEN
|
## `scripts/gate` TMPDIR isolation — PR #240 OPEN
|
||||||
|
|
||||||
**Written with the branch's first commit**, per the standing correction
|
**Written with the branch's first commit**, per the standing correction
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,454 @@
|
||||||
|
# GPU probe SIGINT lane — run manifest
|
||||||
|
|
||||||
|
Every physical run behind
|
||||||
|
`docs/gpu-probe-sigint-framing.md` §4. Pushed so the evidence travels;
|
||||||
|
the log bodies stay machine-local under
|
||||||
|
`/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/` and are
|
||||||
|
identified here by SHA-256 prefix and byte count.
|
||||||
|
|
||||||
|
## Provenance honesty
|
||||||
|
|
||||||
|
These runs were made **before** this manifest existed, so their
|
||||||
|
provenance is **reconstructed, not captured**. Specifically:
|
||||||
|
|
||||||
|
- **Commands** are exact and complete argv. Revision 3 abbreviated
|
||||||
|
R7–R9 as "`--test` ×N (targets 6–37)" and F5 as "`--acceptance` ×6",
|
||||||
|
which are descriptions, not reconstructable invocations. They are
|
||||||
|
written out in full below.
|
||||||
|
- **Worktree** is exact.
|
||||||
|
- **HEAD** is given as a range where the run cannot be pinned to one
|
||||||
|
commit, and marked `~`. It is never guessed at single-commit
|
||||||
|
precision.
|
||||||
|
- **Cleanliness** was not recorded at the time and is therefore
|
||||||
|
`UNKNOWN` for every pre-manifest run. It is not inferred.
|
||||||
|
|
||||||
|
**D0 (below) exists because of this.** No conclusion in §4 should rest
|
||||||
|
on a `UNKNOWN`-cleanliness row once D0 has replaced it.
|
||||||
|
|
||||||
|
## Artifact identity — suffix is NOT byte identity
|
||||||
|
|
||||||
|
Two distinct things were conflated in revision 3 and are separated here.
|
||||||
|
|
||||||
|
**Cargo suffix** is recorded in each log and is therefore *known* per
|
||||||
|
run. **Byte identity** is not: target directories have been overwritten
|
||||||
|
many times since, so a hash computed today is the hash of whatever
|
||||||
|
occupies that path now, not of what a given run executed.
|
||||||
|
|
||||||
|
That the two differ is demonstrated, not assumed. F1 ran in the `main`
|
||||||
|
worktree (`pmacs-fdccc423`) and executed suffixes `-5d9105cb7047aab8`
|
||||||
|
and `-d4dae4f01bcdef62` — **the same suffixes** as the panel-worktree
|
||||||
|
sweeps — yet the bytes at those paths differ by worktree:
|
||||||
|
|
||||||
|
| worktree | `gpu_initial_target…-5d9105cb` | `gpu_invocation…-d4dae4f0` |
|
||||||
|
|---|---|---|
|
||||||
|
| `pmacs-fdccc423` (main) | `e057803988c34cf7` | `00f06aeb089ce38d` |
|
||||||
|
| `pmacs-mapping-gen-…` (§5b) | `1b3cc86cbb8d6092` | `ede0c07dd9abb456` |
|
||||||
|
|
||||||
|
Same suffix, different source head, different bytes **today**. So
|
||||||
|
**"workspace artifact family" was not an identity class** and is
|
||||||
|
withdrawn as a grouping.
|
||||||
|
|
||||||
|
Three levels of knowledge, kept apart:
|
||||||
|
|
||||||
|
0. **Portability caveat.** Suffixes below are read from logs that are
|
||||||
|
**machine-local**; this manifest is the portable record of them, and
|
||||||
|
a reader elsewhere is trusting this transcription, not verifying it.
|
||||||
|
Rows that were never logged say `UNKNOWN` and are not guessed.
|
||||||
|
1. **Suffix — known**, from each log. A differing suffix means Cargo
|
||||||
|
computed a different metadata hash, i.e. it treated the two as
|
||||||
|
distinct compilations.
|
||||||
|
2. **Today's bytes at a path — known**, and shown above.
|
||||||
|
3. **The bytes a historical run executed — UNKNOWN.** Target
|
||||||
|
directories have been overwritten repeatedly since; a hash computed
|
||||||
|
now is the hash of the current occupant.
|
||||||
|
|
||||||
|
So statements of the form "R9 and the sweeps ran byte-different
|
||||||
|
binaries" are **withdrawn everywhere**. What is established is that
|
||||||
|
they ran **different Cargo compilations** (different suffixes), which
|
||||||
|
is enough to void the comparison and is all that is claimed. R1 and R2
|
||||||
|
have no preserved log and cannot claim even a suffix.
|
||||||
|
|
||||||
|
## Runs
|
||||||
|
|
||||||
|
All in worktree `pmacs-mapping-gen` unless stated. `WT=mg` is
|
||||||
|
`/home/jeans/Repos/personal/pmacs-mapping-gen`; `WT=main` is
|
||||||
|
`/home/jeans/Repos/personal/pmacs` at `72da24a`. All carry
|
||||||
|
`CARGO_TARGET_DIR=/home/jeans/build/pmacs-gate-targets/pmacs-mapping-gen-8cb089c8`
|
||||||
|
except `WT=main`, which uses `…/pmacs-fdccc423`.
|
||||||
|
|
||||||
|
| id | exact command (after `cargo`) | WT | HEAD | clean | Cargo suffixes executed | result | log (sha256/16, bytes) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| R1 | `test --features crdt --test gpu_invocation_acceptance ctrl_c_on_launcher_group` ×3 | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 0.15–0.17 s | **none preserved** |
|
||||||
|
| R2 | `test --features crdt --test gpu_invocation_acceptance` | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 15 passed | **none preserved** |
|
||||||
|
| R3 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright ctrl_c_on_launcher_group` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | green | `e09a96512035284e` 33113 |
|
||||||
|
| R4 | `test --features crdt --lib --test gpu_invocation_acceptance --no-fail-fast` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-6b4b8223` only | green, 2145 + 15 | `89050c702de22d57` 158812 |
|
||||||
|
| R5 | `test --features crdt --no-fail-fast --test gate_script_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-6b4b8223` only | green | `31b3e5249b475479` 3706 |
|
||||||
|
| R6 | `test --features crdt --no-fail-fast --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` | green, 11+15+15 | `332693a39c73731a` 4569 |
|
||||||
|
| R7 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `gpu_invocation…-6b4b8223` **only** — R7 does not select `gpu_initial_target` | green | `9e1ebc59ed9f0dd4` 187531 |
|
||||||
|
| R8 | `test --features crdt --no-fail-fast --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` (`half2.log:438`, `:459`) | green | `8b26ebfcf5f871b4` 28677 |
|
||||||
|
| R9 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | **`-91f51d0b` / `-6b4b8223`** (log `:3066`, `:3087`) | green | `b31d98ee2f427eca` 214566 |
|
||||||
|
| R10 | `test --workspace --features crdt --no-fail-fast --test gpu_initial_target_acceptance --test gpu_invocation_acceptance -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | **`-5d9105cb` AND `-d4dae4f0`** (log `:3`, `:24`) | green | `81b48fd7a0e261dc` 3553 |
|
||||||
|
| F1 | `build --workspace --no-default-features --features luajit,crdt && test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | **main** | `72da24a` | clean (verified `git status --porcelain` empty) | `-5d9105cb` / `-d4dae4f0`; today's occupants `e0578039…` / `00f06aeb…` | **red** | `10b55b8ba8741125` 334446 |
|
||||||
|
| F2 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`postclean.log`) | **red** | `474f88f0dad581fe` 338555 |
|
||||||
|
| F3 | same argv as F2, with a resource sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-inst.log`) | **red** | `7b8519e7300e8bb3` 338555 |
|
||||||
|
| F4 | same argv as F2, with a process-table sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-diag.log`) | **red** | `5ccdefc5d89eece3` 338555 |
|
||||||
|
| F5 | stage 15 of `./scripts/gate --protocol --acceptance bottom_panel_stage1_acceptance --acceptance bottom_panel_stage2a_acceptance --acceptance bottom_panel_stage2b_daemon_acceptance --acceptance bottom_panel_stage2b_gpu_acceptance --acceptance bottom_panel_stage2b_protocol_acceptance --acceptance gui_stage1a_wire_acceptance` | mg | `5174f73` + uncommitted docs | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (3 bins: both ctrl_c copies + `m6_1_pty_mode_lifecycle`) | `20260817T172537Z-2375685/15-sweep-crdt.log`, sha `e5bdc911e366` |
|
||||||
|
| F6 | stage 9 of `./scripts/gate --protocol` | mg | **`724b785`** (see below) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T063330Z-1977433/09-sweep-crdt.log`, sha `7a75d999ac4f` |
|
||||||
|
| F7 | stage 9 of `./scripts/gate --protocol` | mg | `5174f73` (committed 08:45:41, run began 08:45:49) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T064549Z-2144707/09-sweep-crdt.log`, sha `9d3c6ad1bfc9` |
|
||||||
|
|
||||||
|
Supporting, not a reduction: `9b8a01076b44bb7c` 98838
|
||||||
|
(`proc-sample.log`) is the process-table sampler output behind the
|
||||||
|
retracted "mechanism located" claim.
|
||||||
|
|
||||||
|
**The red count is 7, not 5.** Revision 3 said "F1–F5, red 5/5" while
|
||||||
|
the framing separately cited gate `…-2144707`, a *different* physical
|
||||||
|
run the manifest never listed. Both exist, both are red, and there is a
|
||||||
|
third gate run too. F1–F7 above enumerate all seven, each with its own
|
||||||
|
log digest. F5 also has an extra failing binary
|
||||||
|
(`m6_1_pty_mode_lifecycle_started_then_exited`) that F6/F7 do not.
|
||||||
|
|
||||||
|
**R2 and R6 are distinct runs.** Revision 2's §4 cited `gpu3.log` for
|
||||||
|
both; that log is R6's three-suite run only, and R2's log was never
|
||||||
|
preserved. R1 likewise has no log. Both are marked accordingly rather
|
||||||
|
than backfilled.
|
||||||
|
|
||||||
|
## The onset is datable — and this supersedes the reduction matrix
|
||||||
|
|
||||||
|
`sweep-crdt` has 17 log files in this target directory. Counted **per
|
||||||
|
test copy**, not per stage:
|
||||||
|
|
||||||
|
| outcome | runs |
|
||||||
|
|---|---|
|
||||||
|
| both copies `... ok` | **13** |
|
||||||
|
| **neither copy executed** — the stage died compiling `pmacs` (`error[E0308]: mismatched types`), log `20260815T182846Z-708693` | **1** |
|
||||||
|
| both copies `FAILED` | **3** (`…-1977433`, `…-2144707`, `…-2375685`) |
|
||||||
|
|
||||||
|
Revision 4 said "14 runs, 11 green, 3 red on other tests" and that the
|
||||||
|
earlier reds "failed on unrelated tests". **Both were wrong**: the
|
||||||
|
count is 13 / 1 / 3, and one of the earlier non-passing runs is a
|
||||||
|
**compile failure that never reached either copy**, not a red on
|
||||||
|
another test. Of the genuinely red-on-other-tests sweeps, `…-2839374`
|
||||||
|
(08-15 09:55) failed protocol and version rows and `…-830195`
|
||||||
|
(08-15 18:37) failed `composition_overhead_under_ten_percent` and a
|
||||||
|
v21/v20 row — those two did execute `ctrl_c`, and it passed.
|
||||||
|
|
||||||
|
So the failure is **not long-standing**. Last observed green:
|
||||||
|
`20260815T185708Z`. First observed red: `20260816T063330Z`. The machine
|
||||||
|
was not rebooted across it — the current boot began 08-14 09:30.
|
||||||
|
|
||||||
|
**But this is not yet a source boundary.** Reflog and commit times put
|
||||||
|
HEAD at `7599661` during the last green (`3c06176` was committed 40 s
|
||||||
|
*after* that run finished) and at `724b785` during the first red
|
||||||
|
(`5174f73` was committed 08:45:41, after that run ended at 08:42:01 —
|
||||||
|
revision 4's manifest wrongly recorded F6 at `5174f73`). Cleanliness
|
||||||
|
was captured for **neither**, and the tree was being edited throughout.
|
||||||
|
So the window dates a **machine/worktree-state transition**, not two
|
||||||
|
clean source revisions.
|
||||||
|
|
||||||
|
One relationship is worth recording **for exactly what it shows**:
|
||||||
|
`72da24a` is an **ancestor** of `7599661` (`git merge-base
|
||||||
|
--is-ancestor`), yet fails today (F1) while `7599661` passed on 08-15.
|
||||||
|
The two observations differ in commit **and** environment **and**
|
||||||
|
time, so they are **non-comparable and support no causal conclusion of
|
||||||
|
any kind** — not even "outcome is not determined by commit alone",
|
||||||
|
since different commits can deterministically produce different
|
||||||
|
outcomes. The pair does not discriminate an environmental change, a
|
||||||
|
source/environment interaction, or a fix before `7599661` followed by a
|
||||||
|
regression before `724b785`. And an ancestor outside the interval is
|
||||||
|
irrelevant to whether the interval contains a regression.
|
||||||
|
|
||||||
|
## D1/D2 — EXECUTED 2026-08-19. The outer invocation is the variable
|
||||||
|
|
||||||
|
**The causal variable is the OUTER invocation of the test runner**, so
|
||||||
|
it is recorded here as a first-class column. Earlier "exact commands"
|
||||||
|
in this file are incomplete for that reason: they gave the inner
|
||||||
|
`cargo` argv and omitted how the runner itself was started.
|
||||||
|
|
||||||
|
### Controlled arms, committed head, worktree-local target
|
||||||
|
|
||||||
|
Head `77b623c`, `dirty=0`, worktree
|
||||||
|
`/home/jeans/Repos/personal/pmacs-probe-sigint`, target
|
||||||
|
`/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e`,
|
||||||
|
`TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/arms`.
|
||||||
|
|
||||||
|
Inner command, identical in both arms:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo test --features crdt --no-fail-fast \
|
||||||
|
--test gpu_invocation_acceptance --test gpu_initial_target_acceptance \
|
||||||
|
-- ctrl_c_on_launcher_group
|
||||||
|
```
|
||||||
|
|
||||||
|
The **actual evidence-producing outer invocations** were:
|
||||||
|
|
||||||
|
```
|
||||||
|
# fg arm
|
||||||
|
/home/jeans/build/pmacs-gate-targets/d0a/arms2.sh fg
|
||||||
|
|
||||||
|
# bg arm
|
||||||
|
setsid nohup /home/jeans/build/pmacs-gate-targets/d0a/arms2.sh bg \
|
||||||
|
> /dev/null 2>&1 & disown
|
||||||
|
```
|
||||||
|
|
||||||
|
`arms2.sh` is machine-local, so naming it is not portable provenance by
|
||||||
|
itself. This is the complete body that produced `arms2.tsv`; it records
|
||||||
|
the inner `cargo` exit rather than the status of the disowned outer job:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
# D1/D2 controlled arms, v2: full binary SHA-256 captured PER RUN,
|
||||||
|
# immediately after the run, before anything can rebuild them.
|
||||||
|
set -u
|
||||||
|
WT=/home/jeans/Repos/personal/pmacs-probe-sigint
|
||||||
|
TD=/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e
|
||||||
|
TMP=/home/jeans/build/pmacs-gate-targets/tmp/arms; mkdir -p "$TMP"
|
||||||
|
OUT=/home/jeans/build/pmacs-gate-targets/d0a
|
||||||
|
ARM="$1"; LOG="$OUT/arm2-$ARM.log"
|
||||||
|
HEAD=$(git -C "$WT" rev-parse HEAD); DIRTY=$(git -C "$WT" status --porcelain | wc -l)
|
||||||
|
env TMPDIR="$TMP" CARGO_TARGET_DIR="$TD" sh -c \
|
||||||
|
"cd $WT && cargo test --features crdt --no-fail-fast --test gpu_invocation_acceptance --test gpu_initial_target_acceptance -- ctrl_c_on_launcher_group" \
|
||||||
|
> "$LOG" 2>&1
|
||||||
|
EXIT=$?
|
||||||
|
OK=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. ok" "$LOG")
|
||||||
|
BAD=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. FAILED" "$LOG")
|
||||||
|
SIG=$(grep -m1 -o "SigIgn=[0-9a-f]*" "$LOG" || echo "SigIgn=not-captured")
|
||||||
|
# Full digests of exactly the binaries this run executed, taken now.
|
||||||
|
HASHES=""
|
||||||
|
for b in $(grep -E "Running tests/gpu_" "$LOG" | sed 's|.*deps/||; s|)||'); do
|
||||||
|
HASHES="$HASHES $b=$(sha256sum "$TD/debug/deps/$b" | cut -d' ' -f1)"
|
||||||
|
done
|
||||||
|
printf '%s\tarm=%s\thead=%s\tdirty=%s\texit=%s\tok=%s\tfailed=%s\t%s\tlogsha=%s\thashes=%s\n' \
|
||||||
|
"$(date -Iseconds)" "$ARM" "$(echo $HEAD | cut -c1-7)" "$DIRTY" "$EXIT" "$OK" "$BAD" "$SIG" \
|
||||||
|
"$(sha256sum "$LOG" | cut -d' ' -f1)" "$HASHES" >> "$OUT/arms2.tsv"
|
||||||
|
echo "arm2 $ARM: exit=$EXIT ok=$OK failed=$BAD $SIG"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result — head `77b623c`, `dirty=0`, digests captured PER RUN
|
||||||
|
|
||||||
|
| arm | outer | cargo exit | ok | failed | `SigIgn` | log sha256 (full) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| fg | foreground | 0 | 2 | 0 | not captured (no failure ⇒ no dump) | `b6117619242f3dabadbe5826f59f11cef451cd09a2beb304e9a3dec1a4616465` |
|
||||||
|
| bg | `setsid nohup … &` | 101 | 0 | 2 | `0000000000001007` | `30ac6568e345c485ddf23734a239d71bd06d4a3c2d549f1b4a8942a930527507` |
|
||||||
|
|
||||||
|
Binary digests, **full SHA-256, taken immediately after each run before
|
||||||
|
anything could rebuild them** — not read later from a reused path:
|
||||||
|
|
||||||
|
| binary | fg arm | bg arm |
|
||||||
|
|---|---|---|
|
||||||
|
| `gpu_initial_target_acceptance-91f51d0b5303ff9f` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` |
|
||||||
|
| `gpu_invocation_acceptance-6b4b8223dea45247` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` |
|
||||||
|
|
||||||
|
**These are byte-identical, and that claim is now carried by the
|
||||||
|
capture rather than by inference.** The earlier arms table recorded only
|
||||||
|
16-character prefixes at run time and its full values were read
|
||||||
|
afterwards from reused paths — which is exactly the provenance rule
|
||||||
|
§7/D0 states, applied against my own record. Those rows are superseded
|
||||||
|
by the table above. The raw `arms.tsv` and `arms2.tsv` remain
|
||||||
|
machine-local; the literal harness and full rows above are their
|
||||||
|
portable transcription.
|
||||||
|
|
||||||
|
### Disposition — UNRECORDED CORROBORATION, not a controlled arm
|
||||||
|
|
||||||
|
This table was read ad hoc from `/proc/self/status` in the two shells
|
||||||
|
and **its runs were not captured**: no head, no cleanliness, no log,
|
||||||
|
no digest. It agrees with the arms above and with §4c's capture, and it
|
||||||
|
is labelled separately for that reason — it corroborates, it does not
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
| context | child `SigIgn` | `SIGINT` |
|
||||||
|
|---|---|---|
|
||||||
|
| foreground | `0000000000001000` | bit 12 (SIGPIPE) only — deliverable |
|
||||||
|
| `setsid nohup … &` | `0000000000000007` | SIGHUP, SIGINT, SIGQUIT — ignored |
|
||||||
|
|
||||||
|
The raw behavioural probe underlying the selected helper (framing §7c)
|
||||||
|
supersedes it as the *recorded* mechanism check:
|
||||||
|
`sh -c 'trap "exit 23" 2; kill -INT $$; exit 0'` exits **23** when
|
||||||
|
`SIGINT` is deliverable and **0** when it is inherited as ignored.
|
||||||
|
Verified in both contexts. It is **not itself the remedy** because it
|
||||||
|
does not preserve `kill` failure; §7c specifies the total checked-in
|
||||||
|
helper and its 0/1/2 consumer ABI.
|
||||||
|
|
||||||
|
### The first D1/D2 capture, and why it is superseded
|
||||||
|
|
||||||
|
The capture quoted in framing §4c came from `d12.log`, which finished
|
||||||
|
14:10 — **five minutes before `afe3631` committed the diagnostic
|
||||||
|
code** — and ran in the reused `d0a-B` target directory rather than
|
||||||
|
this worktree's. Its signal facts agree with the arms above, but it is
|
||||||
|
**not admissible provenance**: uncommitted tree, foreign target. The
|
||||||
|
arms table replaces it, and `d12.log` is retained only as the first
|
||||||
|
sighting.
|
||||||
|
|
||||||
|
### Historical foreground/background mapping — RECONSTRUCTED
|
||||||
|
|
||||||
|
The claim that "every reduction was foreground and every full sweep was
|
||||||
|
backgrounded" is **reconstructed from this session's transcript, not
|
||||||
|
captured at run time**. No run before today recorded its outer
|
||||||
|
invocation, because none of the harnesses knew it mattered. It is
|
||||||
|
consistent with every observation and with the two arms above, but it
|
||||||
|
is inference, and rows R1–R10 and F1–F7 carry **no outer-invocation
|
||||||
|
field**. That gap is the direct cause of nine revisions spent on a
|
||||||
|
confounded matrix.
|
||||||
|
|
||||||
|
## D0a — EXECUTED 2026-08-19. Verdict: difference NOT captured
|
||||||
|
|
||||||
|
Ten runs, counterbalanced `A B B A A B B A A B`, N = 5 per endpoint,
|
||||||
|
**zero voids, zero splits**. Endpoints checked out detached and clean
|
||||||
|
in dedicated worktrees (`pmacs-d0a-A`, `pmacs-d0a-B`), each with its own
|
||||||
|
target directory, each run performing the gate's `build-crdt`
|
||||||
|
precondition then the `sweep-crdt` command. `dirty=0` verified per run.
|
||||||
|
|
||||||
|
| run | endpoint | HEAD | class | ctrl_c ok/failed | red bins | log |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| A#1 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-1.log` |
|
||||||
|
| B#1 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-1.log` |
|
||||||
|
| B#2 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-2.log` |
|
||||||
|
| A#2 | A | `7599661` | **red** | 0 / 2 | 4 | `d0a/A-2.log` |
|
||||||
|
| A#3 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-3.log` |
|
||||||
|
| B#3 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-3.log` |
|
||||||
|
| B#4 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-4.log` |
|
||||||
|
| A#4 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-4.log` |
|
||||||
|
| A#5 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-5.log` |
|
||||||
|
| B#5 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-5.log` |
|
||||||
|
|
||||||
|
**Exact commands.** Every run, in full. `<WT>` is
|
||||||
|
`/home/jeans/Repos/personal/pmacs-d0a-A` (detached at `7599661`) or
|
||||||
|
`/home/jeans/Repos/personal/pmacs-d0a-B` (detached at `724b785`);
|
||||||
|
`<TD>` is `/home/jeans/build/pmacs-gate-targets/d0a-A` or
|
||||||
|
`/home/jeans/build/pmacs-gate-targets/d0a-B` correspondingly:
|
||||||
|
|
||||||
|
```
|
||||||
|
env TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/d0a \
|
||||||
|
CARGO_TARGET_DIR=<TD> \
|
||||||
|
sh -c 'cd <WT> \
|
||||||
|
&& cargo build --workspace --no-default-features --features luajit,crdt \
|
||||||
|
&& cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-run provenance, transcribed.** All runs: `exit=101`, `dirty=0`,
|
||||||
|
`ok=0 failed=2`, suffixes `-5d9105cb` / `-d4dae4f0`, `/tmp` 3 G of 30 G.
|
||||||
|
Times are local 2026-08-19. `load` is the 1/5/15 average at run start;
|
||||||
|
`MemFree`/`MemAvail` in MB; `daemons` counts live `pmacs --daemon`:
|
||||||
|
|
||||||
|
| start | run | class | red bins | load | MemFree | MemAvail | daemons | log sha256/16 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 12:21:20 | A#1 | red | 3 | 2.51 2.85 3.57 | 1549 | 42069 | 72 | `e1c0fe47d55d8f5e` |
|
||||||
|
| 12:26:55 | B#1 | red | 2 | 7.86 13.48 8.97 | 9405 | 42725 | 76 | `3105794d515e6ec3` |
|
||||||
|
| 12:32:12 | B#2 | red | 2 | 8.04 18.28 13.23 | 9761 | 43370 | 80 | `07a662fb5ca15687` |
|
||||||
|
| 12:35:39 | A#2 | red | 4 | 11.34 21.23 16.20 | 8933 | 43281 | 84 | `450aacb9d15244c9` |
|
||||||
|
| 12:39:35 | A#3 | red | 3 | 13.40 26.07 20.45 | 10628 | 42989 | 88 | `19156bbbc852e2d3` |
|
||||||
|
| 12:43:26 | B#3 | red | 2 | 8.66 22.18 20.82 | 8778 | 43588 | 92 | `6f0d768a76ed9cb0` |
|
||||||
|
| 12:47:20 | B#4 | red | 2 | 17.96 33.10 27.07 | 10403 | 43617 | 96 | `75a043a3d5568598` |
|
||||||
|
| 12:50:49 | A#4 | red | 3 | 10.99 27.07 26.52 | 10702 | 43335 | 100 | `c1b6d88c08764d9a` |
|
||||||
|
| 12:54:47 | A#5 | red | 3 | 9.75 28.50 28.70 | 8494 | 43053 | 104 | `2ae21a3f53fc1a1a` |
|
||||||
|
| 12:58:38 | B#5 | red | 2 | 11.43 26.69 28.97 | 8562 | 43122 | 108 | `82c5baa0c7e49e37` |
|
||||||
|
|
||||||
|
**Two defects in the previous transcription of this table, recorded
|
||||||
|
rather than silently fixed.** Every digest had lost its leading hex
|
||||||
|
character — A#1 read `1c0fe47d55d8f5e…` where the value is
|
||||||
|
`e1c0fe47d55d8f5e` — because the extraction started one byte late in
|
||||||
|
`logsha=<value>`. And `/tmp` and `MemAvailable` were captured by the
|
||||||
|
harness but dropped from the table. A transcription that silently
|
||||||
|
corrupts its own digests is worse than a pointer to the raw file, since
|
||||||
|
it looks verifiable and is not.
|
||||||
|
|
||||||
|
**`uptime` was NOT captured, and is `UNKNOWN` for all ten runs.** §7's
|
||||||
|
condition list names `uptime`; the harness recorded only the load
|
||||||
|
averages from it and discarded the elapsed time. The classifications
|
||||||
|
stand — none of them depends on it — but the condition list was **not
|
||||||
|
fully satisfied**, and D1/D2's harness must capture it. Recorded rather
|
||||||
|
than quietly treated as met.
|
||||||
|
|
||||||
|
Note the leaked-daemon count climbing 72 → 108, four per run. Recorded,
|
||||||
|
not implicated: it rises monotonically while every run classifies the
|
||||||
|
same. Raw logs stay machine-local at
|
||||||
|
`/home/jeans/build/pmacs-gate-targets/d0a/`; the table above is the
|
||||||
|
portable record.
|
||||||
|
|
||||||
|
**Endpoint verdicts: A uniform-red, B uniform-red.** By the approved
|
||||||
|
table this is the *both endpoints uniform the same way* row:
|
||||||
|
|
||||||
|
> **the difference is not captured by those two commits** under current
|
||||||
|
> conditions, and the question becomes what else changed across the
|
||||||
|
> window.
|
||||||
|
|
||||||
|
### What this settles
|
||||||
|
|
||||||
|
- **The two commits do not discriminate under current conditions.**
|
||||||
|
`7599661` passed inside `sweep-crdt` on 08-15 and now fails **5/5**
|
||||||
|
clean. **No bisect of `7599661..724b785` is justified under current
|
||||||
|
conditions**, and none will be run.
|
||||||
|
- **That is the whole of the causal claim.** An earlier wording here
|
||||||
|
said "the source hypothesis is eliminated" and that "the interval
|
||||||
|
cannot contain the transition"; both are **withdrawn**. Uniform-red
|
||||||
|
today is silent about what was true on 08-15 — a historical source
|
||||||
|
regression could be **masked** by a later environmental effect, or by
|
||||||
|
a source/environment interaction that makes both commits fail now.
|
||||||
|
Not discriminating is not the same as not differing.
|
||||||
|
- **The onset window is deprioritised, not excluded.** It remains a
|
||||||
|
true observation, and it remains *possible* that source matters
|
||||||
|
within it; what is established is only that source cannot be
|
||||||
|
probed **by this comparison, now**.
|
||||||
|
- **A reliable reproduction now exists.** 10/10 today, on two different
|
||||||
|
commits, at ~4 minutes per run. **This is the most useful thing D0a
|
||||||
|
produced**: the mechanism diagnostics D1/D2 no longer depend on a
|
||||||
|
rare event, and can proceed immediately.
|
||||||
|
- **A's three extra failures are recorded, not swept up**:
|
||||||
|
`a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`,
|
||||||
|
`one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client`, and
|
||||||
|
`m6_1_pty_mode_lifecycle_started_then_exited`. The v21/v20 row is
|
||||||
|
expected to differ at that older commit; the other two are
|
||||||
|
process/PTY-spawn rows, the same family as the target. They do not
|
||||||
|
affect classification, which reads only the two target copies.
|
||||||
|
|
||||||
|
### What it does not settle
|
||||||
|
|
||||||
|
Nothing about the mechanism. "What else changed across the window" has
|
||||||
|
one cheap negative result so far: **no package activity in the window**
|
||||||
|
(`/var/log/pacman.log` shows nothing between 08-15 19:57 and 08-16
|
||||||
|
06:33; nearest is 08-18). The `1.88` rust toolchain directory has mtime
|
||||||
|
08-15 22:39, inside the window, but the gate builds with `1.95.0`.
|
||||||
|
Neither is pursued further, because with a reliable reproduction in
|
||||||
|
hand **direct measurement (D1/D2) dominates archaeology**.
|
||||||
|
|
||||||
|
## D0 — re-run the matrix with captured provenance
|
||||||
|
|
||||||
|
Before any §4 row is relied on for a conclusion, re-run the reductions
|
||||||
|
under a harness that records, per run and at the time: exact argv and
|
||||||
|
environment, worktree, `git rev-parse HEAD`, `git status --porcelain`
|
||||||
|
emptiness, the artifact hashes actually executed, the result, and the
|
||||||
|
log digest. Two constraints learned the hard way:
|
||||||
|
|
||||||
|
- run them **at `main`**, not on a feature branch — R1–R10 ran in the
|
||||||
|
`panel-mapping-generation` worktree, which carries §5b changes;
|
||||||
|
- record the **artifact hash per run at run time**, since command shape
|
||||||
|
changes it — the reason R9's result did not mean what it appeared to
|
||||||
|
mean — and since a hash computed later reflects only what occupies
|
||||||
|
that path now;
|
||||||
|
- **first, reproduce the two candidate endpoints CLEANLY** —
|
||||||
|
`7599661` (last observed green) and `724b785` (first observed red) —
|
||||||
|
each checked out clean, each in its **own isolated target
|
||||||
|
directory**. A decision procedure with **no predicted outcome**, and
|
||||||
|
**not decided by one run per endpoint**. The framing's §7 D0a holds
|
||||||
|
the governing contract — the total run classifier
|
||||||
|
(green / red / split / void), the void budget, the endpoint table and
|
||||||
|
the bisect-step policy. In summary, keeping the two conclusions with
|
||||||
|
the verdicts they actually belong to:
|
||||||
|
|
||||||
|
- **expected-direction clean split** (`7599661` uniform green,
|
||||||
|
`724b785` uniform red) → a bisect of `7599661..724b785` is
|
||||||
|
permitted;
|
||||||
|
- **inverted clean split** (`7599661` uniform red, `724b785` uniform
|
||||||
|
green) → the commits differ, but the observed direction contradicts
|
||||||
|
the onset reading; record it and re-examine that reading before any
|
||||||
|
bisect;
|
||||||
|
- **mixed at either endpoint** → the failure is **intermittent under
|
||||||
|
fixed source**; no bisect;
|
||||||
|
- **both endpoints uniform the same way**, green or red → **the
|
||||||
|
difference is not captured by those two commits** under current
|
||||||
|
conditions, and the question becomes what else changed across the
|
||||||
|
window.
|
||||||
|
|
||||||
|
Revision 8 attached "the difference is not captured" to the *mixed*
|
||||||
|
clause. That was wrong — mixed means intermittency, not absence of a
|
||||||
|
difference — and revision 9 corrected it in the framing but **left
|
||||||
|
this file untouched**, because the edit's anchor silently missed.
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Is SIGINT deliverable to this process tree?
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. A shell running a command in the background without
|
||||||
|
# job control sets SIGINT (and SIGQUIT) to SIG_IGN in the child; nohup
|
||||||
|
# adds SIGHUP. SIG_IGN is inherited across fork AND survives exec, so
|
||||||
|
# the disposition reaches every descendant --- cargo, a test binary, and
|
||||||
|
# anything either of them spawns. Tests that signal a child then wait
|
||||||
|
# for it to die will hang until their own deadline and report that as a
|
||||||
|
# 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) --- a validated
|
||||||
|
# (status, token) PAIR. Callers rely on both halves and MUST NOT
|
||||||
|
# re-derive the classification:
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# conflating them fails callers for the wrong reason.
|
||||||
|
#
|
||||||
|
# THE PROBE. A child sends itself SIGINT. Deliverable => the trap runs
|
||||||
|
# => 23. Ignored => the kill is a no-op => the child falls through to
|
||||||
|
# `exit 0`. The `|| exit 24` arms matter: without them a FAILED kill
|
||||||
|
# would also fall through to `exit 0` and be misread as `ignored`,
|
||||||
|
# which is the one wrong answer this helper exists to avoid.
|
||||||
|
#
|
||||||
|
# POSIX shell only --- trap, kill, $$ --- so no /proc and no sigaction:
|
||||||
|
# the mechanism is not Linux-specific and adds no unsafe code.
|
||||||
|
|
||||||
|
probe_status=0
|
||||||
|
sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \
|
||||||
|
|| probe_status=$?
|
||||||
|
|
||||||
|
case "$probe_status" in
|
||||||
|
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
|
||||||
|
;;
|
||||||
|
esac
|
||||||
110
scripts/gate
110
scripts/gate
|
|
@ -506,6 +506,116 @@ esac
|
||||||
WT=$(canon "$(worktree_root)")
|
WT=$(canon "$(worktree_root)")
|
||||||
cd "$WT"
|
cd "$WT"
|
||||||
|
|
||||||
|
# R-b (framing SS7c): refuse to start when SIGINT is not deliverable.
|
||||||
|
#
|
||||||
|
# HERE, immediately after the worktree resolves and BEFORE any log
|
||||||
|
# directory, ambient root or temporary dir exists: a refused run should
|
||||||
|
# leave nothing behind to clean up or mistake for evidence.
|
||||||
|
#
|
||||||
|
# WHY AT ALL. A shell running a command in the background without job
|
||||||
|
# control sets SIGINT to SIG_IGN; that survives fork AND exec, so every
|
||||||
|
# test which signals a child and waits for it hangs to its own
|
||||||
|
# deadline. Seven red sweeps were read as a product teardown defect
|
||||||
|
# before this was found (framing SS4c).
|
||||||
|
#
|
||||||
|
# The helper owns the classification and the wording; this consumer
|
||||||
|
# re-derives neither. It continues only on 0, and otherwise stops after
|
||||||
|
# letting the helper's stderr through untouched.
|
||||||
|
#
|
||||||
|
# `|| sigint_status=$?` IS LOAD-BEARING, and two wrong shapes were tried
|
||||||
|
# before this one:
|
||||||
|
#
|
||||||
|
# * a bare invocation dies under `set -e` at the helper's non-zero
|
||||||
|
# exit, so the refusal below never prints;
|
||||||
|
# * `if ! helper; then sigint_status=$?; fi` captures the status of
|
||||||
|
# the NEGATED condition --- always 0 --- so the guard printed the
|
||||||
|
# diagnosis and then ran the whole gate anyway.
|
||||||
|
#
|
||||||
|
# In a `cmd || assignment` list the failure is handled, so `set -e` does
|
||||||
|
# not fire and `$?` is the helper's own status. This is the same shape
|
||||||
|
# the helper uses internally.
|
||||||
|
#
|
||||||
|
# NO OVERRIDE, deliberately: a run in this state cannot produce valid
|
||||||
|
# evidence, so a flag to proceed anyway would only manufacture red gates
|
||||||
|
# that mean nothing.
|
||||||
|
if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then
|
||||||
|
# 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" \
|
||||||
|
>"$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) 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"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# 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
|
||||||
|
fi
|
||||||
|
|
||||||
TARGET=$(ensure_target_dir "$WT")
|
TARGET=$(ensure_target_dir "$WT")
|
||||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
# $$ as well as the timestamp: two invocations in the same worktree
|
# $$ as well as the timestamp: two invocations in the same worktree
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,4 @@
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
pub mod iso;
|
pub mod iso;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
pub mod sigint_conformance;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
//! Shared SIGINT-guard conformance vectors
|
||||||
|
//! (`docs/gpu-probe-sigint-framing.md` §7c).
|
||||||
|
//!
|
||||||
|
//! # Why these live here rather than in each suite
|
||||||
|
//!
|
||||||
|
//! The contract is that the **shell** consumer (`scripts/gate`) and the
|
||||||
|
//! **Rust** consumer (R-d's `sigint_diagnosis`) agree on every case.
|
||||||
|
//! Two independently written copies of the list can drift while both
|
||||||
|
//! still report "45 cases" — the same-length-different-content
|
||||||
|
//! divergence this matrix exists to rule out. One generator, two
|
||||||
|
//! consumers.
|
||||||
|
|
||||||
|
/// What a consumer must do with a given `(status, stdout)` pair.
|
||||||
|
///
|
||||||
|
/// `ValidatedError` and `Boundary` **both exit 2**, so a test comparing
|
||||||
|
/// only exit codes cannot separate them — and a validator that accepted
|
||||||
|
/// *every* status 2 would pass. They are told apart by whether the
|
||||||
|
/// child's stderr is surfaced: a validated verdict speaks with the
|
||||||
|
/// helper's voice; a boundary failure must not.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Safe,
|
||||||
|
ValidatedIgnored,
|
||||||
|
ValidatedError,
|
||||||
|
Boundary,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
|
||||||
|
pub const TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
|
||||||
|
pub const TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error";
|
||||||
|
|
||||||
|
/// Emitted on stderr by the **branch-discriminating** cases — the
|
||||||
|
/// cross-product rows and X1 — so a consumer's output can be searched
|
||||||
|
/// for it: present ⇒ the child's stderr was surfaced.
|
||||||
|
///
|
||||||
|
/// **X3 and X4 deliberately carry their own payloads instead**, which
|
||||||
|
/// is what makes them distinct inputs rather than duplicates of
|
||||||
|
/// `1/E/empty` and `0/V/safe/bare`. An earlier revision gave every case
|
||||||
|
/// this same sentinel and so shipped 45 entries over 43 distinct
|
||||||
|
/// inputs; [`shared_cases`] now asserts uniqueness so that cannot
|
||||||
|
/// recur silently.
|
||||||
|
pub const SENTINEL: &str = "PMACS-CONFORMANCE-SENTINEL";
|
||||||
|
|
||||||
|
/// The canonical wording the helper uses for `ignored`. X3 emits it
|
||||||
|
/// **without** a valid token, so a consumer that surfaced untrusted
|
||||||
|
/// stderr would repeat it — the defect A6b forbids.
|
||||||
|
pub const CANONICAL_IGNORED: &str =
|
||||||
|
"pmacs: SIGINT is ignored; run this command with SIGINT deliverable";
|
||||||
|
|
||||||
|
pub struct Case {
|
||||||
|
pub name: String,
|
||||||
|
pub status: i32,
|
||||||
|
pub stdout: Vec<u8>,
|
||||||
|
/// Exact stderr this stub emits. Most cases use [`SENTINEL`]; X3
|
||||||
|
/// and X4 carry their own payloads, which is what makes them
|
||||||
|
/// distinct inputs rather than duplicates of other rows.
|
||||||
|
pub stderr: String,
|
||||||
|
pub expect: Outcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `/bin/sh` stub reproducing one case exactly: its stdout bytes, its
|
||||||
|
/// own stderr payload, and its status.
|
||||||
|
#[must_use]
|
||||||
|
pub fn stub_script(case: &Case) -> String {
|
||||||
|
let octal = case.stdout.iter().fold(String::new(), |mut acc, b| {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(acc, "\\{b:03o}");
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
format!(
|
||||||
|
"#!/bin/sh\nprintf '{octal}'\necho '{}' >&2\nexit {}\n",
|
||||||
|
case.stderr, case.status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared set: ten classes × encodings × three statuses, plus
|
||||||
|
/// X1/X3/X4. Only the diagonal validates.
|
||||||
|
///
|
||||||
|
/// X2 — a spawn error with no status — is deliberately absent: the
|
||||||
|
/// shell boundary cannot represent it, because an `exec` failure there
|
||||||
|
/// becomes a status. Rust exercises it separately.
|
||||||
|
#[must_use]
|
||||||
|
#[allow(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "the bulk is the generated vector list; splitting it would \
|
||||||
|
separate a case from the outcome it encodes, which is the \
|
||||||
|
one thing this file exists to keep together"
|
||||||
|
)]
|
||||||
|
pub fn shared_cases() -> Vec<Case> {
|
||||||
|
let toks: [(&str, &[u8]); 3] = [
|
||||||
|
("safe", TOKEN_SAFE),
|
||||||
|
("ignored", TOKEN_IGNORED),
|
||||||
|
("error", 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 diagonal = match status {
|
||||||
|
0 => Outcome::Safe,
|
||||||
|
1 => Outcome::ValidatedIgnored,
|
||||||
|
_ => Outcome::ValidatedError,
|
||||||
|
};
|
||||||
|
let mut lf = correct.to_vec();
|
||||||
|
lf.push(b'\n');
|
||||||
|
out.push(Case {
|
||||||
|
name: format!("{status}/V/{name}/bare"),
|
||||||
|
status,
|
||||||
|
stdout: correct.to_vec(),
|
||||||
|
stderr: SENTINEL.to_owned(),
|
||||||
|
expect: diagonal,
|
||||||
|
});
|
||||||
|
out.push(Case {
|
||||||
|
name: format!("{status}/V/{name}/lf"),
|
||||||
|
status,
|
||||||
|
stdout: lf,
|
||||||
|
stderr: SENTINEL.to_owned(),
|
||||||
|
expect: diagonal,
|
||||||
|
});
|
||||||
|
for (other, bytes) in &toks {
|
||||||
|
if other == name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut olf = bytes.to_vec();
|
||||||
|
olf.push(b'\n');
|
||||||
|
out.push(Case {
|
||||||
|
name: format!("{status}/M/{other}/bare"),
|
||||||
|
status,
|
||||||
|
stdout: bytes.to_vec(),
|
||||||
|
stderr: SENTINEL.to_owned(),
|
||||||
|
expect: Outcome::Boundary,
|
||||||
|
});
|
||||||
|
out.push(Case {
|
||||||
|
name: format!("{status}/M/{other}/lf"),
|
||||||
|
status,
|
||||||
|
stdout: olf,
|
||||||
|
stderr: SENTINEL.to_owned(),
|
||||||
|
expect: Outcome::Boundary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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(Case {
|
||||||
|
name: format!("{status}/{cls}"),
|
||||||
|
status,
|
||||||
|
stdout: bytes,
|
||||||
|
stderr: SENTINEL.to_owned(),
|
||||||
|
expect: Outcome::Boundary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(Case {
|
||||||
|
name: "X1/status-126".to_owned(),
|
||||||
|
status: 126,
|
||||||
|
stdout: TOKEN_SAFE.to_vec(),
|
||||||
|
stderr: SENTINEL.to_owned(),
|
||||||
|
expect: Outcome::Boundary,
|
||||||
|
});
|
||||||
|
out.push(Case {
|
||||||
|
name: "X3/ignored-text-no-token".to_owned(),
|
||||||
|
status: 1,
|
||||||
|
stdout: Vec::new(),
|
||||||
|
// The canonical ignored wording WITHOUT a token: a consumer
|
||||||
|
// that surfaced untrusted stderr would repeat it.
|
||||||
|
stderr: CANONICAL_IGNORED.to_owned(),
|
||||||
|
expect: Outcome::Boundary,
|
||||||
|
});
|
||||||
|
out.push(Case {
|
||||||
|
name: "X4/stderr-noise".to_owned(),
|
||||||
|
status: 0,
|
||||||
|
stdout: TOKEN_SAFE.to_vec(),
|
||||||
|
// Noise on stderr must not affect classification --- and this
|
||||||
|
// payload is what distinguishes X4 from 0/V/safe/bare.
|
||||||
|
stderr: "unrelated chatter on stderr".to_owned(),
|
||||||
|
expect: Outcome::Safe,
|
||||||
|
});
|
||||||
|
// The set must be 45 DISTINCT inputs, not merely 45 entries. A
|
||||||
|
// previous revision gave every case the same stderr, which silently
|
||||||
|
// collapsed X3 into `1/E/empty` and X4 into `0/V/safe/bare` — 45
|
||||||
|
// entries, 43 inputs, and two framing-specified cases quietly not
|
||||||
|
// exercised. Asserted here rather than in each suite so no consumer
|
||||||
|
// can forget it.
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for case in &out {
|
||||||
|
assert!(
|
||||||
|
seen.insert((case.status, case.stdout.clone(), case.stderr.clone())),
|
||||||
|
"duplicate conformance input at {}: (status, stdout, stderr) already present",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(seen.len(), out.len(), "every case must be a distinct input");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,8 @@
|
||||||
//! `~/build/pmacs-gate-targets`, which matters most for the prune
|
//! `~/build/pmacs-gate-targets`, which matters most for the prune
|
||||||
//! tests — a prune bug is unrecoverable.
|
//! tests — a prune bug is unrecoverable.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
|
@ -35,6 +37,72 @@ fn repo_root() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The SIGINT-deliverability helper the gate and the panel suite share
|
||||||
|
/// (`docs/gpu-probe-sigint-framing.md` §7c).
|
||||||
|
fn sigint_helper() -> PathBuf {
|
||||||
|
repo_root().join("scripts/check-sigint-deliverable")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `cmd` with `SIGINT` set to `SIG_IGN`, the way a shell that
|
||||||
|
/// backgrounds a job without job control does.
|
||||||
|
///
|
||||||
|
/// `trap "" INT` sets the ignore in the wrapper shell, and `SIG_IGN` is
|
||||||
|
/// inherited across `fork` **and survives `exec`** — which is the whole
|
||||||
|
/// mechanism under test, so simulating it this way exercises the real
|
||||||
|
/// thing rather than a stand-in.
|
||||||
|
fn under_ignored_sigint(
|
||||||
|
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("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 {
|
fn gate() -> PathBuf {
|
||||||
repo_root().join("scripts/gate")
|
repo_root().join("scripts/gate")
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +161,345 @@ 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);
|
||||||
|
// 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}"
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
// The gate prints the raw probe status it saw. Carry it into every
|
||||||
|
// assertion message: this row failed on macOS with exit 1 where 2
|
||||||
|
// was expected, and the log could not say which status produced it
|
||||||
|
// because the message discarded stderr.
|
||||||
|
assert_eq!(
|
||||||
|
out.status.code(),
|
||||||
|
Some(2),
|
||||||
|
"boundary failures map to 2; gate said:\n{err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A6/A6b/A6c, shell consumer: the shared vectors, asserting the exact
|
||||||
|
/// branch rather than only the exit code.
|
||||||
|
///
|
||||||
|
/// `ValidatedError` and `Boundary` both exit 2, so comparing codes
|
||||||
|
/// alone would let a validator that accepts every status 2 pass. The
|
||||||
|
/// branch-discriminating cases emit a sentinel on stderr; a validated
|
||||||
|
/// verdict surfaces it, while X3/X4 carry dedicated payloads and a
|
||||||
|
/// boundary failure must withhold untrusted stderr.
|
||||||
|
#[test]
|
||||||
|
fn gate_validates_the_whole_shared_conformance_set() {
|
||||||
|
use common::sigint_conformance::{
|
||||||
|
CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cases = shared_cases();
|
||||||
|
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
|
||||||
|
|
||||||
|
for case in cases {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = gate_with_stub_helper(&stub_script(&case), 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 err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let name = &case.name;
|
||||||
|
|
||||||
|
match case.expect {
|
||||||
|
Outcome::Safe => assert!(
|
||||||
|
!err.contains("REFUSING TO RUN"),
|
||||||
|
"case {name}: a validated safe pair must continue: {err}"
|
||||||
|
),
|
||||||
|
Outcome::ValidatedIgnored => {
|
||||||
|
assert_eq!(out.status.code(), Some(1), "case {name}: {err}");
|
||||||
|
assert!(
|
||||||
|
err.contains(SENTINEL),
|
||||||
|
"case {name}: a validated verdict surfaces the helper's \
|
||||||
|
stderr: {err}"
|
||||||
|
);
|
||||||
|
assert!(err.contains("token=valid"), "case {name}: {err}");
|
||||||
|
}
|
||||||
|
Outcome::ValidatedError => {
|
||||||
|
assert_eq!(out.status.code(), Some(2), "case {name}: {err}");
|
||||||
|
assert!(
|
||||||
|
err.contains(SENTINEL),
|
||||||
|
"case {name}: a validated error also speaks with the \
|
||||||
|
helper's voice: {err}"
|
||||||
|
);
|
||||||
|
assert!(err.contains("token=valid"), "case {name}: {err}");
|
||||||
|
}
|
||||||
|
Outcome::Boundary => {
|
||||||
|
assert_eq!(out.status.code(), Some(2), "case {name}: {err}");
|
||||||
|
assert!(
|
||||||
|
err.contains("SIGINT guard boundary error"),
|
||||||
|
"case {name}: {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!err.contains(SENTINEL),
|
||||||
|
"case {name}: a boundary failure must NOT surface the \
|
||||||
|
child's stderr --- this is what separates it from a \
|
||||||
|
validated error, which shares its exit code: {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!err.contains(CANONICAL_IGNORED),
|
||||||
|
"case {name}: and it must never repeat the canonical \
|
||||||
|
ignored wording --- X3 emits exactly that on stderr \
|
||||||
|
with no token: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A8: no capture directory survives, on any path. `root` is RAII
|
||||||
|
// --- it is dropped at the end of this iteration.
|
||||||
|
let residue: Vec<_> = std::fs::read_dir(root.path())
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A8: the guard cannot create its capture directory.
|
||||||
|
///
|
||||||
|
/// Bounded --- it never reaches a stage. `TMPDIR` points at a path that
|
||||||
|
/// does not exist, so `mktemp -d` fails and the guard must refuse
|
||||||
|
/// before running the helper at all.
|
||||||
|
#[test]
|
||||||
|
fn gate_refuses_when_the_capture_directory_cannot_be_created() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = gate_with_stub_helper("#!/bin/sh\nprintf 'pmacs-sigint-v1:safe'\nexit 0\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().join("absent-directory"))
|
||||||
|
.output()
|
||||||
|
.expect("run the stub-worktree gate");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert_eq!(out.status.code(), Some(2), "boundary error: {err}");
|
||||||
|
assert!(
|
||||||
|
err.contains("capture directory"),
|
||||||
|
"the failure names what could not be created: {err}"
|
||||||
|
);
|
||||||
|
assert!(err.contains("no stage has run"), "and no stage ran: {err}");
|
||||||
|
assert!(
|
||||||
|
!String::from_utf8_lossy(&out.stdout).contains("[01]"),
|
||||||
|
"no stage may run"
|
||||||
|
);
|
||||||
|
// A8 on this path too: the temporary root is inspected BEFORE its
|
||||||
|
// RAII drop, and must contain nothing the guard left behind.
|
||||||
|
let residue: Vec<_> = std::fs::read_dir(root.path())
|
||||||
|
.expect("read tmpdir")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
residue.is_empty(),
|
||||||
|
"a guard that could not create its capture directory must leave \
|
||||||
|
nothing behind: {residue:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each helper arm emits its exact token on stdout.
|
||||||
|
#[test]
|
||||||
|
fn sigint_helper_emits_the_exact_token_for_each_arm() {
|
||||||
|
use common::sigint_conformance::{TOKEN_ERROR, TOKEN_IGNORED, TOKEN_SAFE};
|
||||||
|
|
||||||
|
let safe = Command::new(sigint_helper()).output().expect("run helper");
|
||||||
|
assert_eq!(safe.status.code(), Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
safe.stdout,
|
||||||
|
[TOKEN_SAFE, b"\n"].concat(),
|
||||||
|
"the safe arm emits exactly its token plus one LF"
|
||||||
|
);
|
||||||
|
|
||||||
|
let erroring = Command::new(sigint_helper())
|
||||||
|
.env("PATH", "")
|
||||||
|
.output()
|
||||||
|
.expect("run helper");
|
||||||
|
assert_eq!(erroring.status.code(), Some(2));
|
||||||
|
assert_eq!(erroring.stdout, [TOKEN_ERROR, b"\n"].concat());
|
||||||
|
|
||||||
|
// The ignored arm needs a shell that ignores SIGINT; assert its
|
||||||
|
// STDOUT, not merely its status and stderr.
|
||||||
|
let ignored = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]);
|
||||||
|
assert_eq!(ignored.status.code(), Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
ignored.stdout,
|
||||||
|
[TOKEN_IGNORED, b"\n"].concat(),
|
||||||
|
"the ignored arm emits exactly its token plus one LF"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §7c: the helper answers `safe` when `SIGINT` is deliverable.
|
||||||
|
#[test]
|
||||||
|
fn sigint_helper_reports_safe_when_the_signal_is_deliverable() {
|
||||||
|
let out = Command::new(sigint_helper())
|
||||||
|
.output()
|
||||||
|
.expect("run the sigint helper");
|
||||||
|
assert_eq!(out.status.code(), Some(0), "safe is exit 0");
|
||||||
|
assert!(
|
||||||
|
out.stderr.is_empty(),
|
||||||
|
"the safe path is silent, so a clean run says nothing: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §7c: the helper answers `ignored` — exit 1, canonical wording — when
|
||||||
|
/// `SIGINT` is inherited as `SIG_IGN`.
|
||||||
|
#[test]
|
||||||
|
fn sigint_helper_reports_ignored_when_the_signal_is_inherited_ignored() {
|
||||||
|
let out = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]);
|
||||||
|
assert_eq!(out.status.code(), Some(1), "ignored is exit 1");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
err.contains("SIGINT is ignored"),
|
||||||
|
"the canonical ignored diagnosis is the helper's to own: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §7c: the helper answers `error` — exit 2, a DISTINCT diagnosis — when
|
||||||
|
/// the probe cannot decide.
|
||||||
|
///
|
||||||
|
/// The probe shells out, so an empty `PATH` makes its inner `sh`
|
||||||
|
/// unfindable. This is the case a naive `exit 0` would misreport as
|
||||||
|
/// `ignored`, failing the caller for the wrong reason.
|
||||||
|
#[test]
|
||||||
|
fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() {
|
||||||
|
let out = Command::new(sigint_helper())
|
||||||
|
.env("PATH", "")
|
||||||
|
.output()
|
||||||
|
.expect("run the sigint helper with no PATH");
|
||||||
|
assert_eq!(out.status.code(), Some(2), "error is exit 2, never 1");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
err.contains("could not determine"),
|
||||||
|
"error has its own wording: {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!err.contains("SIGINT is ignored"),
|
||||||
|
"error must NOT be reported as ignored --- they are different \
|
||||||
|
problems, and conflating them is the defect the helper exists \
|
||||||
|
to avoid: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// R-b: the gate refuses under ignored `SIGINT`, **before any stage**.
|
||||||
|
///
|
||||||
|
/// This is the row whose absence let a real bug ship: the first
|
||||||
|
/// implementation ran the helper as a bare command under `set -e`, so
|
||||||
|
/// the shell died at the non-zero exit and the refusal never printed;
|
||||||
|
/// the second captured `$?` inside `if !`, which is the status of the
|
||||||
|
/// negated condition — always zero — so the gate printed the diagnosis
|
||||||
|
/// and then ran the whole suite anyway. Both passed every other test in
|
||||||
|
/// this file.
|
||||||
|
#[test]
|
||||||
|
fn gate_refuses_to_start_when_sigint_is_ignored() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
// `--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),
|
||||||
|
"the helper's verdict passes through"
|
||||||
|
);
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
err.contains("SIGINT is ignored"),
|
||||||
|
"the gate surfaces the helper's stderr unchanged rather than \
|
||||||
|
inventing its own wording: {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.contains("no stage has run"),
|
||||||
|
"and says the run is not a test failure: {err}"
|
||||||
|
);
|
||||||
|
let combined = format!("{}{err}", String::from_utf8_lossy(&out.stdout));
|
||||||
|
assert!(
|
||||||
|
!combined.contains("[01]"),
|
||||||
|
"NO stage may run --- the guard sits before stage 1: {combined}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_plan_sweeps_the_workspace_and_never_only_the_tests() {
|
fn the_plan_sweeps_the_workspace_and_never_only_the_tests() {
|
||||||
let root = tempfile::Builder::new()
|
let root = tempfile::Builder::new()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
#![cfg(unix)]
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
@ -171,6 +173,177 @@ mod crdt {
|
||||||
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
|
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// R-d (framing §7c): refuse to run this test when `SIGINT` is not
|
||||||
|
/// deliverable, and say so.
|
||||||
|
///
|
||||||
|
/// The test signals a process group and requires the launcher to
|
||||||
|
/// exit. If `SIGINT` is inherited as `SIG_IGN` --- which a shell
|
||||||
|
/// running a command in the background without job control sets,
|
||||||
|
/// and which survives `fork` and `exec` --- the signal is a no-op
|
||||||
|
/// and the launcher waits out the deadline. Without this the
|
||||||
|
/// failure reads "child did not exit within 5s", which names a
|
||||||
|
/// teardown defect that is not there; that misreading cost nine
|
||||||
|
/// framing revisions (§4c).
|
||||||
|
///
|
||||||
|
/// Both consumers use the **same checked-in helper**, but that alone
|
||||||
|
/// no longer makes them agree: each validates the
|
||||||
|
/// `(status, token)` pair independently, in a different language.
|
||||||
|
/// Revision 12's "they can never disagree" is withdrawn, and the
|
||||||
|
/// shared matrix in `tests/common/sigint_conformance.rs` replaces
|
||||||
|
/// it — both validators run the same vectors.
|
||||||
|
///
|
||||||
|
/// This consumer proceeds only on a validated `(0, safe)` pair. On a
|
||||||
|
/// **boundary** failure the helper's stderr is untrusted and is
|
||||||
|
/// withheld; only a validated verdict speaks with the helper's
|
||||||
|
/// voice.
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// 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() {
|
||||||
|
// 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 --- SIGINT guard boundary error \
|
||||||
|
(status=unavailable token=missing): could not execute {}: {error}",
|
||||||
|
helper.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(out) => out,
|
||||||
|
};
|
||||||
|
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 | 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."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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/A6b/A6c, R-d consumer: the shared vectors, asserting the exact
|
||||||
|
/// branch.
|
||||||
|
///
|
||||||
|
/// `ValidatedError` and `Boundary` both produce `Err`, so comparing
|
||||||
|
/// `is_ok()` alone would let a validator that accepts every status 2
|
||||||
|
/// pass. The branch-discriminating cases emit a sentinel on stderr;
|
||||||
|
/// a validated verdict surfaces it, while X3/X4 carry dedicated
|
||||||
|
/// payloads and a boundary failure must withhold untrusted stderr.
|
||||||
|
#[test]
|
||||||
|
fn rd_precondition_validates_the_whole_conformance_set() {
|
||||||
|
// `super::` and NOT `crate::`: this file is ALSO compiled as a
|
||||||
|
// nested module of `gpu_initial_target_acceptance.rs`, where
|
||||||
|
// `crate::` is the outer test crate and has no `common`.
|
||||||
|
use super::common::sigint_conformance::{
|
||||||
|
CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cases = shared_cases();
|
||||||
|
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
|
||||||
|
|
||||||
|
for case in cases {
|
||||||
|
let path = dir.path().join(case.name.replace('/', "_"));
|
||||||
|
fs::write(&path, stub_script(&case)).expect("write stub");
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||||
|
let got = sigint_diagnosis(&path);
|
||||||
|
let name = &case.name;
|
||||||
|
match case.expect {
|
||||||
|
Outcome::Safe => assert!(got.is_ok(), "case {name}: {got:?}"),
|
||||||
|
Outcome::ValidatedIgnored | Outcome::ValidatedError => {
|
||||||
|
let message = got.expect_err("a validated refusal");
|
||||||
|
assert!(
|
||||||
|
message.contains(SENTINEL),
|
||||||
|
"case {name}: a validated verdict surfaces the helper's stderr: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("token=valid"), "case {name}: {message}");
|
||||||
|
}
|
||||||
|
Outcome::Boundary => {
|
||||||
|
let message = got.expect_err("a boundary refusal");
|
||||||
|
assert!(message.contains("boundary error"), "case {name}: {message}");
|
||||||
|
assert!(
|
||||||
|
!message.contains(CANONICAL_IGNORED),
|
||||||
|
"case {name}: never repeats the canonical ignored wording \
|
||||||
|
--- X3 emits exactly that with no token: {message}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!message.contains(SENTINEL),
|
||||||
|
"case {name}: a boundary failure must NOT surface the child's \
|
||||||
|
stderr --- that is what separates it from a validated error, \
|
||||||
|
which shares its outcome type: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X2 --- Rust only: a shell exec failure becomes a status, so the
|
||||||
|
// shell consumer cannot present a status-less spawn error.
|
||||||
|
let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate");
|
||||||
|
assert!(
|
||||||
|
message.contains("status=unavailable") && !message.contains(SENTINEL),
|
||||||
|
"X2: {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 {
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -1083,6 +1256,7 @@ mod crdt {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
|
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
|
||||||
|
require_sigint_deliverable();
|
||||||
let temp = secure_tempdir();
|
let temp = secure_tempdir();
|
||||||
let socket = temp.path().join("signal.sock");
|
let socket = temp.path().join("signal.sock");
|
||||||
let report = temp.path().join("signal-report");
|
let report = temp.path().join("signal-report");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue