pmacs/docs/gpu-probe-sigint-framing.md

1294 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# GPU launcher / probe SIGINT teardown — framing
Revision 13. Status: **AWAITING APPROVAL — the shipped ABI is
defective on macOS (§4d), and revision 13 replaces it. No
implementation of the replacement.**
Revision 12 was approved at `1fc0df6` and implemented; CI then found its
status-only ABI unsound on a platform this session could not reach.
Mechanism in §4c; the ABI defect and its remedy in §4d and §7c. Still no
product change — this remains gate/test correctness only.
Revision 10 was approved 2026-08-19 at `4fba9f6`, authorising
diagnostic-only D1/D2. They ran, and found the mechanism on the first
reproducing sweep. It is not what this document was built around: bet 1
is **withdrawn by scope** and A5 **retired by scope** (D4 was never
executed, so no claim is made about a real session either way), and
nothing in the evidence implicates the probe's shutdown path.
Revision 9 was approved 2026-08-19 at `15c25ec`. **That approval did
not extend to revision 10**, because retiring D0b (§7) materially
changed the approved diagnostic sequence — the revision 9 text made
D0b mandatory before every other diagnostic. Revision 10's approval
covers that retirement and the A3 contingency that preserves its
obligation. **D1/D2 have since been executed under it and found the
mechanism (§4c).**
Revisions 1 and 2 were each rejected on five findings. Every correction
is recorded in place rather than quietly rewritten, because three of
them were claims this document itself had advanced:
- r1 → r2: the ">6 s selector" and the "≥8 s lifetime" arithmetic
(§5); "two processes with default disposition" (§3); "119 binaries
green, one red" (§1); an unobtainable A2 (§8); "journey steps
touched: none" (§9).
- r2 → r3: **"R9 ran the same binaries" — it did not** (§4); "the probe
never blocks indefinitely" (§3); the launcher call-site count (§5);
reduction provenance, now in `docs/probe-sigint-evidence.md`; and
ledger corrections that had not been made portable (§11).
- r3 → r4: the interaction table overstated (§4); the red count was 7,
not 5, and abbreviated argv were not argv (manifest); "workspace
artifact family" conflated Cargo suffix with byte identity
(manifest); ledgers still carried the falsified R9 conclusions (§11).
**And a finding that reframes the lane: the failure has a datable
onset (§4a) and is not long-standing.**
- r9 → r10: **D0b retired as a precondition** (§7), which changes the
approved sequence and is why this revision needs its own approval;
D0a executed and its causal conclusion narrowed twice — "source
hypothesis eliminated" withdrawn in favour of "the commits do not
discriminate under current conditions" (§4b, and the endpoint
table's two uniform-same rows); portable provenance corrected after
it corrupted every log digest and silently dropped `/tmp` and
`MemAvailable`; and `uptime` recorded as **UNKNOWN**, since §7 names
it but the harness kept only the load averages.
- r8 → r9: D0a's classifier was not total — it named only "clean
split" and "mixed", leaving both-green, both-red, non-execution,
copy-disagreement and unrelated-failure outcomes unprescribed, all of
which occur in the historical logs (§7); and strict A/B/A/B does not
equalise drift (§7).
- r7 → r8: the superseded one-run D0 rule survived in three places
(§4a, §7, manifest, ledger); D0a still overstated its rates and left
the bisect's own classifier unspecified (§7); residual artifact
wording and four wrong suffix attributions (§4, manifest).
- r6 → r7: the ancestry supports **no** causal statement at all — the
observations are non-comparable, and even "outcome is not determined
by commit alone" is withdrawn (§4a); D0a was not yet a valid decision
procedure
(§7); "neither binary contains signal-handling code" is false — the
`pmacs` binary registers SIGINT in daemon mode (§3); residual
artifact wording (§4, manifest).
- r5 → r6: the ancestry argument overreached (§4a) — it shows outcome
is not determined by commit alone, and nothing more; residual
byte-identity and "artifact family" wording in both ledgers (§11);
and three provenance slips (§4, manifest).
- r4 → r5: the section summaries still carried revision-3 counts and
groupings (§4); the onset count was 13/1/3, not 14 (§4a); "byte-
different" overstated what is knowable about historical artifacts
(§4, manifest); and **the onset is not a source boundary** (§4a).
## 1. The problem, stated as what is observed
`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon` fails with
```
child did not exit within 5s (tests/gpu_invocation_acceptance.rs:180, called from :1115)
```
The test spawns `pmacs --gpu --socket <s>` in its own process group
(`process_group(0)`, `:1107`), waits for the probe to report
`phase=ready`, sends `SIGINT` to the **group** (`:1113`), and requires
the launcher to exit within five seconds.
**It fails in two binaries, not one.** `tests/gpu_initial_target_acceptance.rs`
includes the suite as a module, so a reproducing sweep reds twice.
Reference run `20260816T064549Z-2144707/09-sweep-crdt.log`:
| | line | result |
|---|---|---|
| `gpu_initial_target_acceptance` | 3097 | `FAILED. 14 passed; 1 failed … 5.19s` |
| `gpu_invocation_acceptance` | 3131 | `FAILED. 14 passed; 1 failed … 5.18s` |
| green result summaries | — | **119** |
So the correct statement is **119 green result summaries and two red
binaries**. Revision 1 said "119 binaries green, one red", which was
wrong on both halves.
**This is pre-existing on `main`.** The identical `build-crdt &&
sweep-crdt` pair at `72da24a`, clean worktree, own target directory,
fails the same test.
## 2. Why this blocks more than one lane
`sweep-crdt` is stage 15 of the sixteen-stage `--protocol` gate. While
it reds, **no branch can present a green gate**, `main` included.
`panel-mapping-generation` (§5b) is complete with its own fifteen
stages green and is held behind this lane by explicit instruction.
## 3. Ground truth (cited), and what it does *not* establish
- **`run_gpu`'s own path installs no handler.** It
(`src/main.rs:324`) blocks in `command.status()` (`:363`) — a plain
`waitpid` — with nothing installed along the way. Grepping
`SIGINT|signal_hook|sigaction|ctrlc|set_handler|pthread_sigmask|sigprocmask`
across `pmacs-gpu/src` returns nothing.
**Revision 6 said "neither binary contains signal-handling code";
that is false.** The `pmacs` binary *does* — `install_signal_handlers`
(`src/daemon.rs:628`) registers `SIGINT` and `SIGTERM` — it simply is
not on `run_gpu`'s path. And a grep of project sources cannot exclude
a runtime or dependency installing a disposition. So the established
fact is narrow: **no explicit installation on `run_gpu`'s path**.
- **The probe's event loop wakes at least every 50 ms.**
`run_headless_managed_probe` (`pmacs-gpu/src/main.rs:1065`) loops on
`event_rx.recv_timeout(Duration::from_millis(50))`. **Revision 2 said
"never blocks indefinitely", which is false**: the probe's stdin
reader thread blocks in `read_to_end` (`:1109`) with no timeout, and
once `ready` the loop has **no deadline of its own** — it leaves only
when stdin closes (`:1212`). So the process is not bounded; only the
event wakeup is.
- **The daemon *does* handle signals, deliberately.**
`src/daemon.rs:629-641` registers `SIGTERM`/`SIGINT` via
`signal_hook::flag`. The daemon is the process the test asserts must
**survive**, detached from the launcher's group.
**Correction to revision 1.** Revision 1 concluded from the first two
bullets that "two processes with default `SIGINT` disposition should
both die at once". **That does not follow, and it contradicted this
document's own leading hypothesis.** Absence of handler *code* says
nothing about runtime *disposition*: `SIG_IGN` is inherited across
`fork` **and** survives `exec`, so either process can hold a
non-default disposition it never installed — from the test harness,
from `cargo`, or from the invoking shell. Inherited ignore is a live
candidate precisely because `run_gpu`'s path is silent. But **"whatever
disposition they hold was inherited" is a hypothesis, not a finding** —
revision 6 stated it as established, which it is not, since neither a
source grep nor an absent call proves what the runtime disposition is.
D2 measures it. Until then it is one candidate among the three D1/D2
are built to separate.
## 4. Reductions attempted
**Full provenance lives in `docs/probe-sigint-evidence.md`**, which is
pushed with this branch: exact command, worktree, HEAD, cleanliness,
the Cargo suffixes actually executed, result, and log digest for every
physical run. Log bodies stay 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.
Three provenance caveats are recorded there rather than smoothed over:
**R1 and R2 have no preserved log** (revision 2 cited `gpu3.log` for
both R2 and R6; that log is R6's three-suite run alone, and counting
one run as two was wrong); **cleanliness is `UNKNOWN` for every
pre-manifest run**, because it was not recorded at the time and is not
inferrable; and **R1–R10 ran in the `panel-mapping-generation`
worktree**, not at `main`. `D0` re-runs the matrix under a harness that
captures all of it, at `main`, before any row here is relied on.
All rows carry `--features crdt`. Full argv, worktree, HEAD,
cleanliness and Cargo suffixes per run: `docs/probe-sigint-evidence.md`.
| # | reduction (after `cargo test`) | runs | result | log |
|---|---|---|---|---|
| R1 | `--test gpu_invocation_acceptance ctrl_c_on_launcher_group` | 3 | green, 0.15–0.17 s | **no log preserved** |
| R2 | `--test gpu_invocation_acceptance` (whole suite) | 1 | green, 15 passed | **no log preserved** |
| R3 | `--workspace --no-fail-fast -- --skip basedpyright ctrl_c_on_launcher_group` | 1 | green — every binary runs, only this test executes | `filtered.log` |
| R4 | `--lib --test gpu_invocation_acceptance --no-fail-fast` | 1 | green, 2145 + 15 | `two.log` |
| R5 | `--test gate_script_acceptance --test gpu_invocation_acceptance` | 1 | green | `suspect.log` |
| R6 | `--test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | 1 | green | `gpu3.log` |
| R7 | `--lib --bins` + `--test`×14 (targets 6–19) + the suite | 1 | green | `half1.log` |
| R8 | `--test`×18 (targets 20–37) + the suite | 1 | green | `half2.log` |
| R9 | `--lib --bins` + `--test`×32 (targets 6–37) + the suite | 1 | green (`-91f51d0b`, `-6b4b8223`) | `prefix.log` |
| R10 | `--workspace ... --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | 1 | green (`-5d9105cb`, `-d4dae4f0`) | `wsonly.log` |
| F1–F7 | full `--workspace --no-fail-fast -- --skip basedpyright`, plus three gate `sweep-crdt` stages | **7** | **red, 7/7** | `base-sweep.log` (at `72da24a`), `postclean.log`, `sweep-inst.log`, `sweep-diag.log`, gates `…-1977433`, `…-2144707`, `…-2375685` |
**Correction to revision 2: R9 did not run the same compilations.** It
executed `gpu_invocation_acceptance-6b4b8223dea45247`; the failing
sweeps executed `-d4dae4f01bcdef62`. **Differing Cargo suffixes mean
Cargo computed different metadata hashes — different compilations.**
Revision 4 went further and called them "byte-different"; that is
**withdrawn**, because the bytes a historical run executed are not
knowable now — target directories have been overwritten, and a hash
computed today is the hash of the current occupant. The weaker claim is
sufficient: R9 establishes **same target names and order**, not the
same compilations.
What the evidence is **consistent with** is an interaction. It does not
isolate one, because the rows differ in more than the two columns shown
— different source heads, different worktrees, unknown cleanliness, and
different Cargo compilations:
| prior targets execute | compilation set | result |
|---|---|---|
| yes | `-91f51d0b` + `-6b4b8223` (`prefix.log:3066`, `:3087`) | R9 green |
| no | `-5d9105cb` + `-d4dae4f0` (`wsonly.log:3`, `:24`) | R10 green |
| yes | `-5d9105cb` + `-d4dae4f0`, all seven | **F1–F7 red (7)** |
Neither factor alone reproduced it **in these runs**. That is the
whole of the claim. `--workspace` artifact selection is **not
sufficient by itself and not ruled out**; later-selected packages can
influence Cargo's build graph and fingerprints *before* their test
executables run, so "their targets execute after the failure at line
3066" does not exonerate them — that claim is withdrawn. And since
§4's own preamble says no historical row should be relied on until D0,
**this table is a description of what was observed, not a finding**.
Revision 3 asserted it as an interaction while simultaneously
disclaiming its inputs, which cannot both be true.
Also refuted, by measurement: machine load (red on a quiet box, load
2.77); tmpfs starving RAM (**tested by experiment** — `/tmp` 21 G →
1.2 G, available 27 G → 45 G, still red); leaked daemons (peak 58, +8
per sweep, green runs already at 46–60); inotify (47 of 1024).
## 4a. The onset is datable — and it reframes the lane
`sweep-crdt` appears **17 times** in this target directory's gate logs.
`ctrl_c` fails in **exactly the last three**, and passed — both copies,
`... ok` — in the runs before them.
Counted **per test copy** across the 17 `sweep-crdt` logs:
| outcome | runs |
|---|---|
| both copies `... ok` | **13** |
| **neither copy executed** — stage died compiling `pmacs` (`error[E0308]`), `…-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 wrong.** The count is
13 / 1 / 3, and one earlier non-passing run is a **compile failure that
never reached either copy**. The two genuinely red-on-other-tests
sweeps did execute `ctrl_c`, and it passed.
**So the failure is not long-standing.** "Pre-existing on `main`"
remains true — F1 at `72da24a` reproduces it — but "always broken" was
never established and is now contradicted. Last green containing it:
`20260815T185708Z`. First red: `20260816T063330Z`. The machine was not
rebooted across that boundary.
**But the onset is NOT a source boundary, and a Git bisect is not yet
justified.** 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, since `5174f73` landed at 08:45:41,
after that run ended at 08:42:01. **Cleanliness was captured for
neither**, and the tree was under active edit throughout. So the window
dates a **machine/worktree-state transition**, not two clean revisions.
One further relationship is worth recording **only to say what it
cannot support**: `72da24a` is an **ancestor** of `7599661` (verified
by `git merge-base --is-ancestor`), yet `72da24a` fails today while
`7599661` passed on 08-15. **These two observations are
non-comparable** — they differ in commit *and* in environment *and* in
time — so **no causal conclusion of any kind may be drawn from the
pair**.
Revision 6 read it as "outcome is not determined by commit alone".
**That is withdrawn too**: different commits can deterministically
produce different outcomes, and this document's own fix-then-regression
scenario is an example. The pair supports nothing about determinism
either way.
**Revision 5 drew still more from it.** It said a source cause was
"positively discouraged", that the ancestry "says to expect" equal
endpoints, and that "whatever changed is environmental, cached, or
uncommitted". None of that follows either. Nothing in the pair
distinguishes:
- an environmental change;
- a source/environment interaction; or
- a source fix landing before `7599661` and a regression landing before
`724b785`.
And an older ancestor outside the interval behaving badly is simply
**irrelevant** to whether `7599661..724b785` contains a regression. The
endpoints, under the N = 5 contract in §7 D0a, settle only **whether a
bisect is currently justified** — not whether the interval contains a
regression. Those are different questions, and D0a's outcome
(both-uniform-red) answers the first and leaves the second open. (Revision 6 wrote "needs only that the
two clean endpoints differ *now*", which is the superseded one-run
rule; a bare difference decides nothing.)
**This still supersedes the reduction matrix as the lane's first
move**, as endpoint reproduction — which is a decision procedure, not a
prediction.
## 4b. D0a result — the commits do not discriminate today
Executed 2026-08-19 under the approved contract **with one departure,
stated up front**: the contract's captured-conditions list names
`uptime`, and the harness kept only the load averages from it. `uptime`
is therefore `UNKNOWN` for all ten runs. Everything else held — 10 runs,
counterbalanced `A B B A A B B A A B`, N = 5 per endpoint, clean
detached worktrees, isolated target directories, `dirty=0` verified per
run, **zero voids, zero splits** — and no classification depends on the
missing field, so the verdict stands. D1/D2's harness must capture the
full list.
**A (`7599661`) uniform-red. B (`724b785`) uniform-red.** By the
endpoint table this is *both endpoints uniform the same way*: the
difference is **not captured by those two commits**.
- **No bisect of `7599661..724b785` is justified UNDER CURRENT
CONDITIONS**, and none will run. `7599661` passed inside `sweep-crdt`
on 08-15 and fails 5/5 clean today, so the two commits **do not
discriminate now**.
- **That is the entire causal claim.** Earlier wording here — "the
source hypothesis is eliminated", "the interval cannot contain the
transition", "not reachable by source" — is **withdrawn**.
Uniform-red today says nothing about 08-15: a historical source
regression could be **masked** by a later environmental effect, or by
a source/environment interaction under which both commits now fail.
**Failing to discriminate is not the same as not differing.**
- **The onset window is deprioritised, not excluded.**
- **A reliable reproduction now exists** — 10/10 today across two
commits, ~4 minutes per run. This is D0a's most useful product:
**D1/D2 no longer wait on a rare event.**
- One cheap negative on "what else changed": **no package activity in
the window** (`/var/log/pacman.log`, 08-15 19:57 → 08-16 06:33;
nearest 08-18). Not pursued further — with a reproduction in hand,
direct measurement dominates archaeology.
Per-run provenance: `docs/probe-sigint-evidence.md` §D0a.
## 4c. D1/D2 RESULT — the mechanism, and it is my own artifact
**`SIGINT` was ignored by every process in the target group, because I
launched the test runner in the background.**
Captured at the moment of the test's own `kill`:
```
test parent pid 8252 SigIgn=0000000000001007
launcher pid 8281 ppid=8252 pgid=8281 SigIgn=0000000000001007
SigCgt=0000000000000440 wchan=do_wait
probe pid 8284 ppid=8281 pgid=8281 SigIgn=0000000000001007
```
`SigIgn=0x1007` is signals 1, 2, 3 and 13 — and **signal 2 is
`SIGINT`**. The launcher's `SigCgt=0x440` is signals 7 and 11 only,
Rust's SIGBUS/SIGSEGV handlers; there is no `SIGINT` handler anywhere.
Every `SigPnd`/`ShdPnd` is zero and every per-thread `SigBlk` is zero,
so this is **ignored** delivery, not **blocked** delivery. Launcher and
probe share `pgid=8281`, so nothing escaped the group either. All three
candidates D1/D2 was built to separate are thereby separated.
`kill(-pgid, SIGINT)` is a **no-op for every member**. The launcher
waits in `do_wait` for a child that was never told to stop, and the 5 s
deadline fires.
### Where the ignore comes from — measured in both directions
| invocation | child's `SigIgn` | `SIGINT` |
|---|---|---|
| foreground | `0000000000001000` | bit 12 only (SIGPIPE) — **deliverable** |
| `setsid nohup … &` | `0000000000000007` | SIGHUP, SIGINT, SIGQUIT — **ignored** |
A shell running a command in the background without job control sets
`SIGINT`/`SIGQUIT` to `SIG_IGN` in the child; `nohup` adds `SIGHUP`.
**`SIG_IGN` is inherited across `fork` and survives `exec`**, so it
propagates shell → `cargo` → test binary → launcher → probe.
### The controlled experiment
Same command, same tree, same target directory, minutes apart — only
the invocation differs:
| arm | invocation | both target copies |
|---|---|---|
| 1 | foreground | **ok** |
| 2 | `setsid nohup … &` | **FAILED** |
### This invalidates most of this lane's investigation, and I caused it
I adopted `setsid nohup … &` on 08-16 to stop the Bash tool's
ten-minute cap truncating gate runs. **That is the "onset".**
- **The subset-vs-full distinction was never real.** Every reduction I
ran was foreground; every full sweep was backgrounded. The two
variables were perfectly confounded, so §4's matrix measured my
invocation method rather than the code, and R9's "paradox" dissolves.
- **The 08-15 → 08-16 window** dates my method change, not the machine
and not the source.
- **D0a's both-uniform-red is consistent and was right** — its harness
backgrounded both arms, so both were red; the cause was invisible to
a comparison in which it did not vary.
- **"Pre-existing on `main`" is true but trivial**: `main` fails the
same way backgrounded and passes foreground.
### Consequences for the contract
- **Bet 1 is WITHDRAWN BY SCOPE; A5 is RETIRED BY SCOPE.** D4 was
never executed, so **nothing here establishes that a real
`pmacs --gpu` session behaves correctly** — only that no observed
evidence of a user-facing defect survives, every red run being
explained by the runner's invocation. Any user-facing claim needs its
own lane and its own evidence.
- **The §7/§8 remedy no longer follows.** What remains is narrower and
genuinely real:
1. **The gate must not be invoked so that `SIGINT` is ignored** — a
runner practice, and the direct cause of all seven red sweeps.
2. **The test should not fail obscurely when its precondition is
absent.** "child did not exit within 5s" sent this lane chasing a
teardown defect for nine revisions. It should detect an ignored
`SIGINT` and say so. Silently skipping is not acceptable —
`scripts/gate`'s own comments record that self-skipping tests
"void coverage silently".
- **A3's subset/full obligation is discharged by explanation**, not by
D0b: the difference was invocation mode, demonstrated in both
directions.
`#![forbid(unsafe_code)]` rules out `pre_exec` as one *mechanism*; it
does **not** select the remedy, and revision 11's leap from the first to
the second did not follow. §7b weighs the candidates and §7c records
the decision.
## 4d. The shipped ABI is defective on macOS — found by CI
`70f0bc9`, `Test (macos-latest / lua54)` and `… / luajit`, one row:
```
scripts/gate: line 543: .../check-sigint-deliverable: Permission denied
gate: no stage has run; this is not a test failure.
left: Some(1) right: Some(2)
```
**macOS `/bin/sh` returns 1 when it cannot execute a file; Linux
returns 126.** The gate took its `1 | 2)` branch — identifiable only
because that branch's message text differs, since it withholds the
number — so a helper that never ran was classified **`ignored`**. A
broken guard told the operator their environment ignores `SIGINT`.
That is the conflation §7c forbids, reached by a route §7c did not
anticipate. Five of six SIGINT rows pass on macOS; this is the sixth.
**A7 earned its keep here.** It was satisfied *by disclosure* precisely
because the POSIX-portability claim was argued rather than measured.
The first time it was measured, the claim was wrong in a specific,
narrow way — which is the outcome a disclosed-but-untested criterion
exists to make visible.
**A rejected repair, recorded so it is not retried.** Moving `ignored`
from 1 to 3 was proposed and refused: it relocates the collision
without closing it, because an execution failure can return **any**
nonzero status. The generalisation is the useful part —
**no exit status can prove the helper ran.**
## 5. Two retracted claims, both mine, kept as warnings
**Claim A — "mechanism located".** Reported the launcher blocked in
`do_wait` on a probe child in `futex_do_wait`.
**Claim B — the retraction of A.** Argued A was unsupported because the
failing launcher "must live ≥ 8 s" while the sampler's longest-lived
was 5 s.
**Claim B's arithmetic is false.** Both reproducing binaries finish in
**~5.19 s including the five-second timeout** (`:3097`, `:3131`), so
`phase=ready` is reached in roughly a tenth of a second and the failing
launcher lives about **5.1 s total** — squarely inside what the sampler
observed. A ">6 s" selector would therefore have captured **nothing**,
repeating the very sampling error it was written to correct.
So A is **not** refuted by B. A remains **unproven for a different
reason**: under `--features crdt` the suite spawns root launchers from
**six** call sites — `:509, :534, :544, :574, :725, :1097`, all inside
`#[cfg(feature = "crdt")] mod crdt` (`:88`). Eight `--gpu` arguments
appear in the file, but `:38` and `:65` sit under
`#[cfg(not(feature = "crdt"))]` (`:26`) and are compiled out of the
failing configuration. Revision 2 said "five" while citing eight, which
was wrong twice over. Six is the number; what matters is that it is
more than one, so a launcher captured by command line alone cannot be
attributed to *this* test. The `do_wait` /
`futex_do_wait` pair is consistent with the failing instance and
consistent with a healthy sibling, and nothing recorded distinguishes
them.
The standing lesson is now the opposite of revision 1's: **do not key
on process age at all.** Key on identity.
## 6. Bets
1. ~~The failure is a real teardown defect a user meets.~~
**WITHDRAWN BY SCOPE — not falsified.** Every observed red run is
explained by inherited `SIG_IGN` from a background invocation
(§4c), so **no observed evidence of a user-facing defect remains**.
That is weaker than proving a real wgpu session is correct, and
**D4 was never executed** (§7), so the correct statement is: this
lane is now **gate/test correctness only**, and any user-facing
claim is out of its scope and unevidenced in both directions.
2. **UPHELD.** It is **not** a timing margin — confirmed twice over: a
green run finishes in ~0.19 s against a 5 s deadline, and the
foreground arm passes while the background arm fails with the same
binaries.
3. **UPHELD, and now load-bearing.** Raising the deadline is not a fix
and remains out of scope: the signal is never delivered, so no
deadline is long enough.
## 7. First step — diagnostics keyed on identity, not age
**EXECUTED. The mechanism is known (§4c): inherited `SIG_IGN`.** This
section is kept as the record of what was run. D1/D2 discriminated the
three candidates — ignored rather than blocked delivery (`SigPnd` and
per-thread `SigBlk` all zero), and no escape from the group (shared
`pgid`). D3 is discharged by the controlled arms. **D4 was NOT
executed**, and bet 1 is withdrawn by scope rather than falsified.
As written, the step read: the first commit is diagnostic only, and it
must **discriminate** the three live candidates: blocked delivery,
inherited ignore, and an escaped or wrong process
group.
- **D0a — reproduce the onset endpoints CLEANLY** (§4a): `7599661`
(last observed green) and `724b785` (first observed red), each
checked out clean, each in its own isolated target directory. This is
a **decision procedure with no predicted outcome**. One run per
endpoint decides nothing: **so far** the failure has been observed
only in the full sweep, which is a statement about what has been run,
not a property established of the defect. The procedure is therefore
specified rather than left to judgement:
- **N = 5 full `sweep-crdt` runs per endpoint.** Five is a
**predefined evidentiary threshold, chosen in advance so the
outcome cannot be argued after the fact** — it does not
"mathematically separate" anything. The historical 7/7 red and
13/13 green are **not endpoint-specific rates** and must not be
read as such: of the seven reds only F6 ran at `724b785`, and of
the greens only the last ran at `7599661`, both with **unknown
cleanliness**.
- **Counterbalanced order**, not strict alternation. Runs go in
`AB BA AB BA AB` pairs, so neither endpoint systematically follows
the other. Revision 8 claimed strict `A/B/A/B…` makes session drift
"hit both arms equally"; **it does not** — under strict
alternation B always follows A and owns the final time point. What
counterbalancing buys is the removal of *systematic order
confounding*; with an even run count one arm still holds the last
slot, and that residue is accepted and stated rather than papered
over.
- **Identical captured conditions per run** — and D0a satisfied this
list only **partially**: it captured everything below except
`uptime`, keeping the load averages and discarding elapsed time.
D1/D2's harness must capture the whole list. Same harness as D0b —
argv, worktree, `git rev-parse HEAD`, `git status --porcelain`
emptiness, the Cargo suffixes executed, result, log digest — plus
the machine facts that have already misled this lane once
(`uptime`, `free`, `/tmp` usage, leaked-daemon count).
**Classifying a single run.** The unit is *the two copies of the
target test* — `crdt::ctrl_c_…` and
`gpu_invocation_acceptance::crdt::ctrl_c_…` — and nothing else in the
sweep:
| run outcome | definition |
|---|---|
| **green** | both copies executed and both `... ok` |
| **red** | both copies executed and both `FAILED` |
| **split** | both executed, copies **disagree** |
| **void** | either copy **did not execute** |
Two of these are not hypothetical. `20260815T182846Z-708693` is a
**void**: the stage died compiling `pmacs` (`error[E0308]`) and
neither copy ran. And sweeps red on *unrelated* rows are ordinary —
`…-2839374` and `…-830195` both failed other tests while both target
copies passed. **A sweep red only on unrelated tests is a `green`
run** under this classifier, because the classifier reads the two
copies and nothing else. Unrelated failures are still recorded, as
evidence about environment stability.
**Handling each outcome:**
- **void** — discard and re-run, up to **3 voids across the whole
procedure**. Beyond that the environment is too unstable to
classify anything and D0a **stops**; that is itself the finding.
- **split** — **stop immediately.** Two copies of the same source in
different binaries disagreeing within one run is a distinct defect,
and characterising it takes priority over the endpoint question.
**Endpoint verdicts**, from 5 valid (non-void) runs each: *uniform
green* (5/5), *uniform red* (5/5), or **mixed** (anything else).
| `7599661` | `724b785` | conclusion |
|---|---|---|
| uniform green | uniform red | **clean split → bisect `7599661..724b785` permitted** |
| uniform red | uniform green | clean split, **direction inverted** — a real difference, but it falsifies which endpoint was believed good; record loudly and re-examine the onset reading before bisecting |
| uniform green | uniform green | **the commits do not discriminate under current conditions** → no bisect now; ask what else changed across the window. This does **not** exclude a source difference that current conditions mask |
| uniform red | uniform red | **the commits do not discriminate under current conditions** → same. A historical regression masked by a later environmental effect, or a source/environment interaction, remains possible |
| mixed | any | **intermittent under fixed source → no bisect**; back to D1/D2 |
| any | mixed | as above |
- **Permitting a bisect requires the clean-split row.** Every other
row forbids one.
- **The bisect itself uses the same classifier.** Every intermediate
commit is classified by the identical N = 5 protocol under the same
captured conditions; a commit that classifies **mixed** — or
produces a **split**, or exceeds the void budget — **aborts the
bisect** rather than being guessed, skipped, or rerun until it
agrees. A bisect whose steps are cheaper than its endpoints would
inherit exactly the weakness this contract exists to remove.
- **D0b — RETIRED as a precondition on 2026-08-19, kept as a
contingency.** It existed to make the §4 reduction matrix trustworthy
so the subset-vs-full comparison could locate the mechanism
*indirectly*. D0a has since produced a **reliable direct
reproduction** (10/10 across two commits, ~4 min/run), and D1/D2
measure the mechanism itself. Sharpening an indirect instrument while
a direct one is in hand is the wrong order of work.
**The obligation is now SATISFIED, by explanation rather than by
running D0b.** A3 asked that the subset/full difference be accounted
for: §4c accounts for it — every subset ran foreground and every full
sweep backgrounded, and the controlled arms demonstrate the
difference in both directions with byte-identical binaries. **D0b is
therefore not owed and will not run.**
As written, the retired step read: re-run the §4 matrix, at `main`,
recording the artifact hashes actually executed **at run time**.
Revision 2's strongest claim collapsed because command shape silently
changed the binary; no further reduction should be trusted until each
row names the executable it ran.
- **D1 — key on the PID this test records.** The test already owns
`launcher.id()`. Capture around its own `kill`, not by scanning for
age or command line.
- **D2 — snapshot before *and* after the signal**, for the test parent,
the launcher, and the probe:
- `SigIgn`, `SigCgt`, `SigBlk` — **per thread**, from
`/proc/<pid>/task/*/status`, since `SigBlk` is thread-specific and
a process-wide reading would hide a blocked delivery on the one
thread that matters;
- `SigPnd` and `ShdPnd` — a pending-but-undelivered `SIGINT` is
exactly what distinguishes blocked delivery from ignore;
- `PID`, `PPID`, `PGID`, `SID` for each — which settles whether the
signal was even addressed to the right group, and whether anything
escaped it.
A post-failure snapshot alone cannot prove inheritance; the
before/after pair is what makes the claim provable.
- **D3 — run the full sweep under D1/D2 until the failure is captured
*with* its diagnostics.** Only then propose a fix.
- **D4 — NOT EXECUTED.** It would have established whether a real
`pmacs --gpu` session, rather than the wrapper/headless probe,
reproduces the hang. It is **not run and not needed**, because bet 1
is withdrawn by scope: with every observed failure explained by the
runner's invocation, there is no user-facing claim left for this lane
to make. **A5 is retired by scope, not falsified** — nothing here
demonstrates a real session behaves correctly, only that no evidence
of the contrary survives.
## 7b. Remedy options — revision 11 evaluation, superseded by §7c
Revision 11 jumped from "`pre_exec` is `unsafe`" to "therefore a
precondition assertion". That does not follow: ruling out one mechanism
does not select another. Four candidates, with the trade-off that
decides each:
| # | remedy | effect | cost / risk |
|---|---|---|---|
| R-a | **Runner normalisation** — never invoke the gate so that `SIGINT` is ignored; if backgrounding is needed, restore the disposition first | removes the cause for every test at once | a *practice*, not a mechanism: nothing enforces it, and this lane exists because I violated it silently |
| R-b | **Early gate guard** — `scripts/gate` refuses to start when `SIGINT` is `SIG_IGN`, naming the reason | enforces R-a mechanically, once, for all suites | refuses runs that would mostly have succeeded. *This row originally added "needs an explicit override for deliberate background use"; §7c rejects that — see there* |
| R-c | **Fixture isolation** — the test restores the default disposition in the spawned launcher | fixes the test wherever it runs, background included | `pre_exec` is `unsafe`, and `#![forbid(unsafe_code)]` binds the lib crate; an integration test could technically opt out, but doing so to dodge a project invariant needs its own argument |
| R-d | **Test-local precondition assertion** — detect `SIG_IGN` on `SIGINT` and fail with that reason instead of "child did not exit within 5s" | converts nine revisions of misdirection into one accurate line | does not make the test *pass* when backgrounded; it only stops it lying about why it failed |
**They are not exclusive**, and the likely answer is R-b + R-d: a guard
that stops the whole gate from running in a state where several suites
are meaningless, plus a test that explains itself if it is ever reached
that way. R-a alone is what already failed. R-c is the only one that
makes the test genuinely invocation-independent, and it is also the one
that touches an `unsafe` prohibition, so it needs a separate decision.
**Portability is a selection criterion, not an afterthought.** Reading
`SigIgn` from `/proc/self/status` is Linux-only. The suite is already
`#![cfg(unix)]`, so a `/proc`-based guard would narrow it further to
Linux; `sigaction`-based querying would be portable but is `unsafe`.
Whichever is chosen must state what it does on a non-`/proc` unix.
Revision 11 implemented no remedy and selected none. Section 7c records
the later selection that governs implementation.
## 7c. Remedy — SELECTED: R-b + R-d, via one portable probe
**Decided 2026-08-19.** R-b (early gate guard) and R-d (test-local
precondition diagnosis), implemented through a **single checked-in
helper** built on a behavioural probe rather than `/proc` or `unsafe`:
```sh
sh -c 'trap "exit 23" 2; kill -INT $$; exit 0'
```
It sends itself `SIGINT`. If the signal is deliverable the trap runs and
it exits **23**; if `SIGINT` was inherited as `SIG_IGN` the kill is a
no-op and it exits **0**. Verified in both contexts: 23 foreground, 0
under `setsid nohup … &`.
**The raw probe is not a total classifier, and must not be used as
one.** Its `exit 0` is reached both when the `kill` was a no-op *and*
when the `kill` itself failed — a failed `kill` overwrites its own
status and lands on the same exit. Read naively, a broken probe reports
"inherited `SIG_IGN`", which would fail the gate for the wrong reason
and send the next reader down this lane again.
So the **helper owns the classification**. The checked-in executable is
`scripts/check-sigint-deliverable`; this is its complete interface:
**Revision 13 replaces the status-only ABI with a validated
`(status, token)` pair.** The interface is:
| outcome | exit | token on **stdout** |
|---|---|---|
| `safe` | 0 | `pmacs-sigint-v1:safe` |
| `ignored` | 1 | `pmacs-sigint-v1:ignored` |
| `error` | 2 | `pmacs-sigint-v1:error` |
**Any other pair is a boundary error, mapped to 2** — including a
correct-looking status with no token, a token that does not match its
status, an unknown token, or a status outside 0–2. Diagnostics stay on
**stderr**; the token is the only thing on stdout, so parsing it cannot
be confused by human-readable text.
**Why the token, and why the earlier design was wrong.** Revision 12's
ABI carried the verdict in the exit status alone. CI proved that
insufficient: on macOS a shell that cannot execute the helper exits
**1**, which the ABI already reads as `ignored`, so a broken guard told
the operator their environment ignores `SIGINT`. The first proposed
repair — move `ignored` to 3 — was **rejected, correctly**: it only
relocates the collision, because an execution failure can return *any*
nonzero status. **No exit status can prove the helper ran.** A token it
must have printed can.
Consumers therefore validate the exact pair and treat every mismatch as
`error`. They still do not re-derive the classification: the helper
decides, and the pair is what makes the helper's decision
distinguishable from a shell's.
Its complete POSIX-shell shape. Each arm emits **exactly one token on
stdout** and its diagnostic on stderr, so a status is never the only
thing a consumer sees:
```sh
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
```
The helper maps inner 23 → `(0, safe)`, inner 0 → `(1, ignored)`, and
every other status → `(2, error)`. Consumers do not parse the inner
23/0/24 statuses and do not supply their own signal diagnosis.
**The consumer flow is pair-validation, not status inspection:**
1. Run the helper, capturing **status**, **stdout** and **stderr**
separately. The two process boundaries differ and the contract keeps
that difference explicit: the shell consumer always receives a shell
status, including when `exec` fails; Rust's `Command` instead returns a
spawn error with **no status to inspect at all**. Either route can
produce boundary `error`, but they are not the same input.
2. **Compare stdout as BYTES against an exact grammar. There is no
trimming.**
```
stdout := TOKEN | TOKEN LF
TOKEN := "pmacs-sigint-v1:" ("safe" | "ignored" | "error")
LF := 0x0A
```
Nothing else validates: not leading whitespace, not a second
newline, not CR, not interior or trailing spaces, not empty.
**Revision 13's first grammar was unimplementable identically.** It
said "strip one trailing newline, then trim ASCII whitespace" — but
trimming removes *further* newlines, so `TOKEN\n\n` would have
validated while the same clause demanded single-line output. Worse,
POSIX command substitution `$(cmd)` strips **all** trailing
newlines while Rust's `Command` returns raw bytes, so the two
consumers could not have agreed even on a correct rule.
**Variable capture cannot implement this, for two independent
reasons — both measured, not reasoned:**
- **It destroys the status.** `out=$("$helper"; printf x)` returns
`printf`'s status, not the helper's: a helper exiting 1 yields
assignment status **0**. Revision 13 specified exactly this idiom.
- **It is not byte-preserving, and differs by shell.** Command
substitution drops NUL in POSIX `sh`/bash; **zsh keeps it**
(verified). So `TOKEN NUL` validates in one shell and not another
— a contract two consumers cannot implement identically.
**The shell consumer therefore captures to files and compares bytes.**
Rust already receives byte vectors from `Command::output()` and compares
those directly; it does not need or create capture files.
```sh
# The guard runs BEFORE the gate's own temporary roots exist, so it
# creates and owns its capture directory --- and arms the cleanup
# BEFORE the helper can be invoked, so no path can leave residue.
capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || {
echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2
exit 2
}
cleanup_sigint_capture() { rm -rf "$capture"; }
trap cleanup_sigint_capture EXIT HUP INT TERM
# `|| status=$?` IS LOAD-BEARING under `set -eu`: a bare invocation
# dies at the helper's non-zero exit and never reaches the
# assignment. This is the original shipped bug, and an earlier draft
# of THIS SECTION reintroduced it.
status=0
"$helper" >"$capture/out" 2>"$capture/err" || status=$?
# Select an expected token only for public helper statuses. This case
# MUST precede any use of expected_token: the gate runs under `set -u`,
# and an out-of-range shell status has no expected token.
expected_token=
case "$status" in
0) expected_token=pmacs-sigint-v1:safe ;;
1) expected_token=pmacs-sigint-v1:ignored ;;
2) expected_token=pmacs-sigint-v1:error ;;
esac
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 token_ok=1; fi
fi
if [ ! -s "$capture/out" ]; then
token_state=missing
elif [ "$token_ok" -eq 1 ]; then
token_state=valid
else
token_state=unexpected
fi
case "$status:$token_ok" in
0:1)
# SAFE is the sole continuing path. Remove the guard-local
# directory and disarm its trap BEFORE the gate installs its
# later, unrelated cleanup trap.
cleanup_sigint_capture
trap - EXIT HUP INT TERM
;;
1:1)
cat "$capture/err" >&2
printf 'gate: SIGINT guard status=1 token=valid\n' >&2
exit 1
;;
2:1)
cat "$capture/err" >&2
printf 'gate: SIGINT guard status=2 token=valid\n' >&2
exit 2
;;
*)
# The captured stderr is untrusted here and is not surfaced as
# the diagnosis. EXIT runs cleanup_sigint_capture.
printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \
"$status" "$token_state" >&2
exit 2
;;
esac
```
**The capture directory is guard-local by necessity.** The guard sits
immediately after the worktree resolves and deliberately *precedes*
the gate's log directory, ambient root and `GATE_TMPDIR`, so none of
those exist yet. It must therefore create its own, and it inherits
the same **no-residue invariant** the guard was placed early to
honour: a refused run leaves nothing behind.
Files preserve every byte including NUL, `cmp` compares bytes, and
`status` is the helper's own. Rust performs the same comparison on
`out.stdout` against `TOKEN` and `TOKEN + b"\n"`. Neither consumer
trims, and neither routes stdout through a shell variable.
If a future consumer *must* use variable capture, the status has to
be carried out explicitly —
`out=$("$helper"; st=$?; printf x; exit "$st")` — and the NUL
divergence still bars it from claiming byte equality.
3. Accept **only** these three pairs; every other combination is
`error`:
| status | stdout bytes | outcome |
|---|---|---|
| 0 | `pmacs-sigint-v1:safe` | `safe` |
| 1 | `pmacs-sigint-v1:ignored` | `ignored` |
| 2 | `pmacs-sigint-v1:error` | `error` |
4. Proceed only on `safe`. Otherwise stop — and **which stderr is
authoritative depends on whether the pair validated**:
- **Validated pair** (`ignored` or `error`): the helper's stderr
*is* the diagnosis. Surface it unchanged.
- **Boundary failure** (any invalid pair, or no pair at all): the
helper's stderr is **untrusted and must not be presented as the
diagnosis.** The consumer emits its own boundary wording, and
either omits the child's stderr or reproduces it under an explicit
untrusted label.
**This closes a hole revision 13 left open.** A helper exiting
**1 with no token but the canonical `SIGINT is ignored` text on
stderr** would classify as boundary `error` — correctly — and then
tell the operator their environment ignores `SIGINT`, which is A6's
prohibition arriving through the diagnostic instead of the
classification. A verdict that cannot be trusted cannot supply
trusted wording either.
**`safe` is validated like the others.** Revision 12 let a consumer
proceed on exit 0 alone; under revision 13, `0` with a missing or wrong
token is `error` and the consumer stops. That is deliberate — a status
that arrives without the token did not come from this helper.
That produces one of three total outcomes:
| outcome | pair required | reached when |
|---|---|---|
| `safe` | `(0, pmacs-sigint-v1:safe)` | inner probe exits 23 |
| `ignored` | `(1, pmacs-sigint-v1:ignored)` | inner probe exits 0 after a successful `kill` |
| `error` | `(2, pmacs-sigint-v1:error)` | `kill` failed, `sh` unavailable, unexpected exit, another signal |
| `error` (boundary) | **anything else**, including *no* pair | helper missing or unexecutable; a status with a missing, mismatched, unknown or malformed token; **macOS's status 1 with no token** |
`error` is **not** treated as `ignored`. It fails the gate too, but with
a different diagnosis, because "your environment ignores SIGINT" and
"the guard could not run" are different problems and conflating them is
what a naive `exit 0` would do.
This is **POSIX shell only** — `trap`, `kill`, `$$` — so the mechanism
does not depend on `/proc` or `sigaction`: it is not Linux-only and adds
no `unsafe`. That is a contract-level portability argument, not a claim
that every supported Unix has already exercised it; A7 keeps the
implementation record explicit about which platforms were actually
tried.
**Both consumers use the same helper — but that alone no longer makes
them agree.** Under revision 12 the helper's exit status *was* the
verdict, so a shared helper guaranteed a shared answer. Under
revision 13 each consumer **independently validates the pair**, in a
different language, so they can now disagree by validating differently.
Revision 12's claim that they "can never disagree" is withdrawn.
What replaces it is a **shared conformance matrix plus one
consumer-specific boundary case**. Both validators exercise the shared
set and must agree on it; Rust alone exercises the no-status spawn-error
case that the shell boundary cannot represent.
**Two distinct failing outcomes**, which an earlier draft collapsed:
- **`error (validated)`** — the pair `(2, …:error)`. The helper ran and
reported that it could not decide.
- **`error (boundary)`** — anything else. Nothing trustworthy was
returned, so the consumer owns the wording (see step 4).
**The matrix is a generated cross-product over token class, encoding
and status, plus four out-of-band cases — enumerated below and counted
honestly.** An earlier draft applied the
malformed classes only at status 0, so a validator that checked tokens
strictly for `0` and accepted arbitrary output at `1` passed every row.
**Encodings matter, and one of them is what production actually
emits.** The helper prints with `echo`, so the real output is
**`TOKEN` + LF**. Both encodings validate:
```
E1 := TOKEN (bare)
E2 := TOKEN LF (what the shipped helper emits)
```
Per status, the cases are:
| class | stdout | count | expected |
|---|---|---|---|
| **V** | the **correct** token for this status, in `E1` and `E2` | 2 | **validates** |
| **M** | each of the **two other valid tokens**, in `E1` and `E2` | 4 | boundary |
| **E** | empty | 1 | boundary |
| **U** | `pmacs-sigint-v2:…` | 1 | boundary |
| **L** | LF + token | 1 | boundary |
| **X** | token + LF + LF | 1 | boundary |
| **S** | `␠` token `␠` | 1 | boundary |
| **C** | token + CR + LF | 1 | boundary |
| **D** | token token (one line) | 1 | boundary |
| **N** | token + NUL | 1 | boundary |
That is **14 per status × 3 statuses = 42**.
The **six mismatched valid-token pairs** are enumerated rather than
sampled, because choosing one per status would leave half of them
untested:
| status | wrong tokens (each in `E1` and `E2`) |
|---|---|
| 0 | `…:ignored`, `…:error` |
| 1 | `…:safe`, `…:error` |
| 2 | `…:safe`, `…:ignored` |
Only **V validates** — `(0,safe)` → `safe`, `(1,ignored)` → `ignored`,
`(2,error)` → `error (validated)`. The other **36** are
`error (boundary)`.
Out-of-band cases and their applicable consumers:
| # | consumers | case | expected |
|---|---|---|---|
| X1 | shell + Rust | status 126 with a correct token | `error (boundary)` — status outside 0–2 |
| X2 | **Rust only** | spawn failure (missing / unexecutable helper) | `error (boundary)`, **no status inspected** |
| X3 | shell + Rust | status 1, empty stdout, stderr = canonical ignored text | `error (boundary)`, and the output **must not** present "SIGINT is ignored" as the diagnosis |
| X4 | shell + Rust | status 0, correct token, plus extra bytes on **stderr** | `safe` — stderr is not consulted for classification |
**Truthful totals:** the shared set is **45 cases** — the 42-case
cross-product plus X1, X3 and X4 — and both validators must agree on all
45. Rust additionally exercises X2, for **46 distinct cases overall**;
the shell exercises 45 because an `exec` failure there necessarily
becomes a shell status. Earlier drafts said twelve, then twenty-three,
then thirty-four; each was a count of a set that had not actually been
enumerated.
The two consumers:
- **`scripts/gate` fails immediately**, before any stage, with an
explicit ignored-`SIGINT` diagnosis.
- **The target test invokes it** and reports the same precondition
failure if run directly, instead of "child did not exit within 5s".
**Every refusing branch must print the observed helper status AND the
token state** — valid, missing, or unexpected. This is **diagnostic
context, never the classifier**: the classification is the validated
pair, and the printout exists so a failure is legible without another
CI round-trip. Revision 12 printed the raw status only in its catch-all
branch, so when macOS failed, the path had to be identified indirectly
by which message text appeared — the log could not simply say.
**One practical finding, measured after the guard was written:
backgrounding is not the problem — one *way* of backgrounding is.** This
session's tool-level background mode leaves `SIGINT` deliverable (helper
exits 0); `setsid nohup … &` does not (helper exits 1). The construct
that caused this lane was never necessary, which makes the guard cheap:
it forbids only what was already avoidable.
**No override.** A full gate run under ignored `SIGINT` cannot produce
valid evidence, so there is no flag to proceed anyway — a switch that
lets the gate run in a state where several suites are meaningless would
recreate exactly the failure this lane spent nine revisions on.
**R-c is rejected**: restoring the child's disposition needs
`pre_exec`, which is `unsafe`, and dodging a project invariant to make
one test invocation-independent is not a trade this lane will make.
**The Linux-only D1/D2 instrumentation is removed** once its evidence is
portable — it read `/proc`, it has produced its finding, and leaving it
in place would carry a platform dependency for no further return.
## 8. Acceptance criteria — REPLACED for the selected remedy
The A1–A5 written for a teardown fix no longer describe this work; they
are superseded wholesale. What the guard-and-diagnosis change must
show:
- **A1 — the guard bites.** `scripts/gate` invoked with `SIGINT`
ignored exits immediately, before any stage runs, naming the ignored
signal as the reason.
- **A2 — the direct-test diagnosis bites.** The target test run
directly with `SIGINT` ignored fails with the precondition message,
**not** with "child did not exit within 5s".
- **A3 — foreground success is unaffected.** Both target copies pass
foreground, and the guard does not fire, so the remedy costs nothing
in the normal case.
- **A4 — mutation.** Removing the probe's `trap` makes A3 fail: a
normal foreground signal terminates the inner shell and is classified
as `error`, not `safe`. Treating inner exit 0 as `safe` makes A1 and
A2 fail by allowing inherited ignore through. Collapsing `error` into
`ignored` makes A6 fail. Each mutation is named against the distinct
row it must bite.
**Measured 2026-08-19; every prediction holds:**
| mutation | helper fg | helper bg | forced error | bites |
|---|---|---|---|---|
| baseline | 0 | 1 | 2 | — |
| remove the probe's `trap` | **2** | 1 | — | **A3** — foreground degrades to `error`; backgrounded classification unchanged |
| treat inner exit 0 as `safe` | 0 | **0** | — | **A1 and A2** — inherited ignore passes through both consumers |
| collapse `error` into `ignored` | — | — | **1** | **A6** — a forced failure reports the ignored wording |
**Revision 13 adds token mutations**, each of which must bite:
| mutation | must fail |
|---|---|
| consumers accept a **missing** token (status only) | A6 and the macOS row — this is exactly the shipped defect |
| consumers accept a **wrong** token for the status (e.g. `…:safe` with exit 1) | A6 |
| consumers accept an **unknown** token (`pmacs-sigint-v2:safe`) | A6 |
| helper prints the token to **stderr** instead of stdout | **A1 and A3** — see below |
| consumer surfaces child stderr as the diagnosis on a **boundary** failure | **A6b** |
| consumer accepts any status 2 regardless of token | **A6c** |
| consumer trims whitespace before comparing | the **S/C classes** in the shared 42-case cross-product |
**Why that last one maps to A1/A3 and not A2.** With the token on
stderr, stdout is empty, so *every* outcome becomes boundary `error`.
A1 (gate refuses under ignored `SIGINT`) still refuses but with the
wrong diagnosis, and A3 (foreground success unaffected) breaks
outright because `safe` no longer validates — both bite. **A2 does
not**, because A2 only requires the direct test to report *a*
precondition failure rather than the 5 s deadline, and a boundary
`error` satisfies that as written. Revision 13 listed A2 here
incorrectly. Either mapping is defensible; this framing keeps A2
broad — the property it protects is "never the misleading deadline
message" — and relies on A6 to pin *which* diagnosis appears.
- **A5 — the gate is otherwise unchanged**: a normal foreground run
reaches and passes every stage it did before, with no stage added,
skipped, reordered, or made conditional.
- **A6 — the `error` outcome is distinct, and cannot be counterfeited
by a status alone.** With the probe forced to fail, **both the gate
and the direct target test** report `error`, not `ignored`, and
neither claims the environment ignores `SIGINT`. Extended by
revision 13 to cover the pair: a **missing**, **mismatched** or
**unknown** token is `error` in both consumers, whatever the status
accompanying it.
- **A8 — the guard leaves no residue, on every path.** The guard
creates its own capture directory because it runs before the gate's
temporary roots exist, and arms its cleanup **before** invoking the
helper. After `safe`, `ignored`, validated `error`, boundary `error`,
and a failure to create the directory at all, **no capture directory
survives**. This is the same no-residue invariant that put the guard
early in the first place; adding a capture directory must not weaken
it.
- **A6b — a boundary failure never speaks with the helper's voice.**
A helper exiting **1 with no token but the canonical
`SIGINT is ignored` text on stderr** classifies as boundary `error`
in both consumers, **and neither presents "SIGINT is ignored" as the
diagnosis** — the child's stderr is omitted or explicitly labelled
untrusted. Shared case X3. Without this, A6 is satisfiable in the
classification while being violated in the message the operator
actually reads.
- **A6c — exact-pair validation at status 2.** `(2, …:error)` is
`error (validated)`; `(2, missing)`, `(2, …:safe)`, `(2, …:ignored)`
and `(2, unknown-version)` are each **boundary** errors. The status-2
slice of the shared cross-product exercises both permitted encodings,
every mismatched valid token and every malformed class. A validator
that accepts any status 2 regardless of token must fail this row.
- **A6a — the macOS row, SCOPED TO THE GATE.** An unexecutable helper
on macOS makes `/bin/sh` exit **1 with no token**; the gate must
classify that as **boundary error → 2**, never `ignored`. Not
hypothetical: it is the observed CI failure on `70f0bc9`
(`Test (macos-latest / lua54)` and `… / luajit`), and the row is
satisfied only when that platform is green.
**It does not apply to R-d, for two independent reasons**, and
revision 13 was wrong to state it for "both consumers":
- **R-d never sees that status.** The gate invokes the helper through
`/bin/sh`, which converts an exec failure into a shell exit status.
R-d uses Rust's `Command`, which returns a **spawn error with no
exit status at all** — a different code path reaching `error` by a
different route (conformance row 12, not row 5).
- **macOS CI does not compile R-d's test.** It lives inside
`#[cfg(feature = "crdt")] mod crdt`, and the macOS jobs run
`--no-default-features --features <lua>` with no `crdt`;
`Test (crdt)` is `runs-on: ubuntu-latest`.
So R-d's macOS behaviour is **unexercised**, and this framing does not
pretend otherwise. Closing that would need either a non-crdt-gated
R-d row or a macOS crdt job — **neither is proposed here**, and A7
records the gap instead of hiding it.
- **A7 — PARTIALLY EXERCISED ON macOS, one defect found, R-d still
Linux-only.** Revision 12 closed this by disclosure because no
non-Linux unix was reachable. **That is now stale: macOS CI reached
it and measured it red**, so the disclosure fallback no longer
applies and the criterion is restated against evidence.
- **Exercised on macOS (`Test (macos-latest / lua54)` and
`… / luajit`, head `70f0bc9`):** five of the six helper/gate rows
pass — all three helper outcomes, gate refusal on `ignored`, and
gate refusal on a helper-reported `error`.
- **One known defect on macOS:**
`gate_maps_an_unexecutable_helper_to_error_not_ignored` fails,
status 1 with no token classified as `ignored`. This is the whole
reason for revision 13 (§4d), and A6a is the row that closes it.
- **R-d: Linux-only, unexercised on macOS**, because its test is
crdt-gated and the macOS jobs build without `crdt`. Stated as a
gap, not argued away.
- **Everything else remains a contract argument**: the helper is
POSIX shell only, reads no `/proc` and calls no `sigaction`. BSD
and other unixes are still untried.
## 8b. Superseded criteria, kept for the record
These were written for a teardown fix that is no longer the work. They
are retained so the change of target is visible rather than silent;
**none of them binds.**
Written when this lane still expected a teardown repair. **Superseded
by §8**; kept verbatim below.
- **A1.** The mechanism is stated and demonstrated, not inferred: a
witness failing before the change and passing after, plus a mutation
showing the witness bites its own clause.
- **A2.** `sweep-crdt` green for **three consecutive full runs on the
reviewed fixed head of this branch**. Not "on main" — that is
unobtainable before this lane is approved, gated and merged, and
revision 1 stated an impossible precondition. Post-merge
confirmation on `main` is a follow-up, not a gate on the fix.
- **A3.** There is no established "R9 paradox" to explain — R9 ran
different Cargo compilations, so the comparison it appeared to make
was never made. What A3 requires instead: **the demonstrated D1/D2
mechanism accounts for the subset/full difference, or D0b recreates
that comparison under captured provenance before this lane closes.**
In the first case, record the mechanism's explanation. In the second,
whatever D0b shows is either explained by the fix or explicitly
recorded as unexplained. A fix that greens the sweep without either
path leaves the gap stated, not hidden.
- **A4.** No deadline raised, no test skipped, retried, or serialised
to obtain green.
- **A5.** **Conditional on D4.** If bet 1 holds, this is unconditional:
Ctrl-C on a real `pmacs --gpu` session tears down the frontend and
leaves the daemon running. **RETIRED BY SCOPE**: D4 was not executed,
bet 1 is withdrawn, and this lane is recorded as **gate/test
correctness only**. A5 is not claimed satisfied and not claimed
falsified — it is out of scope, and a user-facing teardown claim would
need its own lane and its own evidence.
## 9. Coherence impact (`COHERENCE.md` §20)
- **Journey steps touched: NONE, as finally established.** Earlier
revisions claimed 12(a) "closing is clean", on the premise that this
lane repairs Ctrl-C teardown. §4c withdraws that premise: no product
behaviour changes, because the failure is an artifact of how the test
runner is invoked. Revision 1's "none" reached the right answer by
the wrong route (grade movement, which §20 warns against); this is
the right answer for the stated reason.
- What the lane does touch is **gate trustworthiness**: seven red
sweeps that named a product defect and had none.
- **Grade movement: none expected.** This restores a property that is
supposed to hold, rather than opening a new one.
- **Interaction islands: none added.**
- **Config registry: not touched. Background-work attribution: not
touched.**
- What the lane restores is every *other* lane's ability to prove
itself: while the gate can be run in a state where several suites are
meaningless, a red stage 15 tells you nothing about the branch.
## 10. Out of scope
- Raising or removing the 5 s deadline (bet 3).
- The ~10 daemons each sweep leaks — real, separately recorded, and not
implicated: green runs already ran at 46–60 leaked daemons.
- `gpu_initial_target_acceptance` including the suite as a module. It
is why the failure reds twice, and it is a tidiness question, not a
correctness one.
## 11. Record corrections owed to other ledgers
A correction is not made until it is portable. Two were outstanding
when revision 2 was reviewed, and both are closed by this revision:
- **This branch's ledger** asserted that two default-disposition
processes "should both die at once" and then withdrew that same claim
further down. The assertion is removed; only the withdrawal and its
reasoning remain.
- **`panel-mapping-generation`** carried "119 binaries green, one
red", the ≥8 s arithmetic, the "default action" claim and the ">6 s
selector"; `779a6bd` corrected those. **It still carried more**,
found on re-review: `--workspace` unification "refuted", R9 running
the "same binaries", later packages that "cannot be implicated", and
a cause "cumulative across the preceding 37 binaries". Revision 3's
claim here that the held lane no longer transports falsified claims
was **premature**; those are corrected now, and this section should
be read as a checklist that has been re-verified rather than an
assurance.