fix(test): retire R2 and R4 — two readiness predicates weaker than their assertions

Stage 2 of `docs/macos-ci-signal-integrity-framing.md` revision 3
(acceptance 6–9). Two test races, both the same shape: the thing waited
for was weaker than the thing asserted, so the wait could return inside
the window the assertion needs closed.

R4 — `wait_for_file` returned as soon as `fs::read` succeeded, which
succeeds on a ZERO-BYTE FILE. The probe publishes with
`open(path,'wb').write(b'1')` and `open()` creates the file before
`write()` fills it, so the helper handed `[]` to a caller asserting
`== b"1"`. It now takes the expected bytes and waits while the file
holds a STRICT PREFIX of them — the states a write in flight can be
observed in — returning anything else immediately so the caller's
`assert_eq!` stays the discriminating assertion rather than becoming a
timeout inside a helper that does not know what was expected.

All four callers pass their expectation. `wait_for_published_file`, one
function away in the same suite, gated the real-TUI smoke's
`assert_eq!(…, b"1")` on the identical predicate and is fixed with it:
leaving it would have let R4 recur under a different selector, which the
registry would then have had to judge a new incident.

R2 — the USR1 fixture waited on `ProcessEventKind::Started`, emitted at
SPAWN, not when `/bin/sh` has parsed `trap '' USR1`. SIGUSR1's default
disposition is terminate, so a signal inside that window kills the
child. The child now publishes a marker AFTER the trap and the test
waits for that marker's CONTENT (the same zero-byte trap applies to a
shell's `>` redirection). `exec` replaces the forked `sleep`, so the
group holds exactly one process and the ignored disposition survives by
POSIX rather than by the shell's fork-suppression optimization — an
unstated dependency the old fixture had, since these signals are
group-directed and a forked `sleep` is an untrapped group member.

Four witnesses, each verified by REVERTING the fix and observing the
failure rather than by reasoning about it:

- `wait_for_file_does_not_return_a_zero_byte_readiness_file` fails
  `left: []`, `right: [49]` — R4's two required fragments, verbatim;
- `wait_for_file_does_not_return_a_partial_write` fails on the torn read
  a length check alone would admit;
- `wait_for_file_returns_divergent_content_rather_than_timing_out` fails
  against an over-strict helper that waits for an exact match;
- `usr1_readiness_waits_for_the_trap_not_for_the_spawn` fails
  `left: Some("SIGUSR1")`, `right: Some("SIGTERM")` with the readiness
  wait removed. Its fixture sleeps before `trap` so the pre-trap window
  is deliberate rather than load-dependent, and it proves survival by
  the child's EXIT DISPOSITION rather than by an absence observed within
  a window.

R1 is NOT touched — referred to the async-runtime lane (Q#MCI3), because
widening its budget would make it pass and measure nothing more. R3 is
NOT touched and remains UNRESOLVED, owned by the process-signal /
reap-ledger lanes.

`docs/ci-red-signatures.md` moves R2 and R4 to a "Retired rows" section
with their dispositions and adds the rule the file needed and lacked: a
red matching a retired row is a RECURRENCE that puts the retirement in
question, never a known flake. `docs/active-work.md` carries this lane
from its first commit rather than after review asks for it.

Repetition sets, not single runs: the two `--lib` process tests 15/15,
the whole `vterm_stage2_acceptance` suite 15/15 at default parallelism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-08-05 13:41:05 +02:00
parent 12f2970ad8
commit bd532dde6b
No known key found for this signature in database
5 changed files with 432 additions and 80 deletions

View File

@ -17,6 +17,13 @@ branch and no owner since #166. It is implemented on
(#215, in review), which this file required a lane for and did not have (#215, in review), which this file required a lane for and did not have
until review caught it — the #171 defect recurring. Its block is below. until review caught it — the #171 defect recurring. Its block is below.
**Updated later the same day.** #215 **merged** (`main` @ `12f2970`) and
that lane is **rewritten, not removed** — rule 4 removes a lane when its
ARC is done, and Stage 2 is the arc. Stage 2 (**hardening**) is now in
flight on `ci-signal-hardening`, with its own lane block, its own
checkpoint table, and a lane written **before** the PR was opened rather
than after review asked for it.
**Updated 2026-08-04.** Four PRs landed since: the CI CRDT coverage **Updated 2026-08-04.** Four PRs landed since: the CI CRDT coverage
lane #209, Distribution Stage 1 #211 (released as **v1.1.0**), the lane #209, Distribution Stage 1 #211 (released as **v1.1.0**), the
post-release accuracy pass #212, and **bottom-panel Stage 3 #213 post-release accuracy pass #212, and **bottom-panel Stage 3 #213
@ -93,8 +100,9 @@ lesson, §1 for the two framings).
are identical on every machine. Remote names are otherwise are identical on every machine. Remote names are otherwise
machine-local: `origin` may name this canonical URL, a release mirror, machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone. or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot: **`githubsucks/main` @ `f186253`** - Canonical base at this snapshot: **`githubsucks/main` @ `12f2970`** —
— bottom-panel Stage 3 **#213**, which completes Arc 7, atop the the macOS CI signal-integrity registry **#215**, atop `f186253`:
bottom-panel Stage 3 **#213**, which completes Arc 7, atop the
post-release accuracy pass **#212**, Distribution Stage 1 **#211** post-release accuracy pass **#212**, Distribution Stage 1 **#211**
(released as **v1.1.0**, the first release with prebuilt binaries), (released as **v1.1.0**, the first release with prebuilt binaries),
and the CI CRDT coverage lane **#209**. Beneath those, `cfc1710`: and the CI CRDT coverage lane **#209**. Beneath those, `cfc1710`:
@ -106,7 +114,7 @@ lesson, §1 for the two framings).
`Hello` still advertises v20** — two different facts, and #184 landed `Hello` still advertises v20** — two different facts, and #184 landed
only the first. only the first.
**The recovery floor advances with the base**, so the check below **The recovery floor advances with the base**, so the check below
now requires `f186253` or newer; a tree at `cfc1710` no longer now requires `12f2970` or newer; a tree at `f186253` no longer
passes. That is deliberate — a check accepting an older commit than passes. That is deliberate — a check accepting an older commit than
the declared base passes on a tree the rest of this file does not the declared base passes on a tree the rest of this file does not
describe. describe.
@ -145,7 +153,7 @@ git worktree list
git status --short --branch git status --short --branch
``` ```
The `git log` command must expose `f186253` — the base named above — or a The `git log` command must expose `12f2970` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not declares canonical will pass on a tree the rest of this file does not
@ -161,31 +169,92 @@ form. All four steps ran clean. **The two-argument form still does not
work** for a remote-only branch (`fatal: invalid reference`), which is work** for a remote-only branch (`fatal: invalid reference`), which is
why every lane below spells out the `-b` form. why every lane below spells out the `-b` form.
## macOS CI signal integrity — STAGE 1 IN REVIEW, PR #215 ## macOS CI signal integrity — STAGE 1 MERGED (#215); STAGE 2 IN REVIEW
**This file requires a lane for every open PR** (see the #171/#174 note **This file requires a lane for every open PR** (see the #171/#174 note
above: an open PR is exactly the volatile work this file records, and above: an open PR is exactly the volatile work this file records, and
#171 drifted 153 commits while invisible here). #215 had none until this #171 drifted 153 commits while invisible here). #215 had none until
entry — the same defect, caught in review. review caught it; **Stage 2's lane is written with its first commit**,
which is where the same defect stops recurring.
- **Branch `macos-ci-signal-integrity`**, base `githubsucks/main` @ Framing `docs/macos-ci-signal-integrity-framing.md` **revision 3**,
`bfb97c6`. Framing `docs/macos-ci-signal-integrity-framing.md` already approved. Stage 1 is acceptance 15; Stage 2 is acceptance 69.
**revision 3**.
- **PR: <https://github.com/levineuwirth/pmacs/pull/215>** — Stage 1, ### Stage 2 — hardening, IN REVIEW
docs only. **Checkpoints, newest last** — a lane that records only the
*reviewed* head goes stale the moment a review round is pushed, which - **Branch `ci-signal-hardening`**, worktree `../pmacs-ci-signals`, base
is how #171 became invisible: `githubsucks/main` @ `12f2970` (the #215 merge). Opened from an
**isolated worktree** because the shared checkout was on another
lane's branch with clean-but-foreign state; never switch it.
- **PR: opened from this branch; the number and its first CI checkpoint
land in the follow-up commit** — this block is written *with* the
work, before the PR exists, so the row below is filled in rather than
invented. **Checkpoints, newest last**, because a lane that records
only one head goes stale on the next push:
| head | CI run | result |
|---|---|---|
| *(opening head)* | *(pending — filled in once the run exists)* | — |
**The branch tip is authoritative over any row here.** Verify with
`git rev-parse githubsucks/ci-signal-hardening` rather than trusting
the newest line.
- **What it ships — exactly two fixes, both at the readiness
predicate**, plus four witnesses:
- **R4.** `wait_for_file` takes the **expected bytes** and waits while
the file holds a *strict prefix* of them, so a zero-byte or torn
read is no longer a readiness signal. Four callers updated; **the
identical mechanism in `wait_for_published_file`**, one function
away in the same suite, is fixed with it — leaving it would have let
R4 recur under a different selector.
- **R2.** The USR1 fixture publishes a marker **after** `trap '' USR1`
and the test waits for that marker's **content**; `exec` replaces
the forked `sleep`, so the group holds one process and the ignored
disposition survives by POSIX rather than by a shell's
fork-suppression optimization.
- **The witnesses were verified by REVERTING each fix**, not by
reasoning. With the old predicate restored,
`wait_for_file_does_not_return_a_zero_byte_readiness_file` fails
`left: []`, `right: [49]` — **R4's two required fragments, verbatim**
— and with the readiness wait removed,
`usr1_readiness_waits_for_the_trap_not_for_the_spawn` fails
`left: Some("SIGUSR1")`, `right: Some("SIGTERM")`.
- **R1 is NOT touched** — referred to the async-runtime lane (Q#MCI3);
widening its budget would make it pass and measure nothing more.
**R3 is NOT touched** and stays **unresolved**; it belongs to the
process-signal / reap-ledger lanes and nothing here may make it look
otherwise.
- **Repetition sets, not single runs:** the two `--lib` process tests
**15/15**, the whole `vterm_stage2_acceptance` suite (9 tests, default
parallelism) **15/15**.
- **Verification:** fmt, diff-check, clippy with and without `crdt`,
`--lib` 1897, `--lib --features crdt` 2082, vterm Stage 2 9, m4 149,
required GPU 221, and the **full serialized sweep in BOTH feature
configurations against a `main` baseline** — the blindness the handoff
§5 warns about, which is why the baseline is taken first. Each `--lib`
figure is exactly one above #215's (1896 / 2081): the R2 witness.
Recovery from a clean checkout:
```sh
git fetch githubsucks
git worktree add ../pmacs-ci-signals \
-b ci-signal-hardening \
githubsucks/ci-signal-hardening
```
### Stage 1 — MERGED as #215
- **Branch `macos-ci-signal-integrity`**, merged to `main` @ `12f2970`
after two review rounds. Docs only. Final CI checkpoint:
| head | CI run | result | | head | CI run | result |
|---|---|---| |---|---|---|
| `d33bf4d` | [30950108477](https://github.com/levineuwirth/pmacs/actions/runs/30950108477) | 14/14 green — the reviewed head | | `d33bf4d` | [30950108477](https://github.com/levineuwirth/pmacs/actions/runs/30950108477) | 14/14 green — the reviewed head |
| `f76897c` | [30990816890](https://github.com/levineuwirth/pmacs/actions/runs/30990816890) | review round 1 | | `f76897c` | [30990816890](https://github.com/levineuwirth/pmacs/actions/runs/30990816890) | review round 1 |
| *(tip)* | — | review round 2: framing wording, the missing quote, this table | | `2e0617f` | — | review round 2: framing wording, the missing quote, the checkpoint table |
**The branch tip is authoritative over any row here.** Verify with - **What Stage 1 shipped:** `docs/ci-red-signatures.md`, the single
`git rev-parse githubsucks/macos-ci-signal-integrity` rather than
trusting the newest line.
- **What Stage 1 ships:** `docs/ci-red-signatures.md`, the single
authority for judging a red CI run. Rows key on **signature** authority for judging a red CI run. Rows key on **signature**
selector + job/flavor + every required fragment, normalized — so **a selector + job/flavor + every required fragment, normalized — so **a
test-name match confers nothing**. The rerun rule is replaced: a green test-name match confers nothing**. The rerun rule is replaced: a green
@ -202,30 +271,6 @@ entry — the same defect, caught in review.
not rows — unmatchable by construction, so a red in either is a new not rows — unmatchable by construction, so a red in either is a new
incident. The registry is therefore *stricter* than the list it incident. The registry is therefore *stricter* than the list it
replaces: nothing is pre-excused. replaces: nothing is pre-excused.
- **Verification:** fmt, diff-check, clippy with and without `crdt`,
`--lib` 1896, `--lib --features crdt` 2081, pmacs-protocol 19, m4 149,
required GPU 221. All three named tests pass locally on Linux —
consistent with R1R4 being macOS occurrences, and **not evidence
about any of them**.
### Stage 2 — hardening, NOT started
Waits on #215's merge. Owns R2's trap-readiness fix and R4's
`wait_for_file` predicate, each with a **repetition set** rather than a
single green run, plus a discriminating witness that fails without the
fix. **R1 is referred to the async-runtime lane** (Q#MCI3) rather than
patched: widening its budget would make it pass and measure nothing
more. **R3 stays unresolved** and belongs to the process-signal /
reap-ledger lanes.
Recovery from a clean checkout:
```sh
git fetch githubsucks
git worktree add ../pmacs-ci-signals \
-b macos-ci-signal-integrity \
githubsucks/macos-ci-signal-integrity
```
## CI CRDT coverage — MERGED (#209); kept for its three follow-ons ## CI CRDT coverage — MERGED (#209); kept for its three follow-ons

View File

@ -1,7 +1,9 @@
# Agent handoff — cross-machine continuity # Agent handoff — cross-machine continuity
**Last updated: 2026-08-05.** The live CI-triage rule in §5 now points **Last updated: 2026-08-05.** The live CI-triage rule in §5 now points
at `docs/ci-red-signatures.md` (PR #215, in review), which keys on at `docs/ci-red-signatures.md` (**PR #215, merged as `main` @
`12f2970`**; its Stage 2 hardening is in flight — see
`docs/active-work.md`), which keys on
signature rather than test name; the hazards list this file used to signature rather than test name; the hazards list this file used to
carry is retired, and its two unevidenced entries are audit notes there. carry is retired, and its two unevidenced entries are audit notes there.
Previously **2026-08-04, as bottom-panel Stage 3 #213 — the adopter Previously **2026-08-04, as bottom-panel Stage 3 #213 — the adopter

View File

@ -79,10 +79,19 @@ Main-branch greens are **occurrence evidence** and accumulate toward a
rate. They retire nothing by themselves. Retired rows stay in this file rate. They retire nothing by themselves. Retired rows stay in this file
with their disposition, so a recurrence is recognisable. with their disposition, so a recurrence is recognisable.
**A red matching a RETIRED row is a recurrence, and it puts the
retirement in question — it is not a known flake.** The claim a
retirement makes is that the mechanism is gone; the same signature
afterwards falsifies that claim, which is a stronger finding than a live
row, not a weaker one. Reopen the row rather than rerunning.
--- ---
## Live rows ## Live rows
**Two of the four evidenced rows are live.** R2 and R4 were retired on
2026-08-05 and are below, under "Retired rows", with their dispositions.
### R1 — supersede cancellation budget ### R1 — supersede cancellation budget
| field | value | | field | value |
@ -102,22 +111,6 @@ test's own `tick()` + `sleep(1ms)` pump, so the interval is dominated by
when *the test* was scheduled. **Widening the budget would make it pass when *the test* was scheduled. **Widening the budget would make it pass
and measure nothing more.** and measure nothing more.**
### R2 — USR1 delivered before the trap is installed
| field | value |
|---|---|
| **selector** | `--lib process::tests::a_successful_signal_disposition_depends_on_whether_it_is_fatal` |
| **job / flavor** | macOS / lua54 |
| **required fragments** | `leader=exited(signal SIGUSR1)`**one exact fragment, not two loose ones**. Split into `leader=exited(` and `SIGUSR1` it would match a child that exited by some *other* disposition while `SIGUSR1` appeared elsewhere in the output |
| **causal status** | **test race** |
| **evidence** | [#213 run 30927084982 attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30927084982/attempts/1) |
| **retirement** | the fixture proves the trap is installed, with a witness that fails without it |
Readiness is `ProcessEventKind::Started`, emitted at **spawn** — not when
`/bin/sh` has parsed `trap '' USR1`. SIGUSR1's default disposition is
terminate, so a signal inside that window kills the child. The fixture's
own comment states the requirement it does not enforce.
### R3 — live-leader EPERM with an unobservable group ### R3 — live-leader EPERM with an unobservable group
| field | value | | field | value |
@ -138,7 +131,58 @@ field able to disagree — could not be read at all.
**Do not treat a red matching this row as environmental.** A green rerun **Do not treat a red matching this row as environmental.** A green rerun
changes nothing about it. changes nothing about it.
### R4 — readiness predicate satisfied by an empty file ---
## Retired rows
**These stay here on purpose.** A retirement is a claim that a mechanism
is gone; keeping the signature is what makes a recurrence recognisable
as a falsification of that claim rather than as a fresh mystery. Both
were retired **causally** — the mechanism removed, plus a discriminating
witness that fails without the fix — never by a count of green runs.
### R2 — USR1 delivered before the trap is installed — RETIRED 2026-08-05
| field | value |
|---|---|
| **selector** | `--lib process::tests::a_successful_signal_disposition_depends_on_whether_it_is_fatal` |
| **job / flavor** | macOS / lua54 |
| **required fragments** | `leader=exited(signal SIGUSR1)`**one exact fragment, not two loose ones**. Split into `leader=exited(` and `SIGUSR1` it would match a child that exited by some *other* disposition while `SIGUSR1` appeared elsewhere in the output |
| **causal status** | **test race** |
| **evidence** | [#213 run 30927084982 attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30927084982/attempts/1) |
| **retirement condition** | the fixture proves the trap is installed, with a witness that fails without it |
| **disposition** | **met.** The child publishes a readiness marker *after* `trap '' USR1`, the test waits for that marker's **content**, and `process::tests::usr1_readiness_waits_for_the_trap_not_for_the_spawn` is the witness |
Readiness was `ProcessEventKind::Started`, emitted at **spawn** — not when
`/bin/sh` has parsed `trap '' USR1`. SIGUSR1's default disposition is
terminate, so a signal inside that window kills the child. The fixture's
own comment stated the requirement it did not enforce.
Three things the fix and the witness settled that the row did not say:
- **Which call failed.** The fragment is rendered only on a *failed*
`kill`, and the USR1 that killed the child cannot itself have failed —
it is what did the killing. The failing call is therefore the SIGTERM
that follows, whose diagnostic reports the leader's earlier death.
**Why a group-directed TERM found no group is not established here**,
and the fix does not depend on the answer. **This says nothing about
R3**, whose leader was observed **live**.
- **The witness proves the predicate, not the platform.** The old fixture
passes on Linux; the window is real everywhere but only macOS ever
reported it. So the witness widens the pre-trap window *deliberately*
(the fixture sleeps before `trap`) instead of hoping a loaded runner
supplies one, and it proves survival by the child's **exit
disposition** — a child that took the USR1 reports
`Signaled { signal: "SIGUSR1" }` — rather than by an absence observed
within a window.
- **The fixture had a second, unnamed dependency.** These signals are
group-directed, so a forked `sleep` would be an *untrapped* member of
the same group. That it survived at all depended on the shell
suppressing the fork for the last command of a `-c` script — a bash
and dash optimization, not a guarantee. The fixture now says `exec`,
and an ignored disposition survives `exec` by POSIX.
### R4 — readiness predicate satisfied by an empty file — RETIRED 2026-08-05
| field | value | | field | value |
|---|---| |---|---|
@ -147,14 +191,28 @@ changes nothing about it.
| **required fragments** | `left: []` **and** `right: [49]` | | **required fragments** | `left: []` **and** `right: [49]` |
| **causal status** | **test race** | | **causal status** | **test race** |
| **evidence** | [#214 run 30932558752 attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30932558752/attempts/1) | | **evidence** | [#214 run 30932558752 attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30932558752/attempts/1) |
| **retirement** | `wait_for_file` requires the expected content, with a witness that fails against a zero-byte file | | **retirement condition** | `wait_for_file` requires the expected content, with a witness that fails against a zero-byte file |
| **disposition** | **met.** The helper takes the expected bytes and waits while the file holds a **strict prefix** of them; `wait_for_file_does_not_return_a_zero_byte_readiness_file` is the witness, and with the old predicate restored it fails with `left: []`, `right: [49]`**this row's two fragments, verbatim** |
`wait_for_file` returns as soon as `fs::read` succeeds — which succeeds `wait_for_file` returned as soon as `fs::read` succeeded — which succeeds
on a **zero-byte file**. The probe writes readiness with on a **zero-byte file**. The probe writes readiness with
`open(path,'wb').write(b'1')`, and `open()` creates the file before `open(path,'wb').write(b'1')`, and `open()` creates the file before
`write()` fills it. The predicate is "readable"; the assertion is `write()` fills it. The predicate was "readable"; the assertion is
"contains `1`" (`49` is ASCII `'1'`). "contains `1`" (`49` is ASCII `'1'`).
Two notes for anyone who reads a future red here:
- **The same mechanism lived in a second helper.** `wait_for_published_file`,
one function away in the same suite, gated the real-TUI smoke's
`assert_eq!(…, b"1")` on the identical "readable" predicate. It was
fixed with the same predicate; leaving it would have let this row
recur under a different selector, which the registry would have had to
judge a new incident.
- **The helper returns divergent content instead of waiting for a
match**, so a child that publishes the *wrong* thing is reported as a
diff by the caller that owns the expectation, rather than as a timeout
in a helper that does not. That behaviour has its own witness.
--- ---
## Audit notes — historical claims with no linked occurrence ## Audit notes — historical claims with no linked occurrence
@ -219,6 +277,14 @@ replaces: nothing is pre-excused.
Four incidents, three tests, **four signatures**. Count signatures: the Four incidents, three tests, **four signatures**. Count signatures: the
process test contributed two, and only one of them is a test bug. process test contributed two, and only one of them is a test bug.
| date | row | event |
|---|---|---|
| 2026-08-05 | R2, R4 | **retired** — mechanism removed, discriminating witness added; see "Retired rows" |
**The retirements are not occurrences and do not close the log.** R1 and
R3 stay live, and each retired row keeps its signature so a later red
matching one reopens it.
**All four *evidenced* rows (R1R4) are macOS.** That is a property of **All four *evidenced* rows (R1R4) are macOS.** That is a property of
these occurrences, not of the file: **A1's job is `GPU Render these occurrences, not of the file: **A1's job is `GPU Render
(headless)`, which runs on Ubuntu**, and **A2's job was never (headless)`, which runs on Ubuntu**, and **A2's job was never

View File

@ -2796,6 +2796,61 @@ mod tests {
.expect("Started carries a pid") .expect("Started carries a pid")
} }
/// Wait until `path` holds exactly `expected`.
///
/// **The predicate is the content, never the file's existence.** A
/// shell publishes readiness with `printf … > path`, and the `>`
/// redirection creates the file *before* the command that fills it —
/// so "it exists", and even "it is readable", is satisfied by a
/// zero-byte file. That is `docs/ci-red-signatures.md` R4's mechanism
/// arriving in a second place; a readiness gate written the weak way
/// re-opens the very window R2 is about.
fn wait_for_published(path: &std::path::Path, expected: &[u8], timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
if std::fs::read(path).is_ok_and(|bytes| bytes == expected) {
return;
}
assert!(
Instant::now() < deadline,
"child never published {expected:?} to {} within {timeout:?} \
(last read: {:?})",
path.display(),
std::fs::read(path).ok()
);
std::thread::sleep(Duration::from_millis(10));
}
}
/// `/bin/sh -c` argument for a child that ignores SIGUSR1, says so,
/// and then becomes `sleep` — the R2 fixture.
///
/// The order is the whole point:
///
/// - **`trap '' USR1` first.** SIGUSR1's default disposition is
/// terminate, and `ProcessEventKind::Started` is emitted when the
/// process is *spawned*, not when `/bin/sh` has parsed anything.
/// Waiting on `Started` therefore returns inside a window where the
/// signal kills the child.
/// - **The marker second**, so the test has something to wait on that
/// the shell can only publish once the trap exists.
/// - **`exec` last**, so the process group holds exactly one process.
/// An *ignored* disposition survives `exec`, while a forked `sleep`
/// would be an untrapped member of the same group — and these
/// signals are group-directed, so it would die and take the shell's
/// `wait` (and the group) with it.
///
/// `pre_trap` is the delay the R2 witness prepends to widen the
/// pre-trap window deliberately; the fixture itself passes `""`.
fn trapped_usr1_command(ready: &std::path::Path, pre_trap: &str) -> String {
let path = ready.to_str().expect("UTF-8 temp path");
assert!(
!path.contains('\''),
"the single-quoting below assumes no quote in the temp path: {path}"
);
format!("{pre_trap}trap '' USR1; printf trapped > '{path}'; exec sleep 30")
}
/// Drive the production diagnostic until it observes the leader as /// Drive the production diagnostic until it observes the leader as
/// exited, bounded by `timeout`. /// exited, bounded by `timeout`.
/// ///
@ -3223,13 +3278,20 @@ mod tests {
#[test] #[test]
fn a_successful_signal_disposition_depends_on_whether_it_is_fatal() { fn a_successful_signal_disposition_depends_on_whether_it_is_fatal() {
let mut sup = ProcessSupervisor::new(); let mut sup = ProcessSupervisor::new();
let temp = tempfile::TempDir::new().expect("tempdir");
let ready = temp.path().join("usr1-trapped");
let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh"); let mut spec = ProcessSpec::new("diag-disposition-live", "/bin/sh");
// Ignore USR1 so the successful non-fatal signal cannot end the // Ignore USR1 so the successful non-fatal signal cannot end the
// child and confuse the state assertion with a real exit. // child and confuse the state assertion with a real exit — and
spec.args = vec!["-c".into(), "trap '' USR1; sleep 30".into()]; // then WAIT for the child to say it has done so. `Started` is
// emitted at spawn and proves nothing about what `/bin/sh` has
// parsed (`docs/ci-red-signatures.md` R2); see
// `trapped_usr1_command`.
spec.args = vec!["-c".into(), trapped_usr1_command(&ready, "")];
spec.group = true; spec.group = true;
let id = sup.spawn(spec).expect("spawn"); let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id); let pid = spawn_started_pid(&mut sup, id);
wait_for_published(&ready, b"trapped", Duration::from_secs(10));
sup.signal(id, Signal::SIGUSR1).expect("USR1 delivers"); sup.signal(id, Signal::SIGUSR1).expect("USR1 delivers");
assert!( assert!(
@ -3261,6 +3323,57 @@ mod tests {
nix::sys::signal::kill(Pid::from_raw(-i32::try_from(pid).unwrap()), Signal::SIGKILL); nix::sys::signal::kill(Pid::from_raw(-i32::try_from(pid).unwrap()), Signal::SIGKILL);
} }
/// R2's discriminating witness — readiness must gate on the **trap**,
/// not on the spawn.
///
/// `spawn_started_pid` returns when `ProcessEventKind::Started`
/// arrives, which is emitted at spawn; the shell has not parsed
/// `trap '' USR1` yet, and SIGUSR1's default disposition is
/// terminate. This fixture makes that window a deliberate second wide
/// instead of leaving it to a loaded runner, so the weak predicate is
/// *guaranteed* to return inside it — which is what makes this test
/// discriminating rather than lucky.
///
/// Survival is proved by the child's **exit disposition**, not by an
/// absence observed within a window: a child that took the USR1
/// reports `Signaled { signal: "SIGUSR1" }`, and one that ignored it
/// reports the SIGTERM sent afterwards. Remove the
/// `wait_for_published` line and this test reports `SIGUSR1`.
#[test]
fn usr1_readiness_waits_for_the_trap_not_for_the_spawn() {
let mut sup = ProcessSupervisor::new();
let temp = tempfile::TempDir::new().expect("tempdir");
let ready = temp.path().join("usr1-trapped");
let mut spec = ProcessSpec::new("diag-trap-readiness", "/bin/sh");
spec.args = vec!["-c".into(), trapped_usr1_command(&ready, "sleep 1; ")];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
// The weak predicate is already satisfied, a second before the
// trap exists. The strong one cannot be.
wait_for_published(&ready, b"trapped", Duration::from_secs(10));
sup.signal(id, Signal::SIGUSR1)
.expect("USR1 delivers to a child that has trapped it");
sup.terminate(id).expect("TERM delivers");
let events = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
let signal = events.iter().find_map(|e| match &e.kind {
ProcessEventKind::Signaled { signal } => Some(signal.clone()),
_ => None,
});
assert_eq!(
signal.as_deref(),
Some("SIGTERM"),
"a child that trapped USR1 before the signal must die of the \
TERM instead: {events:?}"
);
let _ =
nix::sys::signal::kill(Pid::from_raw(-i32::try_from(pid).unwrap()), Signal::SIGKILL);
}
/// Q#DC4 / acceptance 4 — the measured group is a real observation, /// Q#DC4 / acceptance 4 — the measured group is a real observation,
/// not a restatement of the input. /// not a restatement of the input.
/// ///

View File

@ -544,7 +544,10 @@ fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() {
)) ))
.eval::<AnyUserData>() .eval::<AnyUserData>()
.expect("open raw input probe"); .expect("open raw input probe");
assert_eq!(wait_for_file(&ready_path, Duration::from_secs(5)), b"1"); assert_eq!(
wait_for_file(&ready_path, b"1", Duration::from_secs(5)),
b"1"
);
let frontend_id = FrontendId::LOCAL; let frontend_id = FrontendId::LOCAL;
state.dispatch_key( state.dispatch_key(
@ -564,9 +567,10 @@ fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() {
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
); );
let expected_input = b"\x1bv\x03\x1bw";
assert_eq!( assert_eq!(
wait_for_file(&input_path, Duration::from_secs(5)), wait_for_file(&input_path, expected_input, Duration::from_secs(5)),
b"\x1bv\x03\x1bw", expected_input,
"unescaped local bindings reach the child; C-c C-c sends one literal interrupt" "unescaped local bindings reach the child; C-c C-c sends one literal interrupt"
); );
} }
@ -718,42 +722,151 @@ fn wait_for_output(
/// The plain helper panics with only the missing path, which is the least /// The plain helper panics with only the missing path, which is the least
/// useful thing to know: a readiness file that never appears is exactly when /// useful thing to know: a readiness file that never appears is exactly when
/// "how far did startup get" decides where to look next. /// "how far did startup get" decides where to look next.
///
/// Carries [`wait_for_file`]'s `expected` predicate for the same reason:
/// this helper gates on a file the probe fills with `open(path,'wb')`
/// too, so "readable" would let a zero-byte read through here as well.
fn wait_for_published_file( fn wait_for_published_file(
pty: &mut PmacsPty, pty: &mut PmacsPty,
path: &Path, path: &Path,
expected: &[u8],
timeout: Duration, timeout: Duration,
startup: &[(&str, &Path)], startup: &[(&str, &Path)],
) -> Vec<u8> { ) -> Vec<u8> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if let Ok(bytes) = fs::read(path) { if let Some(bytes) = settled_file(path, expected) {
return bytes; return bytes;
} }
assert!( assert!(
Instant::now() < deadline, Instant::now() < deadline,
"child never published {} within {timeout:?}\n startup: {}", "child never published {expected:?} to {} within {timeout:?}\n \
last read: {:?}\n startup: {}",
path.display(), path.display(),
fs::read(path).ok(),
describe_startup(pty, startup) describe_startup(pty, startup)
); );
thread::sleep(Duration::from_millis(20)); thread::sleep(Duration::from_millis(20));
} }
} }
fn wait_for_file(path: &Path, timeout: Duration) -> Vec<u8> { /// Wait until `path` holds bytes the caller can assert against, and
/// return them.
///
/// **The predicate is the content, not readability.** Every probe in this
/// suite publishes with `open(path,'wb').write(...)`, and `open()` creates
/// the file *before* `write()` fills it — so "`fs::read` succeeded" is
/// satisfied by a **zero-byte file** and this helper used to return `[]`
/// into a caller asserting `== b"1"`. That is `docs/ci-red-signatures.md`
/// R4, whose failure text is `left: []`, `right: [49]`.
///
/// The wait therefore continues while the file holds a **strict prefix**
/// of `expected` — the states a write in flight can be observed in — and
/// returns as soon as the bytes are anything else. Returning on
/// divergence rather than insisting on a match is deliberate: the
/// caller's `assert_eq!` stays the discriminating assertion, so wrong
/// content is reported as a diff by the test that owns the expectation,
/// not as a timeout in a helper that does not.
fn wait_for_file(path: &Path, expected: &[u8], timeout: Duration) -> Vec<u8> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if let Ok(bytes) = fs::read(path) { if let Some(bytes) = settled_file(path, expected) {
return bytes; return bytes;
} }
assert!( assert!(
Instant::now() < deadline, Instant::now() < deadline,
"file was not published: {}", "file never settled: {} (expected {expected:?}, last read {:?})",
path.display() path.display(),
fs::read(path).ok()
); );
thread::sleep(Duration::from_millis(20)); thread::sleep(Duration::from_millis(20));
} }
} }
/// `Some(bytes)` once `path` is readable and no longer a write in
/// flight; `None` while it is missing or holds a strict prefix of
/// `expected`.
fn settled_file(path: &Path, expected: &[u8]) -> Option<Vec<u8>> {
let bytes = fs::read(path).ok()?;
let in_flight = bytes.len() < expected.len() && expected.starts_with(&bytes);
(!in_flight).then_some(bytes)
}
/// R4's discriminating witness — the zero-byte file, reproduced exactly.
///
/// `docs/ci-red-signatures.md` R4 is `wait_for_file` returning `[]` into a
/// caller asserting `== b"1"`, because `open(path,'wb')` creates the file
/// before `write()` fills it. The file here is created empty and filled
/// later, which makes that window certain instead of load-dependent.
///
/// Revert the predicate to "`fs::read` succeeded" and this fails with
/// `left: []`, `right: [49]` — R4's two required fragments, verbatim.
#[test]
fn wait_for_file_does_not_return_a_zero_byte_readiness_file() {
let temp = tempfile::TempDir::new().expect("tempdir");
let path = temp.path().join("ready");
// The state `open(path,'wb')` leaves behind before its `write`.
drop(fs::File::create(&path).expect("create the empty readiness file"));
let filled = path.clone();
let writer = thread::spawn(move || {
thread::sleep(Duration::from_millis(150));
fs::write(&filled, b"1").expect("fill the readiness file");
});
assert_eq!(wait_for_file(&path, b"1", Duration::from_secs(5)), b"1");
writer.join().expect("writer thread");
}
/// The same predicate, one step past R4: a **partial** write is a torn
/// read, not a readiness signal.
///
/// R4's occurrence was the zero-byte case because the marker is one byte.
/// The multi-byte callers in this suite (`b"\x1bv\x03\x1bw"`,
/// `b"VTERM_INPUT_SMOKE\n"`) can observe a prefix for the same reason, and
/// a helper that only rejected *empty* would hand one to `assert_eq!`.
#[test]
fn wait_for_file_does_not_return_a_partial_write() {
let expected = b"VTERM_INPUT_SMOKE\n";
let temp = tempfile::TempDir::new().expect("tempdir");
let path = temp.path().join("child-input");
drop(fs::File::create(&path).expect("create the empty input file"));
let filled = path.clone();
let writer = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
fs::write(&filled, b"VTERM").expect("partial write");
thread::sleep(Duration::from_millis(100));
fs::write(&filled, expected).expect("complete write");
});
assert_eq!(
wait_for_file(&path, expected, Duration::from_secs(5)),
expected
);
writer.join().expect("writer thread");
}
/// Content that is *wrong* rather than *unfinished* comes straight back,
/// so the caller's `assert_eq!` reports it.
///
/// This is what keeps the strengthened predicate from moving the
/// assertion into the helper: waiting for an exact match would turn a
/// child that published the wrong thing into a timeout in a function that
/// does not know what was expected of it, and delete the diff that says
/// what arrived instead.
#[test]
fn wait_for_file_returns_divergent_content_rather_than_timing_out() {
let temp = tempfile::TempDir::new().expect("tempdir");
let path = temp.path().join("child-size");
fs::write(&path, b"29 90\n").expect("publish the wrong geometry");
assert_eq!(
wait_for_file(&path, b"28 90\n", Duration::from_millis(500)),
b"29 90\n"
);
}
#[test] #[test]
#[allow(clippy::too_many_lines, reason = "one real-host lifecycle scenario")] #[allow(clippy::too_many_lines, reason = "one real-host lifecycle scenario")]
fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() { fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() {
@ -888,7 +1001,13 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
// are protocol escapes pmacs writes directly, never painted cells, so the // are protocol escapes pmacs writes directly, never painted cells, so the
// differ cannot split them. // differ cannot split them.
assert_eq!( assert_eq!(
wait_for_published_file(&mut pty, &alt_ready_path, Duration::from_secs(10), startup), wait_for_published_file(
&mut pty,
&alt_ready_path,
b"1",
Duration::from_secs(10),
startup
),
b"1", b"1",
"alt-screen readiness breadcrumb was published but malformed" "alt-screen readiness breadcrumb was published but malformed"
); );
@ -904,12 +1023,19 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
pty.write_input(b"\x03\x1bw") pty.write_input(b"\x03\x1bw")
.expect("escaped copy selection binding"); .expect("escaped copy selection binding");
wait_for_output(&mut pty, b"\x1b]52;c;", Duration::from_secs(5), startup); wait_for_output(&mut pty, b"\x1b]52;c;", Duration::from_secs(5), startup);
let input = wait_for_file(&input_path, Duration::from_secs(5)); let expected_input = b"VTERM_INPUT_SMOKE\n";
assert_eq!(input, b"VTERM_INPUT_SMOKE\n"); let input = wait_for_file(&input_path, expected_input, Duration::from_secs(5));
assert_eq!(input, expected_input);
pty.write_input(b"\x1b[200~PASTE_AFTER_EXIT\x1b[201~") pty.write_input(b"\x1b[200~PASTE_AFTER_EXIT\x1b[201~")
.expect("route a real host paste event"); .expect("route a real host paste event");
let size = String::from_utf8(wait_for_file(&size_path, Duration::from_secs(5))) // The probe writes `f'{lines} {columns}\n'`, so the settled content
.expect("UTF-8 stty size"); // carries the trailing newline the assertion below trims.
let size = String::from_utf8(wait_for_file(
&size_path,
b"28 90\n",
Duration::from_secs(5),
))
.expect("UTF-8 stty size");
assert_eq!(size.trim(), "28 90", "child PTY must receive cell geometry"); assert_eq!(size.trim(), "28 90", "child PTY must receive cell geometry");
thread::sleep(Duration::from_millis(200)); thread::sleep(Duration::from_millis(200));
pty.write_input(b"\x03\x18\x03").expect("quit pmacs"); pty.write_input(b"\x03\x18\x03").expect("quit pmacs");