Skip to content

perf(frontend): take the present path off the emu mutex, and make the pacer yield (GUI stall, steps 0b+0c) - #208

Merged
doublegate merged 3 commits into
mainfrom
perf/present-wiring
Jul 30, 2026
Merged

perf(frontend): take the present path off the emu mutex, and make the pacer yield (GUI stall, steps 0b+0c)#208
doublegate merged 3 commits into
mainfrom
perf/present-wiring

Conversation

@doublegate

Copy link
Copy Markdown
Owner

Motivation

Steps 0b and 0c of the GUI-stall fix. #206 added the PresentBuffer; this
wires it in and replaces the pacer.

The reported symptom was that the emulator is unusable: menu clicks taking 15-45
seconds, roughly one frame presented per 30-60 seconds, audio cycling ~1 s on /
~1 s off. That turned out to be two independent defects in the frontend, and
fixing only the first would have left the emu thread hogging the mutex in a tight
loop:

  1. App::snapshot took the emu mutex every UI frame merely to clone the
    framebuffer, while the emu thread held that same mutex across an entire
    emulated frame.
  2. The pacer's fell-behind branch was next = now with no sleep and no yield.
    This core is ~6.5x slower than real time, so it was taken on every iteration
    — the thread re-locked immediately and held the mutex ~100% of the time.

Measured

A new #[ignore]d stopwatch
(measure_ui_read_latency_through_the_handoff_versus_the_emu_mutex) times a
competing UI thread's read both ways against a live emu thread. --release, 120
samples, development machine:

UI-side read p50 p99 max
via the handoff 894 ns 163 µs 163 µs
via the emu mutex (the old path) 97.8 ms 113.6 ms 118 ms

The same run reported 60 snap-forwards over 60 produced frames — the core is
behind on every iteration, confirming directly that the old no-yield branch was
always the one taken.

Changes

0b — the UI stops taking the emu mutex

  • App::snapshot -> App::take_present: reads the handoff, takes no emu lock.
    The only emu-lock takers left on the UI thread are the menu actions that
    genuinely mutate the core (open / close / pause / reset) — on a click, not per
    frame.
  • EmuCore::publish_into is the single producer path, so the emu-thread and the
    emu-thread-off build cannot drift about what a published frame is. It publishes
    only the w * h * 4 prefix, not the 1,228,800-byte FB_MAX backing store that
    produce_frame never resizes.
  • The consumer bounds its upload by the bytes it actually received — the
    release-mode half of the contract PresentBuffer::publish debug-asserts.
  • present.reset() on ROM load, CloseRom and Reset; deliberately not on
    TogglePause, since a pause must keep showing the frame it paused on.
  • EmuThread::spawn takes an EmuThreadParams struct: the eighth argument would
    have tripped clippy::too_many_arguments.

0c — the pacer

Ported from RustyNES: a bounded catch-up burst (MAX_CATCHUP_FRAMES = 3) then a
snap forward, then block_until_native — capped 2 ms naps down to a 2 ms
margin, then a precise spin. The nap cap is load-bearing: with one long sleep an OS
oversleep blows past the target and the spin never engages.

The pacing decisions are extracted into a Schedule so they are testable with
synthetic instants rather than a real clock — no timing flake.

Deviation from the port: the emu lock is taken per frame, not across a
whole burst. An N64 frame costs this core ~100 ms, so holding across three would be
a ~300 ms UI stall.

Known cost, recorded rather than tuned away

Because the core is behind every iteration, the snap imposes a full frame period of
wait after each frame, so the effective rate is 1 / (frame_cost + period) — about
8.1 FPS against a ~9.3 FPS core ceiling, ~13%. That is the ported behavior and
it is what buys the UI its window. Whether a shorter yield is better is a question
for the perf work with measured data, not a constant to adjust by feel.

Thread-priority elevation is deliberately not ported: it is the only unsafe
in RustyNES's frontend and should wait for measurement.

What this does NOT fix

The core is still ~6.5x slower than real time. That is a separate, real defect
(P1), and it should not be attacked until perf.rs says where the time goes.
The audio hiccup (P2) is most likely downstream of producer starvation and
should be re-measured after this, not tuned against a starved producer.

Determinism (ADR 0004)

Untouched. This moves where already-produced bytes are copied and when
wall-clock waits happen — neither of which the deterministic core observes.

Verification

Both new guards are mutation-checked:

  • removing the catch-up cap makes frames_due report 225,001 frames due;
  • removing the publish_into call takes the end-to-end test red.

Gates run locally: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps, scripts/check_en_us.sh,
pre-commit run markdownlint --all-files, and the no_std thumbv7em build — all
green.

docs/frontend.md is updated in the same change (the defect, the measurement
table, the pacer, and the known cost).

… pacer yield

Steps 0b and 0c of the GUI-stall fix. #206 added the PresentBuffer; this wires it
in and replaces the pacer. Two independent halves of one defect, and fixing only
the first would have left the emu thread hogging the mutex in a tight loop.

0b -- the UI stops taking the emu mutex
---------------------------------------
App::snapshot took the emu mutex every UI frame merely to clone the framebuffer,
while the emu thread held that same mutex across an entire emulated frame. It is
replaced by App::take_present, which reads the handoff and takes no emu lock at
all. The only emu-lock takers left on the UI thread are the menu actions that
genuinely mutate the core, which happen on a click rather than per frame.

Producers publish through one path, EmuCore::publish_into, so the emu-thread and
the emu-thread-off build cannot drift about what a published frame is. It sends
only the w*h*4 prefix, not the 1,228,800-byte FB_MAX backing store that
produce_frame never resizes.

The consumer bounds its upload by the bytes it actually received. That is the
release-mode half of the contract PresentBuffer::publish debug-asserts, so a
short buffer is skipped rather than sliced out of bounds.

0c -- the pacer
---------------
The old fell-behind branch was `next = now` with no sleep and no yield. This core
is ~6.5x slower than real time, so it was taken every iteration: the thread
re-locked the emu mutex immediately and held it ~100% of the time.

Ported from RustyNES: a bounded catch-up burst then a snap forward, then
block_until_native -- capped 2 ms naps down to a 2 ms margin, then a precise
spin. The nap cap is load-bearing; with one long sleep an OS oversleep blows past
the target and the spin never engages.

The pacing decisions are extracted into a `Schedule` so they are testable with
synthetic instants rather than a real clock -- no timing flake. Deviation from the
port: the emu lock is taken PER FRAME, not across a whole burst, because an N64
frame costs this core ~100 ms and holding across three would be a ~300 ms UI
stall.

Measured
--------
A new #[ignore]d stopwatch times a competing UI thread's read both ways against a
live emu thread (release, 120 samples, development machine):

  UI read via the handoff    p50 894 ns    p99 163 us     max 163 us
  UI read via the emu mutex  p50 97.8 ms   p99 113.6 ms   max 118 ms

The same run reported 60 snap-forwards over 60 produced frames -- the core is
behind on every iteration, which confirms directly that the old no-yield branch
was always the one taken.

Known cost, recorded rather than tuned away: because the core is behind every
iteration, the snap imposes a full period of wait after each frame, so the
effective rate is 1/(frame_cost + period) -- about 8.1 FPS against a ~9.3 FPS
core ceiling, ~13%. That is the ported behavior and it is what buys the UI its
window. Whether a shorter yield is better is a question for the perf work with
measured data, not a constant to adjust by feel.

Thread-priority elevation is deliberately not ported: it is the only unsafe in
RustyNES's frontend and should wait for measurement.

Both new guards are mutation-checked: removing the catch-up cap makes frames_due
report 225,001; removing the publish_into call takes the end-to-end test red.

Determinism (ADR 0004) is untouched -- this moves where already-produced bytes
are copied and when wall-clock waits happen, neither of which the core observes.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh, markdownlint, no_std thumbv7em -- all green.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@doublegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6a6a7cfb-dbb2-409d-a2b2-13c52c2fcfbb

📥 Commits

Reviewing files that changed from the base of the PR and between 1bb378a and 5305173.

📒 Files selected for processing (3)
  • crates/rustyn64-frontend/src/app.rs
  • crates/rustyn64-frontend/src/emu.rs
  • docs/frontend.md
📝 Walkthrough

Summary by CodeRabbit

  • Performance Improvements

    • Improved UI responsiveness by switching to a lock-free frame handoff from emulation.
    • Refined emulation pacing to use bounded catch-up and more stable timing, reducing stutter.
    • Preserves the currently displayed frame when pausing.
  • Bug Fixes

    • The display now reliably clears when closing or resetting a ROM.
    • Improved consistency of frame and status updates between emulation and the UI.
  • Documentation

    • Updated frontend documentation explaining the new frame handoff and pacing behaviour.
  • Tests

    • Added coverage for pacing timing and publish/hand-off correctness.

Walkthrough

The frontend replaces mutex-protected framebuffer snapshots with a PresentBuffer handoff, adds bounded wall-clock emulation pacing, publishes video/audio output from the emulation thread, and updates UI lifecycle and rendering paths.

Changes

Frontend presentation handoff

Layer / File(s) Summary
PresentBuffer publication contract
crates/rustyn64-frontend/src/app.rs, crates/rustyn64-frontend/src/emu.rs, crates/rustyn64-frontend/src/emu_thread.rs
EmuCore::publish_into transfers active RGBA bytes and emulator status through PresentBuffer; emulation-thread startup passes the shared buffer via EmuThreadParams.
Wall-clock pacing loop
crates/rustyn64-frontend/src/emu_thread.rs
Schedule limits catch-up, snap-forwards when behind, and waits with bounded sleeping and spinning; pacing statistics and behavioural tests are added.
UI presentation lifecycle
crates/rustyn64-frontend/src/app.rs, docs/frontend.md
The UI consumes presentation data without the emulator mutex, resets output for ROM close/reset/load actions, preserves frames when paused, and uploads staged framebuffer bytes using current dimensions. Documentation describes the handoff and pacing behaviour.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EmuThread
  participant EmuCore
  participant PresentBuffer
  participant App
  participant Renderer
  EmuThread->>EmuCore: step emulation frame
  EmuCore->>PresentBuffer: publish video and status
  App->>PresentBuffer: take latest presentation
  App->>Renderer: upload staged framebuffer
Loading

Possibly related PRs

  • doublegate/RustyN64#62: Changes the EmuCore frame RGBA and dimension pipeline consumed by this PR’s publish_into handoff.
  • doublegate/RustyN64#158: Changes the framebuffer dimensions and pixels generated before this PR publishes them for presentation.
🚥 Pre-merge checks | ✅ 6 | ❌ 4

❌ Failed checks (4 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning Violates the 72-character Conventional Commits limit, so the title check fails. Shorten it to 72 characters or fewer while keeping the type(scope): subject format and the main change.
Oracle Number Is Stated ⚠️ Warning It measures UI latency, but never states the n64-systemtest failing-assertion count nor says 'not measured'; docs/STATUS.md shows the current oracle count is 90. Add the measured before/after n64-systemtest failing-assertion count, or explicitly mark the effect as not measured, and keep docs/STATUS.md as the current figure.
Changelog Entry For User-Visible Changes ⚠️ Warning CONTRIBUTING.md requires user-visible changes in CHANGELOG.md under [Unreleased], but this PR only touches app.rs and emu_thread.rs. Add a short [Unreleased] CHANGELOG.md item for the UI/pacer fix, then tick the PR template changelog checkbox.
Measured, Never Tuned ⚠️ Warning FAIL: SPIN_MARGIN, SLEEP_CHUNK, and MAX_CATCHUP_FRAMES are new timing constants in emu_thread.rs but have no accuracy-ledger entry; docs/frontend.md only calls them ported from RustyNES. Record each constant in docs/accuracy-ledger.md with provenance/measurement, or cite a manual/wiki source for the chosen values.
✅ Passed checks (6 passed)
Check name Status Explanation
Description check ✅ Passed The description is clearly on-topic and matches the frontend stall and pacer changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Docs-As-Spec Sync ✅ Passed Diff only touches rustyn64-frontend and docs/frontend.md; no rustyn64-{cpu,rsp,rdp,audio,cart,core} behaviour change to sync.
Unsafe Stays Out Of The Chip Crates ✅ Passed No actual unsafe syntax appears in .rs; no #![forbid(unsafe_code)] lines were removed; the frontend adds no unsafe blocks, so the policy holds.

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

@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/rustyn64-frontend/src/app.rs (1)

231-231: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale doc wording: "snapshot" no longer exists.

The unchanged doc comment above redraw still reads "input -> snapshot -> egui pass...", but App::snapshot was replaced by take_present in this PR. Worth a one-word update while touching this function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustyn64-frontend/src/app.rs` at line 231, Update the doc comment
above App::redraw to replace the stale “snapshot” stage with “take_present,”
matching the current frame pipeline and the renamed method.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/rustyn64-frontend/src/app.rs`:
- Around line 143-146: Update App’s presentation reset flow by adding or using
reset_presentation to reset the handoff and stage a zero-filled framebuffer at
the default dimensions, rather than only clearing PresentBuffer or clearing the
staging buffer. In load_rom, ensure this presentation reset occurs even when
core.load_rom fails, while preserving the error return; update the CloseRom and
Reset dispatch arms to call reset_presentation and widen dispatch mutability as
needed. Keep framebuffer upload behavior intact so the staged blank frame
replaces the old GPU texture.

In `@docs/frontend.md`:
- Around line 48-96: The documentation contains minor grammar and typography
issues: add the comma after “time” before “Against,” replace the hyphens in
“15-45” and “30-60” with en dashes, and add the comma before “and” in the
“ported behavior” sentence. Limit changes to these wording fixes in the defect
and known-cost sections.

---

Outside diff comments:
In `@crates/rustyn64-frontend/src/app.rs`:
- Line 231: Update the doc comment above App::redraw to replace the stale
“snapshot” stage with “take_present,” matching the current frame pipeline and
the renamed method.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 56f3dfa0-20d8-46c9-b059-34f9d2c3241a

📥 Commits

Reviewing files that changed from the base of the PR and between 63e08e9 and fbf9d4b.

📒 Files selected for processing (4)
  • crates/rustyn64-frontend/src/app.rs
  • crates/rustyn64-frontend/src/emu.rs
  • crates/rustyn64-frontend/src/emu_thread.rs
  • docs/frontend.md

Comment thread crates/rustyn64-frontend/src/app.rs Outdated
Comment thread docs/frontend.md
Adopted from the Antigravity review of #208, and it is another instance of this
repo's recurring failure mode: the comment I wrote at the reset site claimed the
window would "show black until the new ROM produces", and the code did not do it.

Two independent traps, and each one alone leaves the previous ROM's last frame on
screen:

1. PresentBuffer::reset clears the HANDOFF, but take_into then returns None, so
   App::frame_staging keeps its old bytes and keeps being uploaded. This is what
   the review found.
2. Merely EMPTYING frame_staging is also not enough, because the upload is then
   skipped -- and Gfx::upload_framebuffer writes into a persistent wgpu texture
   that render() keeps sampling. The stale image would stay on the GPU.

So the new App::blank_presentation fills the staging buffer with actual black at
the default geometry, which the next upload writes over the texture, and it is a
named helper rather than three call sites precisely so the two halves cannot drift
apart again. load_rom and dispatch take &mut self to reach it.

Also documents that SPIN_MARGIN and SLEEP_CHUNK being equal is coincidental --
they answer different questions and the port defines them separately -- so nobody
collapses them into one constant.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh -- all green. Smoke-tested by running the release binary against
tests/roms/homebrew/render_fill.z64 for 12 s: no panic, clean exit on SIGTERM.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the Antigravity review

Blocking: stale framebuffer after ROM load / reset — ADOPTED (1bb378a)

Correct, and worth naming what kind of mistake it was: the comment I wrote at that
very call site claimed the window would "show black until the new ROM produces",
and the code did not do it. That is this repo's most-repeated failure mode — a
comment stating a rule is not an implementation of it — and it happened again in
the same commit that cited the discipline.

Your diagnosis is exact: PresentBuffer::reset clears the handoff, take_into
then returns None, and frame_staging keeps its old bytes and keeps being
uploaded.

One correction to the prescribed fix, though — frame_staging.clear() alone is
not sufficient.
With the buffer empty, the upload guard
(frame_staging.len() >= need) makes the upload skip, and
Gfx::upload_framebuffer writes into a persistent wgpu texture that render()
keeps sampling every frame. So clearing stops the re-upload but leaves the stale
image on the GPU, and the screen still shows the old ROM. There are two traps here,
not one.

The fix is App::blank_presentation, which fills the staging buffer with actual
black
at the default geometry so the next upload overwrites the texture. It is a
named helper rather than three inline call sites specifically so the halves cannot
drift apart again; load_rom and dispatch now take &mut self to reach it.

Suggestion: status is read independently of the frame — REJECTED, it would make the flags stale when they matter

The skew is real but bounded and already accepted: EmuCore::publish_into
publishes the frame and the status readings together, once per frame, so the
worst case is that the status is one frame ahead of the dims of the frame currently
displayed. PresentBuffer's field docs already state that tolerance.

Gating the status update on a successful take_into would be actively worse.
run_frame early-returns while paused, so a paused core publishes status with
unchanged counters and no new video content — under your proposal the
paused and rom_loaded flags would then stop updating precisely in the state
where the user is looking at them. The counters are never compared against the
frame geometry, so there is nothing for the coupling to buy.

Suggestion: PacerStats uses RelaxedACKNOWLEDGED, no change (already documented)

The design intent you ask for is already recorded on the type: "Counters only —
nothing schedules against them (ADR 0006: master_ticks is the only clock position
that is ever incremented)." That ADR reference is the load-bearing part in this
project: a pacing counter that anything scheduled against would be a second clock,
which the architecture forbids outright. Release semantics would imply exactly the
cross-thread ordering guarantee that must not be relied on here.

Nitpick: SPIN_MARGIN == SLEEP_CHUNK, intentional or coincidental? — ADOPTED as documentation

Fair question, and the answer is coincidental. They answer different questions —
"how close to the target before switching to a spin" versus "how long may any single
nap be" — and the RustyNES original defines them separately for that reason. Either
can be tuned without the other. Now stated on SLEEP_CHUNK, along with an explicit
"do not collapse them into one constant".


On verification, stated plainly

The quantitative evidence in the PR body is from a harness driving the real
EmuThread and the real handoff, so it measures the mechanism, not a mock. I
also smoke-ran the release binary against tests/roms/homebrew/render_fill.z64 for
12 s: it stays up at ~34% CPU with no panic and exits cleanly on SIGTERM.

What I cannot verify from here is the part the user actually reported — whether the
menu feels responsive and how many frames per second reach the screen. That needs
someone looking at the window. If the menu turns out to be responsive but
presentation is still roughly one frame per 30 s, then the handoff was not the whole
story and the core's ~6.5x throughput deficit dominates; that outcome gets reported
as such rather than explained away.

Gates on 1bb378a: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps, scripts/check_en_us.sh — all green.

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/rustyn64-frontend/src/emu_thread.rs (1)

245-263: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not charge a poisoned emu.lock() as a produced frame. If the mutex is poisoned, coordinator.step and core.publish_into never run, but schedule.advance() and produced += 1 still do. That lets the pacer report progress while the published frame stays stale. Break the thread or recover the poisoned guard before counting the frame.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustyn64-frontend/src/emu_thread.rs` around lines 245 - 263, The
emulation loop must not advance pacing or count a frame when acquiring the
emulator mutex fails due to poisoning. Update the flow around the emu.lock()
call so a poisoned lock either terminates the thread or recovers a valid guard
and completes the frame work before reaching schedule.advance() and produced +=
1; preserve counting only for successfully processed frames.
crates/rustyn64-frontend/src/app.rs (1)

6-15: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Scope the mutex claim to the emulation-thread build.

The new documentation says mutex access is limited to menu actions, but the #[cfg(not(feature = "emu-thread"))] redraw path still locks self.emu on every frame at Lines 264-272. Document both execution modes instead of asserting a global contract.

As per path instructions, comments that disagree with the implementation are correctness hazards. Based on learnings, documentation does not enforce behaviour and this mismatch must be corrected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rustyn64-frontend/src/app.rs` around lines 6 - 15, The module
documentation incorrectly makes a global mutex-access claim while the
non-emu-thread redraw path still locks self.emu each frame. Update the
documentation near App::snapshot to distinguish the emu-thread mode from the
#[cfg(not(feature = "emu-thread"))] mode, accurately describing mutex usage in
each execution path without changing behavior.

Sources: Path instructions, Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/rustyn64-frontend/src/app.rs`:
- Around line 6-15: The module documentation incorrectly makes a global
mutex-access claim while the non-emu-thread redraw path still locks self.emu
each frame. Update the documentation near App::snapshot to distinguish the
emu-thread mode from the #[cfg(not(feature = "emu-thread"))] mode, accurately
describing mutex usage in each execution path without changing behavior.

In `@crates/rustyn64-frontend/src/emu_thread.rs`:
- Around line 245-263: The emulation loop must not advance pacing or count a
frame when acquiring the emulator mutex fails due to poisoning. Update the flow
around the emu.lock() call so a poisoned lock either terminates the thread or
recovers a valid guard and completes the frame work before reaching
schedule.advance() and produced += 1; preserve counting only for successfully
processed frames.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 80c4928f-e349-4a65-a5a6-0dbc159e7d84

📥 Commits

Reviewing files that changed from the base of the PR and between fbf9d4b and 1bb378a.

📒 Files selected for processing (2)
  • crates/rustyn64-frontend/src/app.rs
  • crates/rustyn64-frontend/src/emu_thread.rs

Adopted from the CodeRabbit and Antigravity reviews of #208.

A failed ROM load skipped the blanking entirely via the `?`, and that is not
harmless: EmuCore::load_rom performs its warm reset BEFORE the fallible boot, so a
rejected image has already discarded the previous machine. The UI was left showing
the old ROM's picture, describing a core that no longer exists. The load now
blanks unconditionally and propagates the error afterwards.

Also from the reviews:

  - publish_into gained a debug_assert on the bytes/dims guard. It stays tolerant
    in release, but presentable_geometry makes the guard unreachable today, so a
    trip is a core-side bug that deserves a panic in development rather than a
    silently frozen picture.
  - blank_presentation uses saturating_mul, matching the other two framebuffer
    length computations. The defaults cannot overflow; one form everywhere is
    cheaper to audit than three.
  - docs/frontend.md: two missing commas and en dashes for the two ranges.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh, markdownlint -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the third Antigravity review

Blocking: EmuThread::spawn signature change without a version bump — REJECTED, premise checked and does not hold

The conditional in the finding is the load-bearing part: "If rustyn64-frontend is exported as a crate dependency". It is not. Checked rather than assumed:

  • no cargo publish anywhere in .github/workflows/;
  • no crates.io reference in any workflow, docs/*.md, or CONTRIBUTING.md;
  • releases ship binary assets (v0.7.0 published three), not crates.

So there are no external callers of EmuThread::spawn to break. The rule you are applying is right, and it starts applying the moment this crate is published — worth keeping in mind, but not a blocker now.

Blocking: publish_into skips publish but still calls publish_statusPARTIALLY ADOPTED (5305173)

The desync concern is rejected, but the "silently skipped" part is fair and is now fixed.

Why the desync is not a defect: the counters describe the core, not the presented frame. If the frame is unusable and the status were frozen with it, a live core would be misreported as hung — strictly worse than a status line that is one frame ahead of the picture. EmuCore::publish_into publishes both together every frame, so the skew is bounded at one frame, and PresentBuffer's field docs already accept exactly that.

What was right: silently skipping was wrong. presentable_geometry clamps w/h to the blit texture, so need > 0 && need <= rgba.len() is unreachable today — which means a trip is a core-side bug, and it should not degrade quietly into a frozen picture in a build nobody is debugging. Added a debug_assert! beside the guard: loud in development, tolerant in release.

Suggestion: from_secs_f64(1.0 / target_fps()) can panic on 0 / NaN / negative — REJECTED, not reachable

Region::target_fps is a const fn over a two-variant enum (config.rs:58):

pub const fn target_fps(self) -> f64 {
    match self {
        Self::Ntsc => 60.0,
        Self::Pal => 50.0,
    }
}

There is no input that yields zero, NaN, or a negative — the set of possible return values is {60.0, 50.0}. A validation branch here would be unreachable code guarding against a value the type system already excludes.

Suggestion: saturating_mul used inconsistently — ADOPTED (5305173)

Correct and free. blank_presentation now uses saturating_mul like the upload guard and publish_into. The defaults (320x240) cannot overflow, so this buys no safety — it buys one form to audit instead of three, which is the actual value.

Suggestion: take_present updates fb_dims even if the upload will be skipped — REJECTED, unreachable by construction

After a successful take_into, frame_staging holds exactly the bytes for those dims: take_into does out.clear(); out.extend_from_slice(&slots.bufs[ready]), and PresentBuffer::publish requires (and now debug-asserts) frame.len() == w * h * 4. So frame_staging.len() >= need always holds whenever fb_dims has just been updated.

The other path that sets fb_dims is blank_presentation, which sets the staging length to match in the same function. There is no state where the dims advance while the buffer is too short.

Nitpick: the measurement test uses println!REJECTED, that is its interface

It is an #[ignore]d stopwatch whose documented invocation is cargo test ... -- --ignored --nocapture; println! is how a test hands numbers to a human under --nocapture. It asserts nothing that depends on the output, and it never runs in CI.


Behavioral confirmation, including the part that did NOT work

The user has run this build. Reporting both halves, since the PR body committed to doing so:

  • Menu: fixed. Clicks went from 15-45 seconds to ~1 second. The starvation this PR targets was real and is resolved.
  • Presentation: improved but still short. From ~1 frame per 30-60 s to ~1 frame/second — a 30-60x gain, but well below the ~8 FPS this PR's own measurement predicted.

That second outcome is the one the PR body pre-registered as "the handoff was not the whole story and P1 dominates", so it is now the live question, and it belongs to the separate core-throughput work rather than to this change. Audio is still chopping, which at ~1 FPS is expected: the producer supplies roughly one frame of samples per second where sixty are needed, so it is starvation, not a ring defect — tuning the ring against a starved producer is exactly what the plan says not to do.

Gates on 5305173: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc, scripts/check_en_us.sh, markdownlint — all green.

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

Decouples the frontend presentation path from the emulator core mutex by reading frames and status from a PresentBuffer handoff, and replaces the emulator thread's unbounded catch-up loop with a capped-burst wall-clock pacer.

Blocking issues

  • app.rs:L141: load_rom swallows lock acquisition failures when self.emu.lock() returns Err (poisoned mutex) by defaulting to Ok(()) via map_or. If the emulator thread panics and poisons the mutex, load_rom silently masks the failure and returns Ok(()) to the caller even though the core was not updated. It should return Err(AppError::Rom(...)) when lock acquisition fails.

Suggestions

  • emu_thread.rs:L251: When self.emu.lock() is poisoned, EmuThread ignores the error and continues running its loop indefinitely. The thread loop should log and terminate upon mutex poisoning rather than retrying lock acquisition against a dead core on every frame iteration.
  • emu.rs:L310: The debug_assert! format string mixes named variable captures ({w}, {h}, {need}) with an implicit positional placeholder {} for rgba.len(). Use an explicit named capture ({len}) or positional index to avoid format string ambiguity across toolchain versions.

Nitpicks

  • app.rs:L177: blank_presentation calls self.frame_staging.clear() immediately before self.frame_staging.resize(len, 0). The clear() call is redundant prior to resize.

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

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.

1 participant