441 lines
21 KiB
Markdown
441 lines
21 KiB
Markdown
# Framing — macOS CI signal integrity: a signature registry, then hardening
|
||
|
||
**Revision 4.** Status: **Stage 1 MERGED** as #215 (`main` @ `12f2970`);
|
||
**Stage 2 implemented** on `ci-signal-hardening`. Scouted against
|
||
`githubsucks/main` @ `bfb97c6`.
|
||
|
||
**Revision 4 records implementation findings, not a new design round.**
|
||
The acceptance in §4 is unchanged and was approved at revision 3; §8
|
||
below is what building Stage 2 established, including two places where
|
||
this document's own account of a mechanism was imprecise.
|
||
|
||
**Revision 2 → 3** exists because the implementation discovered a state
|
||
the contract did not allow, and the contract — not the implementation —
|
||
was what needed changing. Acceptance 3 offered a binary: carry an
|
||
incumbent in with a signature and evidence, or remove it as never
|
||
substantiated. **Both incumbents are neither**, and shipping a third
|
||
state while the governing criterion still said "two" would have made the
|
||
framing describe something the branch does not do.
|
||
|
||
Revision 3 also **retires the phrase "mechanism named"**, which
|
||
overstated what the audit found. The a33 audit proves an assertion
|
||
string and a historical claim exist; the m6_8 audit proves a test is
|
||
timing-based.
|
||
**Neither establishes a failure mechanism** — no occurrence was ever
|
||
**linked or captured**, so nothing is known about how either fails.
|
||
(Not "never observed": someone may well have seen one and not recorded
|
||
it. What is established is the absence of a *record*, which is the only
|
||
thing this audit can speak to.) They become
|
||
**audit notes A1/A2**, not registry rows, and `R`-numbers are reserved
|
||
for signatures with linked evidence.
|
||
|
||
Revision 2
|
||
separates a machine-matchable signature from verbatim, variable CI
|
||
output; preserves historical incident evidence while centralizing live
|
||
triage policy; gives retirement a causal rule rather than an arbitrary
|
||
green-run count; and corrects the rerun rule.
|
||
|
||
Four red CI incidents across #213 and #214 were each correctly judged
|
||
"not caused by this PR" — and #214's case is airtight, because it is
|
||
docs-only and its tree is byte-identical to a green `main`. **That
|
||
proves the PRs did not cause them. It does not prove they are harmless
|
||
environmental noise, and three of the four have a specific, findable
|
||
mechanism.**
|
||
|
||
This lane separates those two claims, which the current process
|
||
conflates.
|
||
|
||
---
|
||
|
||
## 0. Coherence impact (COHERENCE §20)
|
||
|
||
- **Journey steps touched:** none.
|
||
- **Interaction islands:** none.
|
||
- **Config registry adoption:** none.
|
||
- **Background-work attribution:** none.
|
||
- **Why it belongs on the board:** it protects §19's acceptance-test
|
||
ratchet and every arc that reads a red run. A flake list that is
|
||
wrong, incomplete, or keyed by test *name* rather than *signature*
|
||
makes "rerun before concluding" a habit rather than a judgement — and
|
||
a real regression arriving in that stream is indistinguishable from
|
||
the noise it hides in.
|
||
|
||
---
|
||
|
||
## 1. Ground truth (measured at `bfb97c6`)
|
||
|
||
### 1.1 The accounting: FOUR incidents, THREE tests, FOUR signatures
|
||
|
||
The registry must count **signatures**, not test names. The process test
|
||
alone produced two, with different mechanisms and different causal
|
||
status — collapsing them under one name would have hidden a possible
|
||
product defect behind a known-flaky label.
|
||
|
||
The registry key is an exact **test selector + job/flavor + normalized
|
||
match rule**, not a pasted panic block. PIDs, elapsed times, and rendered
|
||
OS-error suffixes vary between runs; each row therefore names the
|
||
required invariant fragments and the evidence link preserves the
|
||
verbatim occurrence. A row matches only when every listed requirement
|
||
is satisfied; where a requirement lists alternative platform renderings,
|
||
one of those alternatives suffices. A test-name match by itself never
|
||
matches a registry entry.
|
||
|
||
| # | exact test selector | job / flavor | required signature fragments | causal status |
|
||
|---|---|---|---|---|
|
||
| 1 | `--lib async_runtime::tests::supersede_cancels_in_flight_job_within_50ms` | macOS / luajit | `supersede did not cancel within 50ms` | **measurement design** — see §1.2 |
|
||
| 2 | `--lib process::tests::a_successful_signal_disposition_depends_on_whether_it_is_fatal` | macOS / lua54 | `leader=exited(signal SIGUSR1)` | **test race** — see §1.3 |
|
||
| 3 | *(same test)* | macOS / lua54 | all of `EPERM`, `measured_group=unobservable(` + `ESRCH` / `No such process`, and `leader=live` | **UNRESOLVED — possible product defect** — see §1.4 |
|
||
| 4 | `--test vterm_stage2_acceptance terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt` | macOS / luajit | both `left: []` and `right: [49]` | **test race** — see §1.5 |
|
||
|
||
Evidence:
|
||
[#213 run 30826884642](https://github.com/levineuwirth/pmacs/actions/runs/30826884642),
|
||
[#213 run 30927084982 attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30927084982/attempts/1),
|
||
[#214 run 30932558752 attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30932558752/attempts/1).
|
||
|
||
### 1.2 Signature 1 — the 50ms budget measures more than it claims
|
||
|
||
```rust
|
||
let first = rt.dispatch_sleep(2_000, Some("search"));
|
||
// Let the worker pick the job up so cancel hits a running job.
|
||
thread::sleep(Duration::from_millis(15));
|
||
let started = Instant::now();
|
||
let _second = rt.dispatch_sleep(2_000, Some("search"));
|
||
while !rt.is_complete(first) {
|
||
assert!(started.elapsed() < Duration::from_millis(50), "supersede did not cancel within 50ms");
|
||
let _ = rt.tick();
|
||
thread::sleep(Duration::from_millis(1));
|
||
}
|
||
```
|
||
|
||
**Two design problems, and neither is fixed by a bigger number.**
|
||
|
||
- **The premise is a sleep.** `thread::sleep(15ms)` is asserted-by-comment
|
||
to mean "the worker picked the job up". On a loaded runner it may not
|
||
have, in which case the test measures cancellation of a *queued* job —
|
||
a different code path, already covered by the sibling test — while
|
||
claiming to measure a running one.
|
||
- **The clock includes the observer.** `started` begins before the
|
||
second dispatch, and the budget is consumed by the test's own
|
||
`tick()` + `sleep(1ms)` pump loop. Under scheduling pressure the
|
||
measured interval is dominated by when *the test* got scheduled, not
|
||
by when the worker observed the cancel flag.
|
||
|
||
**Widening 50ms to 200ms would make it pass and measure nothing more.**
|
||
The open question is what this test should assert: a latency bound needs
|
||
a clock the observer does not participate in, or the assertion should be
|
||
reformulated as ordering ("the first settles Cancelled before the second
|
||
completes") rather than duration.
|
||
|
||
### 1.3 Signature 2 — `Started` does not prove the trap is installed
|
||
|
||
```rust
|
||
spec.args = vec!["-c".into(), "trap '' USR1; sleep 30".into()];
|
||
let id = sup.spawn(spec).expect("spawn");
|
||
let pid = spawn_started_pid(&mut sup, id); // waits for ProcessEventKind::Started
|
||
sup.signal(id, Signal::SIGUSR1).expect("USR1 delivers");
|
||
```
|
||
|
||
`spawn_started_pid` waits for `ProcessEventKind::Started { pid }`, which
|
||
is emitted when the process is **spawned** — not when `/bin/sh` has
|
||
parsed and installed `trap '' USR1`. **SIGUSR1's default disposition is
|
||
terminate**, so a signal delivered inside that window kills the child,
|
||
and the record is `exited(signal SIGUSR1)` instead of `Running`.
|
||
|
||
The fixture's own comment states the requirement it does not enforce:
|
||
*"Ignore USR1 so the successful non-fatal signal cannot end the child
|
||
and confuse the state assertion with a real exit."* That is exactly the
|
||
confusion observed.
|
||
|
||
This is the handoff's **"wait predicate weaker than the assertion"**
|
||
race: the predicate is "process exists", the assertion needs "trap
|
||
installed".
|
||
|
||
### 1.4 Signature 3 — the live-leader EPERM, deliberately unresolved
|
||
|
||
`EPERM, measured_group=unobservable(ESRCH), leader=live` is **not
|
||
established as a test race**. It is the group-target behaviour the
|
||
process-signal lanes have circled three times:
|
||
|
||
- #176 established that a group-directed `kill` returned EPERM while the
|
||
leader was observed alive by a real `try_wait`, retiring "EPERM cannot
|
||
happen for our own children" as a reason to discard the errno.
|
||
- #200 added `measured_group` from `getpgid`, the only field able to
|
||
disagree — and it is reported here as `unobservable(ESRCH)`, meaning
|
||
the group could not be measured at all.
|
||
- The reap-ledger lane parked every disposition change pending exactly
|
||
this evidence.
|
||
|
||
**This lane does not resolve it and must not appear to.** Its registry
|
||
entry carries causal status **UNRESOLVED — possible product defect**,
|
||
and its retirement condition is a diagnosis, not a green rerun. Folding
|
||
it under the same "macOS signal timing" label as signature 2 is how a
|
||
real defect acquires a flake's immunity.
|
||
|
||
### 1.5 Signature 4 — a readiness predicate satisfied by an empty file
|
||
|
||
```rust
|
||
fn wait_for_file(path: &Path, timeout: Duration) -> Vec<u8> {
|
||
loop {
|
||
if let Ok(bytes) = fs::read(path) { return bytes; } // succeeds on 0 bytes
|
||
...
|
||
}
|
||
}
|
||
```
|
||
|
||
The probe writes readiness with `open(path,'wb').write(b'1')`. **`open()`
|
||
creates the file before `write()` fills it**, so `fs::read` can succeed
|
||
on a zero-byte file and `wait_for_file` returns `[]`. The caller then
|
||
asserts `== b"1"` and fails `left: [], right: [49]`.
|
||
|
||
The predicate is "readable"; the assertion is "contains `1`". This is
|
||
the same shape as §1.3 and is fixable at the helper. All four callers of
|
||
this helper require concrete non-empty content; the similar bottom-panel
|
||
helper already rejects empty reads.
|
||
|
||
### 1.6 Live triage policy and historical evidence are mixed together
|
||
|
||
Flake language currently appears in the handoff's two operational
|
||
rules, `docs/active-work.md`, and several landed framing documents. It
|
||
is not all duplication. The process-signal and reap-ledger framings, for
|
||
example, preserve exact historical occurrences and the reasoning those
|
||
lanes built from them; replacing that evidence with a pointer would
|
||
make a durable framing depend on a mutable registry.
|
||
|
||
The real duplication is **live classification and triage policy**. The
|
||
handoff's hazards list names three tests — two of which are not among
|
||
the four incidents seen here — while three of these four are absent.
|
||
Elsewhere, historical occurrence notes, forward-looking risk warnings,
|
||
and current "known flaky" claims are written in the same voice even
|
||
though they require different treatment.
|
||
|
||
A list that is both stale and incomplete is worse than none: it confers
|
||
"known flaky" on whatever happens to be named, and withholds it from
|
||
everything else.
|
||
|
||
### 1.7 What is NOT established
|
||
|
||
- **No incident has been reproduced locally.** All four are macOS-only
|
||
and this machine is Linux. The mechanisms in §§1.2–1.5 are read from
|
||
source and from CI signatures, not from a local repro.
|
||
- **Frequency is unmeasured.** Four incidents across two PRs is not a
|
||
rate. The registry records occurrences so a rate can accumulate;
|
||
it does not claim one now.
|
||
- **Signature 3's cause remains unknown**, by design (§1.4).
|
||
|
||
---
|
||
|
||
## 2. Questions
|
||
|
||
- **Q#MCI1 — where does the registry live? DECIDED:** one dedicated
|
||
`docs/ci-red-signatures.md`, with the handoff's operational rule
|
||
pointing at it. The handoff is a briefing; the registry is an
|
||
occurrence ledger whose rows remain available after retirement.
|
||
- **Q#MCI2 — what retires an entry? DECIDED:** a mechanism-specific
|
||
causal result, not N green runs. A known-race entry requires hardening
|
||
that removes the named mechanism plus a discriminating acceptance
|
||
witness for the stronger predicate. A measurement-design entry
|
||
requires its owning lane to replace or justify the measurement and
|
||
pin the resulting claim. An unresolved entry requires diagnosis and
|
||
an explicit disposition. Main-job greens remain occurrence evidence,
|
||
but cannot retire any row by themselves; retired rows remain in the
|
||
history with their disposition.
|
||
- **Q#MCI3 — does signature 1 get reformulated or re-measured?** §1.2
|
||
argues its budget measures the observer. Reformulating as an ordering
|
||
assertion changes what the test proves; keeping a duration means
|
||
finding a clock the pump loop does not participate in. **This is the
|
||
one question this lane should not answer alone** — it is the
|
||
async-runtime lane's design call.
|
||
- **Q#MCI4 — does hardening ship before or with the registry? DECIDED:**
|
||
the user has already answered: **registry now, hardening next.**
|
||
Recorded here so the sequencing is visible in the document rather
|
||
than only in the conversation.
|
||
|
||
---
|
||
|
||
## 3. Bets
|
||
|
||
- **Bet 1 — signatures 2 and 4 disappear under hardening**, because both
|
||
have a named mechanism and a fix at the readiness predicate.
|
||
*Falsified if either recurs after the predicate is strengthened.*
|
||
- **Bet 2 — a signature-keyed registry refuses name-based immunity.** A
|
||
future failure in the process test which does not contain all of row
|
||
2's or row 3's required fragments is a new incident, not a known
|
||
flake. *Falsified if the operational rule permits a test-name-only
|
||
match.*
|
||
- **Bet 3 — green reruns do not erase unresolved evidence.** Signature
|
||
3 remains unresolved until the process-signal lane diagnoses and
|
||
disposes it, whether or not later runs pass. *Falsified if a green run
|
||
changes that row's status or retirement condition.*
|
||
|
||
---
|
||
|
||
## 4. Acceptance
|
||
|
||
**Stage 1 — the registry (this lane's first PR):**
|
||
|
||
1. **One authoritative table**, with a row per **signature**: exact test
|
||
selector, job and Lua flavor, normalized machine-match rule, evidence
|
||
link to the verbatim occurrence, causal status, and mechanism-specific
|
||
retirement condition. Matching requires the selector, job/flavor,
|
||
and every required fragment; variable values are explicitly
|
||
normalized rather than silently abbreviated.
|
||
2. **One live triage policy.** Operational duplicate classifications and
|
||
rerun rules become pointers to the registry. Historical occurrence
|
||
evidence stays where it supports a landed framing; a relevant note
|
||
may gain a registry status link, but its evidence and reasoning are
|
||
not replaced. Forward-looking risk statements are audited as risks,
|
||
not silently promoted to known flakes.
|
||
3. **The three tests named in the current handoff list are audited**,
|
||
into one of **three** states — the third was found by doing the audit
|
||
and is why this is revision 3:
|
||
- **carried as a registry row** (`R`-numbered) when a signature and a
|
||
linked occurrence both exist;
|
||
- **removed** when nothing substantiates it at all;
|
||
- **recorded as an audit note** (`A`-numbered) when a *historical
|
||
claim* exists but **no occurrence was ever linked**. An audit note
|
||
is not a registry row, cannot be matched against a red run, and
|
||
confers nothing.
|
||
|
||
**No entry survives on reputation, and an audit note is not a weaker
|
||
row — it is a different kind of statement.** A row says "this was
|
||
seen, here is the evidence"; a note says "someone recorded a belief
|
||
and no occurrence backs it."
|
||
4. **The rerun rule is replaced**, not softened:
|
||
- a **green rerun after a red** establishes intermittence only; it
|
||
does not establish environmental cause, harmlessness, or retirement;
|
||
- the **same signature on the rerun** is a second occurrence and
|
||
remains blocking pending investigation or a merge-base control;
|
||
- a **different signature** is a new incident and is judged
|
||
independently. A known test name confers no immunity.
|
||
5. Signature 3 is recorded **UNRESOLVED — possible product defect**, and
|
||
its retirement condition is a diagnosis, never a green rerun.
|
||
|
||
**Stage 2 — hardening (a separate PR):**
|
||
|
||
6. The vterm Stage 2 `wait_for_file` requires the **expected content**
|
||
(preferred) or at minimum a non-empty result, so the predicate
|
||
matches the assertion. Its four callers all require concrete
|
||
non-empty content; the bottom-panel helper already rejects empty
|
||
reads and is not evidence for widening this change further.
|
||
7. The USR1 fixture proves the **trap is installed**, not merely that
|
||
the process started. The child publishes readiness after installing
|
||
the trap, and the test waits on that.
|
||
8. Signature 1 is **not** fixed by widening the budget (Q#MCI3).
|
||
9. Both hardened tests run **repeatedly** (a repetition set, as the
|
||
reap-ledger lane did) rather than once, because a single green run of
|
||
a formerly intermittent test proves nothing.
|
||
|
||
**Quarantine — only if hardening fails:**
|
||
|
||
10. A quarantined test moves to a **separate, still-blocking CI step**.
|
||
Never `#[ignore]`, never `continue-on-error`, never a silent
|
||
retry-to-green. A quarantine that stops failing the build is a
|
||
deletion with extra steps.
|
||
|
||
---
|
||
|
||
## 5. Parked
|
||
|
||
- **Resolving signature 3.** It belongs to the process-signal /
|
||
reap-ledger lanes, which have already parked three tolerance rules
|
||
pending this class of evidence.
|
||
- **The `--no-fail-fast` gap and the crdt job's PTY deadlines**, both
|
||
recorded in the CI CRDT lane. Related in spirit, separate in scope.
|
||
- **A general flake-rate dashboard.** The registry accumulates
|
||
occurrences; turning that into a rate with alerting is its own thing.
|
||
- **Linux and GPU flakes.** `a33_headless_terminal_frame_paints_...`
|
||
and `m6_8_supervisor_reaps_...` were audited under acceptance 3 and
|
||
became **audit notes A1/A2** — claims with no linked occurrence.
|
||
**A1's job runs on Ubuntu**, so the registry is not macOS-only even
|
||
now; A2's job was never recorded. This lane's four *evidenced*
|
||
incidents are macOS, which is a fact about them and not about the
|
||
file.
|
||
|
||
---
|
||
|
||
## 6. Gates
|
||
|
||
The standing `CLAUDE.md` suite. Stage 1 is documentation-only and adds
|
||
no test; **its verification is acceptance 3** — the audit that no entry
|
||
survives on reputation. Stage 2 adds the repetition sets of acceptance 9.
|
||
|
||
---
|
||
|
||
## 7. Stage 2 implementation notes (revision 4)
|
||
|
||
Stage 2 landed acceptance 6–9 on `ci-signal-hardening`. Four findings,
|
||
two of which correct this document.
|
||
|
||
### 7.1 §1.3 named the right window and the wrong assertion
|
||
|
||
`leader=exited(signal SIGUSR1)` is rendered **only on a failed `kill`**,
|
||
by `signal_failure_report`. The USR1 cannot be the call that failed — it
|
||
is the call that did the killing — so the failing call is the **SIGTERM
|
||
that follows**, and the assertion that blew up is
|
||
`.expect("TERM delivers")`, not the `Running` state check. §1.3's "the
|
||
record is `exited(signal SIGUSR1)` instead of `Running`" reads as though
|
||
`ProcessState` carries that value; it does not, and nothing ticks
|
||
between the two calls, so the state assertion could not have seen an
|
||
exit. **The row's fragment and its mechanism were both right.** Why a
|
||
group-directed TERM then found no group is *not* established, and the
|
||
fix does not depend on it.
|
||
|
||
### 7.2 The fixture had a second dependency nobody had named
|
||
|
||
These signals are **group-directed**, so the fixture's `sleep 30` — if
|
||
the shell *forks* it — is an **untrapped member of the same group**, and
|
||
the USR1 kills it even when the trap is installed correctly. That the
|
||
old fixture survived at all depended on bash and dash suppressing the
|
||
fork for the last command of a `-c` script when no non-ignored trap is
|
||
set. Verified locally, and the suppression is visible in `ps`: with
|
||
`trap '' USR1` present, the only member of the group is the **shell's
|
||
own pid** running `sleep` — the shell replaced itself, so there was
|
||
never an untrapped child to kill.
|
||
|
||
**An optimization is not a guarantee.** The fixture now says `exec`, so
|
||
the group holds exactly one process and the ignored disposition survives
|
||
by POSIX. This is a second readiness-shaped defect in the same fixture:
|
||
the test depended on a state it never established.
|
||
|
||
### 7.3 §1.5 scoped the fix one function too narrowly
|
||
|
||
`wait_for_published_file` — in the same suite, one function above
|
||
`wait_for_file` — gates the real-TUI smoke's `assert_eq!(…, b"1")` on
|
||
the identical "`fs::read` succeeded" predicate. §1.5's note that the
|
||
*bottom-panel* helper already rejects empty reads is about a different
|
||
file and correctly refuses scope creep there; it does not cover this
|
||
one. Fixing only `wait_for_file` would have left R4's mechanism live
|
||
under a different selector, where the registry would have had to judge
|
||
the recurrence a **new incident**.
|
||
|
||
### 7.4 What a witness had to be, in each case
|
||
|
||
- **R4's witness reproduces the row.** The file is created empty and
|
||
filled later, so the window is certain rather than load-dependent, and
|
||
with the old predicate restored it fails `left: []`, `right: [49]` —
|
||
the row's two required fragments, verbatim.
|
||
- **R2's witness could not reproduce the row on Linux**, because the old
|
||
fixture passes here; the pre-trap window is real everywhere but only
|
||
macOS ever reported the failure. So the witness **widens the window
|
||
deliberately** (the fixture sleeps before `trap`) and proves survival
|
||
by the child's **exit disposition** — `Signaled { signal: "SIGUSR1" }`
|
||
versus `"SIGTERM"` — rather than by an absence observed within a
|
||
window, which would have been another timing assumption in a lane
|
||
about timing assumptions.
|
||
- Bet 1 is therefore **supported but not yet resolved**: both mechanisms
|
||
are gone and both are witnessed, but the bet is falsified only by a
|
||
recurrence in CI, which only time can supply.
|
||
|
||
## 8. Branch plan
|
||
|
||
Two PRs, in this order — **both now exist**:
|
||
|
||
1. **`macos-ci-signal-integrity`** — the registry, the pointer
|
||
rewrites, the audit, and the rerun rule. No code. **Merged as #215.**
|
||
2. **`ci-signal-hardening`** — the `wait_for_file` predicate and the
|
||
USR1 trap readiness, each with a repetition set. Signature 1 is
|
||
referred to the async-runtime design question rather than patched.
|
||
|
||
Quarantine, if it happens, is a third and is scoped by what hardening
|
||
fails to fix.
|