Merge pull request #241 from levineuwirth/gpu-probe-sigint-teardown

gate: refuse to run when SIGINT is not deliverable
This commit is contained in:
Levi Neuwirth 2026-08-20 13:42:37 +00:00 committed by GitHub
commit f8033bc245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 3150 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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
}

View File

@ -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()

View File

@ -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");