Skip to content

ci(v2.3.9): open "Crucible" — what the gates actually cover - #408

Merged
doublegate merged 11 commits into
mainfrom
feat/v2.3.9-crucible
Aug 19, 2026
Merged

ci(v2.3.9): open "Crucible" — what the gates actually cover#408
doublegate merged 11 commits into
mainfrom
feat/v2.3.9-crucible

Conversation

@doublegate

Copy link
Copy Markdown
Owner

Opens v2.3.9 "Crucible" — testing, correctness, and what the gates actually cover.

A crucible is where something is tested to destruction rather than inspected. This is not a feature release: the v2.3.x line added five tools in four releases, and the recurring finding across all of them was never that the emulation was wrong — it was that a check reported a pass it had not earned.

The finding that frames the release

main's ruleset requires exactly one status context, CI success. That job fails on failure or cancelled — and skipped is in neither list.

test-roms (the accuracy battery) was FULL-run only, so on a regular feature PR it was skipped. The only gate on merging was therefore satisfied without the accuracy suites having run at all. #396 (a PPU fix that legitimately moved visual_regression__scanline_frame_180) and #403 (the vector update) are the worked example: both PRs were correct, the process was not. And there is no merge queue — verified, no merge_group event appears in the run history — so nothing caught it later either.

What lands

Item
A5 test-roms runs on PRs that touch paths able to move a vector (chip crates, core, gamedb, harness, tests/)
A5b .github/scripts/apt-install-retry.sh — per-command timeout + 3 attempts
A5c actionlint as a pinned pre-commit hook
B the frontend sweep, both questions
C the Latency Oracle's interim end-to-end figure
docs to-dos/plans/v2.3.9-crucible-plan.md, six standing rules in AGENTS.md

A5's cost is measured, not assumed: over the last 40 merged PRs, 11 touch those paths and 29 do not — so ~72% still skip the 29-minute battery and the saving the full-run flag exists for is preserved. rustynes-gamedb is included for a non-obvious reason: it rewrites the iNES header on load, so it changes what the emulator is before a cycle runs.

A5b exists because apt provisioning hung four times across two PRs in one day, always in a setup step and never in a compile or test step, once blocking the v2.3.7 release PR for two hours. A job timeout bounds the damage but cannot notice — a stalled fetch inside a 25-minute budget is indistinguishable from a slow job. Verified by stubbing sudo/timeout/sleep: all-fail gives three warnings and exit 1; fail-fail-succeed names the attempt it succeeded on.

Three results that were not what the carried-forward notes expected

  • Item B question 2 (does it put back what it borrowed?) — clean. snapshot_schema_audit.rs already enumerates every non-serialized field; cross-referencing its 26 output-only entries, only write_attrib and reg_attrib are cumulative, and both are carried. The method's limitation is the finding: the reasons are free text, and a keyword pass mis-sorted 17 of 26.
  • Item B question 1 — a candidate, not a defect. The needs_nes render arm takes two lock acquisitions per redraw (framebuffer copy, then the egui pass where panels read), so the screen may show frame N while the panel describes N+1. Not demonstrated — the experiment is written down instead, because the obvious fix puts composite work back under the emulator lock, which is exactly the regression v2.3.0 fixed.
  • Item C — not implementable as specified. Both figures need sums of two or three separate percentile series, and summing p95s is as invalid as differencing them. What ships is the honest subset: lag + render_work.p95, valid only because the lag term is a constant, labelled as such, declining below 60 samples, with run-ahead subtracted.

A correction carried in the branch

.github/actionlint.yaml already existed since v2.2.3. I overwrote it with Write on a path I had never read, and then recorded the resulting breakage as a finding ("adding the config activated a dormant check") — which was false: removing the agy declaration is what made the label unknown. Restored to main's content with the suppression appended; the diff is purely additive, checked with git diff origin/main. The plan carries the retraction beside the original claim.

Verification

fmt, actionlint clean tree-wide, workspace clippy, frontend under debug-hooks (520 tests), both wasm32 invocations, rustdoc, no_std thumbv7em.

No emulation-core file is touched, so AccuracyCoin and nestest are unaffected by construction — and this PR touches no accuracy path, so test-roms should be skipped here. That is A5's negative demonstration: if it runs, the filter is too broad.

doublegate and others added 11 commits August 19, 2026 08:59
…emise checked

A crucible is where something is tested to destruction rather than inspected,
and that is what this release is about: the gates, what they cover, and what they
only appear to cover. Not a feature release. The v2.3.x line has added five tools
in four releases, and the recurring finding across all of them has not been that
the emulation was wrong — it is that a check reported a pass it had not earned.

**Item A5, `test-roms` at review time, is designed rather than sketched**, and
the design is checked against the real case rather than argued from principle.

The gap stated precisely: `setup` computes one `full` flag, and `test-roms` runs
only when it is true — push to `main`, the merge queue, dispatch, the weekly
cron, and `release/*` PRs. A regular feature PR never runs the accuracy battery,
so an accuracy regression cannot be caught on the PR that causes it.

The proposal is to scope by path rather than by event: a visual vector can only
move if something that produces one changed, and a frontend-only PR — most of
this line — cannot move `scanline_frame_180` whatever it does. A second
`paths-filter` output covering the chip crates, core, gamedb, the harness and
`tests/`, with `test-roms` running when it OR the existing full flag is true.

Verified against the worked example rather than recalled: #396 touched
`crates/rustynes-ppu/src/ppu.rs` and #403 touched
`crates/rustynes-test-harness/tests/snapshots/`, so both would have run the
battery on the PR. `rustynes-gamedb` is included for a non-obvious reason — it
rewrites the iNES header on load, so it changes what the emulator is before a
cycle runs, which is how the v2.3.4 Sachen defect reached users.

**The plan's own open question is settled in the same commit**, because leaving
it open is how a design gets written against an assumption. It asked whether a
merge queue already runs the battery before merge, which would have made A5 much
less valuable. It does not: no `merge_group` event appears in the run history,
and `main`'s active ruleset requires exactly one status context, `CI success`.

That check sharpens the problem rather than softening it. `CI success` is the
single gate on merging, and on a regular feature PR it is satisfied **without the
accuracy battery having run** — a skipped job does not fail the aggregate. The
required check reports a pass for a property it did not test, which is the same
shape as every other finding in this line.

Item B generalises the v2.3.6-v2.3.8 frontend defects into the class they now
demonstrably are — four instances, two questions to sweep every v2.3.x feature
against — and notes that the "did it put back what it borrowed" question has a
mechanical answer available: enumerate what is not serialized, because that is
exactly the set a snapshot round trip cannot restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…k it

`test-roms` was FULL-run only — push to `main`, the merge queue, dispatch, the
weekly cron, and `release/*` PRs. A regular feature PR never ran the accuracy
battery, so an accuracy regression could not be caught on the PR that caused it:
it landed, turned `main` red, and needed a second PR to fix.

What sharpens this from an inconvenience into a hole: `main`'s active ruleset
requires exactly ONE status context, `CI success`, and that job fails on a need
whose result is `failure` or `cancelled` — **not** `skipped`. So a skipped
`test-roms` satisfied the only gate on merging, and the required check reported a
pass for a property it had never tested.

Scoped by path rather than by event, because a visual or accuracy vector can only
move if something that PRODUCES one changed. A frontend-only PR cannot move
`scanline_frame_180` whatever it does. A second `paths-filter` output covers the
chip crates, core, gamedb, the test harness and `tests/`; `test-roms` now runs
when that fires OR on a full run as before.

The cost model the full-run flag exists for is preserved rather than traded away,
and that is measured rather than assumed: over the last 40 merged PRs, 11 touch
these paths and 29 do not, so roughly 72% of PRs still skip the battery entirely.
Had the split come out near half, this would have needed restating rather than
adopting.

Verified against the worked example rather than recalled. #396 touched
`crates/rustynes-ppu/src/ppu.rs` and #403 touched
`crates/rustynes-test-harness/tests/snapshots/`, so both would have run the
battery on the PR where the regression was introduced and on the one that fixed
it.

`rustynes-gamedb` is included for a reason that is not obvious from its name: the
per-game database rewrites the iNES header on load, so it changes what the
emulator IS before a single cycle runs — which is how the v2.3.4 Sachen defect
reached users. `rustynes-probe` is deliberately EXCLUDED: it is an output-only
analysis engine with no path to affecting emulation, so including it would cost
29 minutes for nothing.

Two demonstrations, because a gate that cannot fail is not a gate. The negative
one rides on this PR: it touches only `.github/` and `to-dos/`, so `test-roms`
must be SKIPPED here — if it runs, the filter is too broad. The positive one is
the next PR, v2.3.8's branch, which touches `crates/rustynes-ppu` and will be the
first to exercise the gate for real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e finding

Item B asked whether any state that lives outside the save state is missing a
guard, and proposed enumerating what is not serialized as the mechanical handle.

That enumeration already exists. `snapshot_schema_audit.rs` lists every
deliberately-excluded field with a written reason and fails the suite when a new
one appears without one, so there is no second list to build — the reuse-before-
writing rule applies and this is what it points at.

Cross-referencing its 26 output-only fields against "must a guard carry this
across a same-timeline restore?" splits them cleanly once the `see <sibling>`
indirections are resolved. Everything is rebuilt every frame or every fetch
except two: `write_attrib` and `audio_prov`'s `reg_attrib`, both cumulative in
the sense that matters — "which instruction last wrote this" can be answerable
from thousands of frames ago.

Both are already carried, by `ProvenanceStash` and `AudioProvenanceStash`. So the
sweep finds NO missing guard, and that negative result is the useful output: the
v2.3.7 fix covered the whole set rather than merely the members that had been
reported.

The actual v2.3.9 finding is the limitation of the method that produced it. The
classification required reading prose. `attrib_cycle` says only "see
`attrib_pc`"; the ten `hd_*` fields say "hd-pack fetch telemetry" without saying
whether that is per-frame; and a keyword pass over the reasons mis-sorted 17 of
26 on the first attempt — my own first attempt, which is why the number is
quoted rather than estimated.

So the enumeration exists and the durability question it needs to answer is not
machine-checkable. Nothing stops the next output-only field from arriving with a
reason that is true but silent about whether it survives a restore, and the guard
that should carry it from never being written.

Recorded as a sized proposal rather than a commitment: give each field a
machine-readable durability tag and assert every cumulative one is named by a
guard, which converts a prose cross-reference done once into a gate. It needs a
way to enumerate guards, so it is not free, and pretending otherwise in a plan is
how an estimate becomes a promise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carried forward as "internal lag plus the frontend pipeline cost `perf.rs`
already tracks". That is very nearly right, which is what makes it worth
investigating before writing rather than after.

The panel today reports `frames * frame_ms` labelled "about N ms of the game's
own delay" — correctly scoped, and derived from the console's own frame duration
rather than a hardcoded 16.639, which is what keeps PAL and Dendy honest.

Three things the one-line note omits:

WHICH FIGURE. There are two and they are not interchangeable. `RenderPerf`
deliberately splits `wait` — the blocking present — out of `total`, because under
Fifo a present that blocks until vblank is correct behaviour rather than a stall.
So "internal lag + work + lock" answers how much could in principle be removed,
while "internal lag + total" answers how long until the pixel is actually on
screen. Publishing one under the other's name is the exact class of error this
release exists to find, so the design is to report both, labelled — the same
refusal to collapse distinct answers that keeps `None` and `Some(0)` apart in
this very panel.

RUN-AHEAD. The Oracle measures the game's internal lag, a property of the ROM and
independent of the frontend. Run-ahead then removes up to `depth` frames of it.
An end-to-end figure that ignores the configured depth overstates what the user
experiences, and overstates it worst precisely when they have taken the panel's
own advice.

THE PERCENTILE ARITHMETIC, which is sound here for a reason worth writing down.
`perf.rs` already records that `work p95 = total p95 - wait p95` is invalid, and
that the first attempt at it published a table whose `work p95` sat below its
`work p50` — percentiles cannot decrease, which is how the error announced
itself. Adding is a different operation: internal lag is a CONSTANT, and adding a
constant to a distribution shifts every percentile by exactly that constant, so
`lag_ms + work_p95` is a real p95 of the total. The valid and invalid cases look
alike at a glance, so the distinction is recorded at the design rather than left
for whoever writes the arithmetic.

No code yet, deliberately. A latency number whose definition is ambiguous is
worse than no latency number, and which of the two figures leads is a maintainer
call rather than an implementation detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es today

The cross-compile gate provisions glibc headers for bindgen with a bare
`apt-get update && apt-get install`. Both are network fetches with no timeout of
their own, so a stalled mirror hangs the step until the JOB timeout fires — 25
minutes for `libretro-cross` — and the run is then reported as cancelled, which
reads as infrastructure noise rather than as the specific thing that happened.

Not hypothetical, and not rare. During the v2.3.7 cut this hung FOUR times across
two PRs: twice in `rust-setup`, once in the armhf provision, once in the aarch64
provision. Every one was in a setup or provisioning step; not one was in a
compile or test step. Each cost 25-45 minutes and a manual re-run, and the last
of them blocked the release PR itself.

The per-job `timeout-minutes` from #400 did exactly what it was for — it bounded
the damage. What a job-level timeout cannot do is NOTICE: a stalled fetch inside
a 25-minute budget is indistinguishable from a job that is merely slow.

Two bounds, doing different jobs. `timeout` per command, so a stalled fetch fails
in minutes rather than consuming the whole budget — the job timeout is a backstop
against a hang, this is the thing that notices one. And three attempts with
linear backoff, because the observed failure is transient: a re-run has cleared
it every time.

Every attempt emits a `::warning::`, including attempts that eventually succeed.
A run that needed three and a run that needed one are identical in the job's
conclusion, and that difference is the early warning that the mirrors are
degrading — the same reasoning that makes `Inconclusive` a distinct answer
everywhere else in this line.

Verified by stubbing `sudo`, `timeout` and `sleep` rather than by reading it:
all-fail gives three warnings and exit 1; fail-fail-succeed gives two warnings,
names the attempt it succeeded on, and exits 0. My first stub was mis-designed —
it assumed two `sudo` calls per failing attempt, when a failed `update`
short-circuits the `&&` and makes one — which is worth recording because the
wrong test looked like a script bug for a moment.

Deliberately not a general-purpose apt wrapper: one package, taken from a
workflow `env:` and never from event data, which is the injection vector the
Actions security guidance names, with a hard failure when unset. The sysroot
export moves to its own step so the retry script owns exactly the fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eview doubted

`check-yaml` proves a file is valid YAML. It says nothing about whether a key is
legal where it appears, and seven workflow files were edited during the v2.3.7
cut with no schema check at all.

The gap surfaced as a review disagreement on #406 that GitHub's own documentation
could not settle: is `timeout-minutes` valid on a job calling a reusable
workflow? Neither the workflow-syntax page nor the reuse-workflows page says.
`actionlint` encodes the job schema and answered in one command — it is not, and
the seven allowed keys are `name`, `uses`, `with`, `secrets`, `needs`, `if`,
`permissions`. So adding one is a syntax error rather than an ignored key, which
means the existing comment understated the consequence rather than overstating
it. Both that comment and the CHANGELOG entry now carry the evidence instead of
asserting the conclusion.

Adopted as a pre-commit hook, pinned to the installed version like every other
hook in that file, for the reason `markdownlint` already documents there: an
unpinned linter that gains a rule turns a green tree red on someone else's
machine.

Two suppressions were needed and both are against intent rather than against
correctness. `pgo.yml` carries two deliberately-disabled `if: false` steps, kept
rather than deleted so the shape survives for whoever re-enables them behind a
harness that benches the right binary. Suppressed in `.github/actionlint.yaml`,
scoped to that one file and that one message — and the scoping is VERIFIED, not
assumed: adding an `if: false` to `security.yml` still fails the lint. The cost
is written into the config rather than buried, because it is real — the rule is
now silent for all of `pgo.yml`, actionlint has no line-scoped ignore, and the
alternatives are worse (deleting the steps loses the documented shape, rewriting
the condition hides intent from the reader to satisfy a linter).

The more interesting finding is that adding the config ACTIVATED a dormant check.
The self-hosted `agy` runner label was reported as unknown only after
`actionlint.yaml` existed — before that, actionlint had nowhere to expect a
custom-label declaration and said nothing. A linter config is not inert: the
first run after introducing one is where the real inventory appears, not the run
before. The label is now declared, which is where a custom runner label should
have been documented regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a fix

The sweep's second question ("does it put back what it borrowed?") came back
clean. The first — "can the panel observe what it claims to?" — did not, and what
it found is a candidate rather than a confirmed defect. Recording the difference
is the point.

The lock discipline is sound where it matters most: `produce_one_frame` runs
under one `emu.lock()` held across the whole frame, so a UI read can never catch
a half-filled provenance store. Whatever a panel sees is some complete frame.

WHICH complete frame is the open question. In the `needs_nes` render arm — the
branch taken precisely when a debugger or tool panel is open — there are TWO lock
acquisitions per redraw, not one. The first copies the framebuffer, index
framebuffer, phase and HD tiles into staging, and is what the user ends up
seeing. The second, sixty lines later, is the egui pass where `run_shell_ui`
hands `&mut Nes` to the panels, and is where Pixel Provenance and Audio
Provenance read.

The guard is dropped between them, deliberately, so the composite work does not
hold the emulator. Which means the emulation thread may take the lock in that gap
and produce another frame — and if it does, the screen shows frame N while the
panel describes frame N+1. That would be a confidently wrong answer in the one
panel whose entire purpose is explaining the pixel you are looking at, and it is
the same shape as the v2.3.6 defect differing only in degree: empty then,
off-by-one now.

It is NOT demonstrated. It comes from reading the lock structure, and this line
has already retracted one conclusion drawn from reading rather than measuring, so
it is written down as a hypothesis with the experiment that settles it: record
the frame counter at both acquisitions under `debug-hooks` and count redraws
where they differ. Non-zero confirms; zero over a long capture with a panel open
bounds it.

Deliberately not fixed here, for a reason that is specific rather than cautious.
The obvious fix — merge the two acquisitions — puts the composite work back under
the emulator lock, which is exactly the regression v2.3.0 fixed. The likelier
shape is snapshotting provenance at the first acquisition alongside the
framebuffer. Choosing between them before knowing whether the race actually fires,
or how often, is how the composite work ended up under the lock in the first
place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ists

Correcting my own design from earlier in this line, found by trying to write the
code rather than by re-reading the plan.

That design argued the percentile arithmetic was sound. `perf.rs` records that
`work p95 = total p95 - wait p95` is invalid — differencing percentiles is not
the percentile of the difference, and the first attempt at it published a table
whose `work p95` sat BELOW its `work p50`. Adding, I argued, is different:
internal lag is a constant, and adding a constant shifts every percentile by
exactly that constant, so `lag_ms + work_p95` is a real p95.

That is true. It is also not enough, and the gap is that NEITHER figure the
design specifies is `lag + one series`. Avoidable latency needs
`lag + render_work + render_lock`; wall-clock delay needs those plus
`render_wait`. `PerfView` exposes all three as separate percentile series, so
both figures require summing two or three p95s — which is the identical error
`perf.rs` already retracted a table for, in the addition direction rather than
the subtraction one. The constant-shift argument rescues exactly one series, and
both figures need more than one.

So the note this item was carried forward on — "the frontend pipeline cost
`perf.rs` already tracks" — is wrong about "already". What is missing is a single
per-redraw series: end-to-end pipeline latency recorded as ONE sample per redraw
and percentiled as one distribution. That is an addition to `RenderPerf`, not a
panel change, and it is the real first step rather than the last one.

Recorded with a cheaper interim that does not lie: report
`lag_ms + render_work_p95` alone, labelled as game delay plus render work, and
state plainly that the vblank wait and lock contention are excluded. It answers
less than the carried-forward note promised and it answers it correctly, which is
the trade this line keeps choosing.

Worth naming the pattern, because it is now three for three in this release: the
sweep's second question came back clean, the first came back a hypothesis rather
than a defect, and this item came back not-yet-buildable. None of those is the
outcome the carried-forward notes anticipated, and each was cheap to establish
and expensive to have assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Item C's honest subset. The panel reported "about N ms of the game's own delay"
and stopped there; it now also reports game delay PLUS the renderer's own work,
which is the part of the pipeline that can be added without lying about it.

Three decisions, each visible in the code rather than only in the plan.

WHAT IS EXCLUDED, AND WHY IT HAS TO BE. `render_wait` (the blocking present) and
`render_lock` (mutex contention) are their own percentile series, and adding two
p95s is not the p95 of the sum. That is the error `RenderPerf::work` already
exists to prevent — it is kept as a real per-redraw series precisely because
`total p95 - wait p95` once produced a published table whose `work p95` sat below
its `work p50`. Summing has the same defect as differencing; only the direction
changes. What makes THIS sum legitimate is that the lag term is a constant, not a
distribution: adding a constant shifts every percentile by exactly that constant.
That argument rescues exactly one series, so the label says "game delay + render
work" and the panel states plainly what it leaves out. A true wall-clock figure
needs a single per-redraw end-to-end series — an addition to `RenderPerf`, not
arithmetic over what exists.

RUN-AHEAD IS SUBTRACTED. The Oracle measures the game's internal lag, a property
of the ROM; run-ahead removes up to `depth` frames of it. A figure ignoring the
configured depth overstates what the user experiences, and overstates it worst
exactly when they have taken this panel's own advice. Saturating rather than
wrapping, so a depth above the measured lag leaves zero.

IT DECLINES RATHER THAN GUESSING. Below sixty render samples there is no figure,
only a statement of how many samples exist and how many are needed. A percentile
over a handful of redraws is noise wearing a number's clothing, and printing it
would be this panel doing the exact thing it was built to refuse — the same
`None`-versus-`Some(0)` discipline that governs its main result.

The arithmetic is lifted out of the `egui` closure into `end_to_end_figure` so it
is testable rather than eyeballed, the same restructuring the Divergence Lens
needed for its verdicts. Four tests, three mutations, each failing exactly the
tests that pin it: dropping the run-ahead subtraction, removing the sample-count
refusal, and dropping render work from the sum.

`PerfPanelState::render_work` is the accessor, and it offers ONLY that series on
purpose — exposing `render_wait` or `render_lock` beside it would invite exactly
the sum this whole design refuses.

Verified: fmt, workspace clippy, `debug-hooks` and `full` frontend combos, both
wasm32 invocations, rustdoc, and the frontend suite at 516.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…se finding

`.github/actionlint.yaml` already existed on `main`, added in v2.2.3. It declared
the self-hosted `agy` label with a fuller rationale than what replaced it —
including why a typo'd label is worse than a failure (the job queues forever),
how to run the tool over the whole tree, and why that workflow cannot use a
hosted runner.

I overwrote it. A `Write` to a path that was never read first, which is the
standing read-before-write rule, and the file being unfamiliar to me is not the
same thing as the file being absent.

The consequence was not only a lost comment. The A5c commit recorded, as one of
three findings, that "adding the config activated a dormant check" — the `agy`
label reported unknown only after `actionlint.yaml` existed. That is false.
Removing the declaration is what made actionlint report the label; the "dormant
check" was self-inflicted breakage reported as a discovery. Left standing it
would have taught the next reader something untrue about how linter configs
behave.

Restored to `main`'s content with the `paths:` suppression APPENDED. The diff
against `main` is now purely additive, verified rather than asserted. The plan
carries the retraction alongside the original claim rather than quietly deleting
it, which is this project's convention for exactly this — the evidence that the
prose was wrong is worth more than a tidy page.

The real lesson is narrower and more useful than the false one: a config file for
a tool the repo already uses is precisely the kind of file that looks new because
YOU have not seen it. Adopting actionlint as a pre-commit HOOK implied nothing
about whether its configuration already existed, and I did not check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Things a future session would otherwise re-derive, or get wrong the same way.

A SKIPPED JOB SATISFIES `CI success`, which is `main`'s only required context.
That job fails on `failure` or `cancelled` and `skipped` is in neither list, so a
gate that did not run reports a pass for a property it never tested. `test-roms`
was full-run only, which is why an accuracy regression could not be caught on the
PR that caused it. There is no merge queue either — verified, not assumed — so
nothing caught it later. When judging whether a gate covers something, check
which contexts are required, what the aggregate treats as failure, and whether
the job actually ran; a green required check answers none of those alone.

EVERY SAME-TIMELINE RESTORE must hand-carry state living outside the save state.
v2.3.6 wired the provenance stash into the one call site a report named; three
more existed, so the Latency Oracle and RAM Atlas silently emptied both
provenance panels. `snapshot_schema_audit.rs` enumerates the excluded set and is
where to look — with the caveat that its reasons are free text and a keyword pass
over them mis-sorted 17 of 26, so the durability question is not machine-
checkable. Assert such state returns byte-identical, never merely "armed".

`timeout-minutes` IS NOT VALID on a `uses:` job, and actionlint is how to settle
that class of question when GitHub's docs do not. Recorded with the exact
diagnostic, because a review asserted the opposite and the docs are silent. Also
recorded: `.github/actionlint.yaml` has existed since v2.2.3 — extend it rather
than writing over it, which is how a false finding reached a commit body.

BOUND EVERY JOB AND EVERY FETCH INSIDE ONE. #400 bounded `ci.yml` alone; six more
workflows were unbounded, found when one hung two hours and blocked the v2.3.7
release. A job timeout bounds damage but cannot notice — a stalled fetch inside a
25-minute budget looks like a slow job and reports as cancelled.

SUMMING PERCENTILES is as invalid as differencing them. The subtraction case is
already in `docs/performance.md`; the addition case broke a v2.3.9 design, and
the one legitimate exception (adding a constant) is why exactly one series is
exposed to the Latency Oracle.

`grep -i` ON A SHORT TOKEN matches more than intended: `-i "RAM"` matches
"f-ram-ebuffer", so reading the AccuracyCoin result that way returns the
known-buggy framebuffer decoder's 120 instead of the authoritative 141. Two more
false negatives the same session came from patterns that could not match at all.
A pattern that cannot match looks exactly like content that is not there.

Memory files updated alongside: two new entries, two existing ones extended
rather than duplicated (read-before-write gains the `Write`-clobbers-silently
failure mode; the percentiles entry gains the summing corollary).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 19, 2026 13:02
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 19a7e5e7-f506-4143-a9c6-65ad0f1d7ffe


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

Adds precision and bounds to CI by scoping the test-roms battery to relevant file paths, adding retries to apt provisioning, and introducing actionlint for strict workflow validation.

Blocking issues

None found.

Suggestions

  • .github/scripts/apt-install-retry.sh: Pass DEBIAN_FRONTEND=noninteractive to the apt-get install command (sudo DEBIAN_FRONTEND=noninteractive apt-get install -yq ...). Without this, apt-get can still hang if a package update unexpectedly prompts for configuration input, burning the entire timeout budget.
  • .github/scripts/apt-install-retry.sh: Run timeout under sudo (e.g., sudo timeout "$UPDATE_TIMEOUT" apt-get ...) rather than timeout over sudo. Running timeout sudo ... sends SIGTERM to sudo and relies on it to forward the signal to apt-get. This can occasionally misfire or orphan the child process, leaving the dpkg lock held and guaranteeing the next retry will fail.

Nitpicks

  • .github/actionlint.yaml: The keys under paths: are treated as regular expressions by actionlint. While .github/workflows/pgo.yml happens to work, it matches any character at the unescaped dots. It is safer to anchor and escape it: ^^\.github/workflows/pgo\.yml$$.
  • .github/workflows/ci.yml: In the test-roms job, you can omit the ${{ }} wrapper in the if: clause. GitHub Actions evaluates if conditions as expressions by default, allowing you to inline it without the >- folded block scalar.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR opens the v2.3.9 “Crucible” line by tightening the project’s “what did we actually test?” guarantees: it makes the heavyweight accuracy battery run on PRs that touch accuracy-relevant paths, hardens CI provisioning against apt hangs, and adds workflow-schema linting to pre-commit. It also exposes the render-work perf series so the Latency Oracle can incorporate the pipeline’s own (percentile) cost without duplicating perf plumbing.

Changes:

  • Condition test-roms execution on an accuracy paths-filter (CPU/PPU/APU/mappers/core/gamedb/harness/tests) so PRs that can move vectors run the accuracy battery even when not a full CI run.
  • Add .github/scripts/apt-install-retry.sh (bounded + retried apt provisioning) and use it in libretro-cross for bindgen sysroot provisioning.
  • Pin actionlint as a pre-commit hook and extend .github/actionlint.yaml with a narrowly-scoped suppression; expose PerfPanelState::render_work and plumb it into the Latency Oracle.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
crates/rustynes-frontend/src/debugger/perf_panel.rs Exposes the render-work perf series via a small accessor for reuse by other panels.
crates/rustynes-frontend/src/debugger/mod.rs Feeds the perf render-work snapshot into the Latency Oracle UI plumbing.
CHANGELOG.md Documents the actionlint-based resolution of the reusable-workflow timeout-minutes schema question.
AGENTS.md Adds standing operational/CI/testing rules learned from recent regressions and reviews.
.pre-commit-config.yaml Adds a pinned actionlint hook so workflow-schema issues are caught locally.
.github/workflows/release-auto.yml Expands the in-file rationale around reusable-workflow schema constraints (actionlint-verified).
.github/workflows/ci.yml Adds accuracy paths-filter output and uses it to trigger test-roms; switches bindgen apt provisioning to the retry script.
.github/scripts/apt-install-retry.sh New bounded+retried apt helper for CI reliability and clearer failure signaling.
.github/actionlint.yaml Appends a targeted ignore for intentional constant if: false usage in pgo.yml.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@doublegate

Copy link
Copy Markdown
Owner Author

Two accepted, one declined on measurement — thanks, the first two were real

1. DEBIAN_FRONTEND=noninteractive — accepted, fixed in ab30c828. A package that prompts for configuration blocks on stdin that will never arrive in CI, burning the whole timeout budget waiting for a human who is not there. That is the exact failure this script exists to bound, arriving through a door it had left open. Passed explicitly, since the environment is scrubbed on elevation.

2. Elevation outermost, timeout inside — accepted, and this one is the serious find. With timeout on the outside the signal goes to the elevation helper, which may not forward it, leaving apt-get orphaned while still holding the dpkg lock. Every subsequent retry then fails on the lock rather than on the original problem — a retry loop that guarantees its own retries fail is worse than no retry loop. Re-verified with the same sudo/timeout/sleep stubs: fail-fail-succeed still names the attempt it succeeded on and exits 0; shellcheck clean.

3. paths: keys as regexes — declined for the pinned version, on a measurement.

Tested against actionlint 1.7.12, which is what the hook pins, in a throwaway git repo (actionlint needs one — it discovers the project via git, which is why my first attempt at this test silently ignored the config entirely and produced a confident wrong answer until the control caught it):

key if-cond findings meaning
.github/workflows/pgo.yml 0 control — literal key suppresses
.github/workflows/pg..yml 2 not suppressed → keys are not regex here
.github/workflows/pgoXyml 2 negative control

If the keys were regexes, pg..yml would have matched pgo.yml and suppressed. It did not.

So on 1.7.12 the suggested ^\.github/workflows/pgo\.yml$ would stop matching entirely and silently un-suppress the two pgo.yml findings — turning the adopted gate red for the intentional disables. Left as the literal path.

Worth flagging in case it is version-dependent: if actionlint gains regex semantics for these keys in a later release, the pin (rev: v1.7.12) is what protects this, and bumping it should re-run the three rows above rather than assume.

@doublegate
doublegate merged commit 69beb22 into main Aug 19, 2026
26 checks passed
@doublegate
doublegate deleted the feat/v2.3.9-crucible branch August 19, 2026 13:26
doublegate added a commit that referenced this pull request Aug 19, 2026
Review on #408 found two problems with `.github/scripts/apt-install-retry.sh`,
and both would have made it worse than nothing on the exact path it exists to
protect.

ELEVATION MUST BE OUTERMOST, WITH `timeout` INSIDE IT. The original had `timeout`
on the outside, which sends the signal to the elevation helper rather than to
`apt-get`. The helper may not forward it, leaving `apt-get` orphaned while still
holding the dpkg lock — so every subsequent retry fails on the lock rather than
on the original problem. A retry loop that guarantees its own retries fail is
worse than no retry loop.

`DEBIAN_FRONTEND=noninteractive`, for the same class of reason. A package that
prompts for configuration blocks on stdin that will never arrive in CI, burning
the whole timeout budget waiting for a human who is not there — the exact failure
this script bounds, arriving through a door it had left open. Passed explicitly
because the environment is scrubbed on elevation.

Re-verified with the same stubs: fail-fail-succeed still names the attempt it
succeeded on and exits 0. shellcheck clean.

Both are recorded in the plan beside the item rather than only here, because the
lesson generalises past this script: wrapping a command for reliability puts the
wrapper in the signal path, and the wrapper's own failure modes then belong to
the thing it was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

Correction: the ordering fix did not land in this PR.

My reply above said the two apt-install-retry.sh defects were "fixed in ab30c828". The commit exists, but it was never on this branch, so what merged here still has timeout outermost and no DEBIAN_FRONTEND.

What happened, since the mechanism is worth recording: the compound shell command that was supposed to git checkout this branch before editing was blocked by a privilege-escalation guard (it pattern-matches sudo in command text, and the script's content contains it). A blocked call runs nothing at all — including the parts before the offending token — so the checkout never happened and the edit landed on the stacked branch instead. The follow-up git push origin feat/v2.3.9-crucible then pushed the unchanged local ref: a silent no-op that looked like success.

Both findings were correct and both fixes are real; they ride in #409 instead, unchanged. Nothing is lost — but this PR merged without them, and the record here said otherwise.

Apologies for the noise; flagging it rather than letting the earlier comment stand.

doublegate added a commit that referenced this pull request Aug 19, 2026
… missed (#409)

* feat(perf): measure the two-acquisition race instead of reasoning about it

v2.3.9 item B recorded a hypothesis: the `needs_nes` render arm — taken exactly
when a debugger or tool panel is open — acquires the emulator lock TWICE per
redraw. Once to copy the framebuffer the user will see, and again sixty lines
later for `run_shell_ui`, where panels read `&mut Nes`. The guard is dropped
between them so the composite work does not hold the emulator, which means the
emulation thread can take the lock in that gap. If it does, the screen shows
frame N while a panel describes N+1 — a confidently wrong answer in Pixel
Provenance, whose whole purpose is explaining the pixel you are looking at.

That was written down as a hypothesis rather than a defect, with the experiment
attached, because this line has already retracted one conclusion drawn from
reading rather than measuring. This is the experiment.

`Nes::cycle()` is read at both acquisitions and the readings compared. The choice
of quantity matters: it is cumulative and monotonic, and `produce_one_frame`
holds the lock across a WHOLE frame, so any difference at all means at least one
complete frame landed in the gap — there is no partial-frame reading to
misinterpret. No frame counter exists on `Nes`, and adding one would have been a
second source of truth for something the cycle counter already answers.

Both counters are kept, not just the hits. "The race did not fire" and "nothing
was observed" both read as zero hits, and only the denominator separates them —
the same distinction this release keeps insisting on, applied to its own
instrument. The denominator counts redraws where the race COULD have fired: both
readings present, meaning a ROM is loaded and the arm ran twice. Counting
ROM-less redraws would dilute the rate toward zero and manufacture the reassuring
answer.

Surfaced in the Performance panel as a rate with the counts beside it, and the
hover text says what a zero does and does not mean: it BOUNDS the effect over
that capture, it is not proof the race cannot happen. A measurement that reads as
a verdict is how the next person stops looking.

`debug-hooks`-gated throughout, so the shipped default carries neither the two
reads nor the counters. Two tests pin the counter's semantics — that an
unobserved redraw is not a clean one, and that hits and observations move
independently.

What this does NOT do is fix anything. The rate has to be observed on a real
session with a panel open before a fix is chosen, because the obvious fix —
merging the two acquisitions — puts the composite work back under the emulator
lock, which is exactly the regression v2.3.0 fixed.

Verified: fmt, workspace clippy with the feature OFF, `debug-hooks` and `full`
frontend combos, both wasm32 invocations, rustdoc, and the frontend suite at 522.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): two ordering defects in the apt retry, both caught in review

Review on #408 found two problems with `.github/scripts/apt-install-retry.sh`,
and both would have made it worse than nothing on the exact path it exists to
protect.

ELEVATION MUST BE OUTERMOST, WITH `timeout` INSIDE IT. The original had `timeout`
on the outside, which sends the signal to the elevation helper rather than to
`apt-get`. The helper may not forward it, leaving `apt-get` orphaned while still
holding the dpkg lock — so every subsequent retry fails on the lock rather than
on the original problem. A retry loop that guarantees its own retries fail is
worse than no retry loop.

`DEBIAN_FRONTEND=noninteractive`, for the same class of reason. A package that
prompts for configuration blocks on stdin that will never arrive in CI, burning
the whole timeout budget waiting for a human who is not there — the exact failure
this script bounds, arriving through a door it had left open. Passed explicitly
because the environment is scrubbed on elevation.

Re-verified with the same stubs: fail-fail-succeed still names the attempt it
succeeded on and exits 0. shellcheck clean.

Both are recorded in the plan beside the item rather than only here, because the
lesson generalises past this script: wrapping a command for reliability puts the
wrapper in the signal path, and the wrapper's own failure modes then belong to
the thing it was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(perf): reset the lock-gap counters on clear, plus three review corrections

Four findings from the #409 review, one of them a real defect in the instrument
this PR exists to add.

`RenderPerf::clear()` did not reset the new `lock_gap_*` counters. It is
documented as a regime-change reset, so a ROM change or pacing-regime change
would have cleared every sample ring and left the numerator and denominator of
the skew rate standing — mixing two populations into one percentage and
presenting it as a single measurement. That is exactly the defect the `wait`
series had before it, and the comment explaining that fix sits four lines above
the place the new counters were missing from. Adding to `stats()` and forgetting
`clear()` is evidently the shape of this mistake; the test now pins it and the
mutation fails without the reset.

The `cycle_at_fb` comment said the reading is taken "at the moment the
framebuffer is copied". It is taken on ACQUIRING the lock, a few lines earlier.
Equivalent — the emulator cannot advance while the guard is held, so every
reading inside that scope names the same frame — but "close enough to be
misleading" is how prose stops being checked, and a reviewer asking means the
next reader would have. The comment now says where it is read and why that is the
same thing.

`DEBIAN_FRONTEND` is now set with `env` rather than as a bare assignment to the
elevation helper. Both work on a standard runner, but the bare form additionally
requires SETENV in sudoers, and on a stricter host it fails by refusing to run at
all — breaking the wrapper rather than degrading it. Raised independently by both
reviewers, which is usually a sign the point is real.

And the counters use plain `+= 1`: one increment per redraw cannot overflow a
`u64` in any run that terminates, so `saturating_add` implied a bound worth
reasoning about where there is none.

Verified: shellcheck, the stub replay (fail-fail-succeed still names its attempt
and exits 0), fmt, workspace clippy, frontend under `debug-hooks` at 523 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants