diff --git a/docs/active-work.md b/docs/active-work.md index 6def70f..ff4075e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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 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//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 **Written with the branch's first commit**, per the standing correction diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b852072..553f130 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -2547,6 +2547,34 @@ its own step, never `&&`-chained. **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] ``` diff --git a/docs/gpu-probe-sigint-framing.md b/docs/gpu-probe-sigint-framing.md new file mode 100644 index 0000000..7427939 --- /dev/null +++ b/docs/gpu-probe-sigint-framing.md @@ -0,0 +1,1293 @@ +# GPU launcher / probe SIGINT teardown — framing + +Revision 13. Status: **APPROVED 2026-08-19 at `5dece3e` — the shipped +ABI is defective on macOS (§4d), and revision 13 replaces it. +Implementation may proceed under §8's A1–A8 contract.** + +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 ` 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//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 ` 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. diff --git a/docs/probe-sigint-evidence.md b/docs/probe-sigint-evidence.md new file mode 100644 index 0000000..5b191fe --- /dev/null +++ b/docs/probe-sigint-evidence.md @@ -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. `` is +`/home/jeans/Repos/personal/pmacs-d0a-A` (detached at `7599661`) or +`/home/jeans/Repos/personal/pmacs-d0a-B` (detached at `724b785`); +`` 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= \ + sh -c 'cd \ + && 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=`. 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. diff --git a/scripts/check-sigint-deliverable b/scripts/check-sigint-deliverable new file mode 100755 index 0000000..1b7b8ce --- /dev/null +++ b/scripts/check-sigint-deliverable @@ -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 diff --git a/scripts/gate b/scripts/gate index 2ddd9d7..817bdd8 100755 --- a/scripts/gate +++ b/scripts/gate @@ -506,6 +506,116 @@ esac WT=$(canon "$(worktree_root)") 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") STAMP=$(date -u +%Y%m%dT%H%M%SZ) # $$ as well as the timestamp: two invocations in the same worktree diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7d2e4ca..5ecc4bd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -25,3 +25,4 @@ pub mod daemon; pub mod iso; pub mod pty; +pub mod sigint_conformance; diff --git a/tests/common/sigint_conformance.rs b/tests/common/sigint_conformance.rs new file mode 100644 index 0000000..f7f2d68 --- /dev/null +++ b/tests/common/sigint_conformance.rs @@ -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, + /// 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 { + 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 +} diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index 15b36b5..fff9a58 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -28,6 +28,8 @@ //! `~/build/pmacs-gate-targets`, which matters most for the prune //! tests — a prune bug is unrecoverable. +mod common; + use std::path::{Path, PathBuf}; use std::process::Command; @@ -35,6 +37,72 @@ fn repo_root() -> PathBuf { 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 { 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` // 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] fn the_plan_sweeps_the_workspace_and_never_only_the_tests() { let root = tempfile::Builder::new() diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 5a3bb88..27501d2 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -2,6 +2,8 @@ #![cfg(unix)] +mod common; + use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -171,6 +173,177 @@ mod crdt { 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 { let deadline = Instant::now() + timeout; loop { @@ -1083,6 +1256,7 @@ mod crdt { #[test] fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() { + require_sigint_deliverable(); let temp = secure_tempdir(); let socket = temp.path().join("signal.sock"); let report = temp.path().join("signal-report");