Files
virtualjaguar-libretro/app/tests/tsan_canary.cpp
Joe MattielloandClaude Opus 5.5 df0f6d271b feat(app): emulation on its own thread, phase 1 (#967) (#1000)
* feat(app): FrameExchange triple buffer for the emulation thread (#967)

Frame moves to frame.h so CoreHost, FrameExchange and VideoOut share it.
Content epochs keep a previous game's frame from ever being shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): EmuCommands, the emulation thread's ordered command queue (#967)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): PacingClock mode A, the audio clock as master with an even frame grid (#967)

A wall-clock grid at the core's period, trimmed at most 0.5% by the smoothed
SDL queue level: true Jaguar rate on the sound card's crystal without the
10.7 ms device-pull jitter a raw queue trigger would add.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): PacingClock refill rebases the grid so it converges (#967)

During refill frames start at up to 2x the core rate but the grid still
advanced a full period per frame, leaving it ahead of the clock by the extra
audio just queued. The loop then slept that long, drained the queue below the
low-water mark and re-armed refill: bursts and ~66 ms stalls forever, with
underruns. Rebase the grid on each early start while refilling, and make
testRefillFromEmpty check evenness, rate and underruns after the refill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): EmuThread, the single owner thread of the core (#967)

post() at frame boundaries in order, call() that runs inline on the thread
itself and after a stop, callFor() and a join timeout for a core stuck in
retro_run, advance() for headless and tests, inline mode for everything else.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): InputSnapshot, the newest input for the emulation thread's poll (#967)

States are newest-wins; pointer motion is summed until a frame takes it and is
delivered exactly once.  InputManager::poll can run on the UI thread with the
keypad overlay ticking per emulated frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): EmuThread: pending call returns on abandon, exact advance, rate-based pacing test (#967)

call() polls for abandonment instead of blocking forever on a queued task;
advance(n) counts only budgeted frames so a frame already in flight cannot
satisfy it; the pacing test checks rates over measured windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): AudioOut safe across the emulation and UI threads; mode-A update (#967)

update(false) counts underruns without nudging the playback ratio: in mode A
the pacer follows the audio clock and the two controllers must not fight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): CoreHost owner thread, EmuSnapshot and queued writes (#967)

Inline nothing changes.  With an owner, the UI reads options, AV info and
discs from a pinned EmuSnapshot, option values through a UI-side mirror (the
config is still written on the UI thread), and its writes reach the core at
the next frame boundary.  Any other core call off the owner is refused,
counted and logged once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): the real core inline vs on the emulation thread, frame for frame (#967)

Also pins a load while frames run (no stale frame, empty buffer inside the
load call, rising serials) and the owner rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): CoreHost publishes the snapshot before a load call returns (#967)

A load or unload made through EmuThread::call() returned before the batch
ended, so the UI could pin the previous option table, AV info or disc list.
finishLoad, stopSession, a failed startSession and the disc eject / index
commands now publish themselves.  With no post set while threaded, queued
writers are counted and dropped instead of recursing into themselves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): Settings > Interface > Emulation thread and --emu-thread (#967)

Off by default in every build for now (nightlies default it on in phase 3,
once nothing is switched off while threaded).  Decided once per run;
headless runs stay inline unless --emu-thread on.  Nothing starts a thread
yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): VJ_PERF_CSV input-latency and underrun columns, --perf-input (#967)

Latency is the oldest input event's SDL timestamp to the core's poll; the
first three columns are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): content and state actions through EmuThread::call; headless --emu-thread on (#967)

Load, boot BIOS, unload, reset, save and load state, SRAM flushes and the
shutdown unload run where the core lives; inline they run in place as before.
The save-state thumbnail is the frame the state was taken on.  UI checks ask
"halted?" instead of CoreHost::inFrame().  The headless smoke tests pass with
--emu-thread on with the inline hashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): switch off what is not thread-safe yet while the emulation thread is on (#967)

RetroAchievements, netplay, the Tools windows and the GDB stub option are off
for a threaded run (phases 2 and 3 bring them over).  Cheat changes from the
Cheats window reach the core as one command, never half a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): windowed emulation-thread check for the Mac Studio (#967)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): the interactive loop with the emulation on its own thread (#967)

The UI thread polls and publishes input, takes the newest frame, drains
messages and counters and presents; it never waits for the emulation.  The
5 s SRAM flush is posted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): VJ_PERF_CSV event-to-present column (#967)

The swap that first shows a frame which polled an input event, timed from
the event: inline from the iteration's oldest polled event, threaded through
the InputSnapshot publish number each frame carries.  --perf-input now
injects its key from an SDL timer at a swept delay, so events land evenly
over the display period in both loops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): late latch for the threaded loop (#967)

With the emulation thread and vsync on, the UI thread waits until just
before the next vblank (the slowest recent UI build plus a pad that grows on
a missed vblank), publishing input as each event arrives, then takes the
newest frame.  It tracks the vblank grid from swaps that block and stays off
while it cannot.  The fake-clock model: event -> present 25.3 ms for 5 ms
core frames (inline 25.0, unlatched 46.4); 34.2 ms for 14 ms frames, which
only mode B can fix.  --late-latch off for A/B measurement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): late latch backs off on persistent misses, hysteresis (#967)

The latch now releases (with a 300-swap cool-down) when more than a quarter
of its last 60 waits missed the vblank they aimed at, so a build whose GPU
work lands late cannot keep every frame a vblank behind.  Once locked it
lets go on jitter only at 2 ms (locks below 1 ms), and a wait that itself
overran its wake time no longer counts as a miss.  The locked/released log
line and the perf note show misses, releases and the pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ci(app): ThreadSanitizer job over the emulation-thread tests (#967)

VJ_APP_TSAN instruments the app, the core, SDL and the tests; the job runs
ctest -L emu_thread and fails on any race report.  tsan_canary proves the
sanitizer is really on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(app): the Emulation thread setting (#967)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): route UI disc commands through call() so staged_ is never written off the UI wait (#967)

A disc insert can re-stage the CD BIOS in the core (stage_cd_bios via
open_disc_and_resolve_boot), which writes CoreHost::staged_ from the
VJ_EVT_BIOS_STAGED/REJECTED event.  Posted from the UI, that write ran on the
emulation thread while the BIOS panel read stagedBios() every frame.  The
File menu tray/disc items and desktop.cpp toggleDiscTray/selectDisc now run
inside EmuThread::call (in place when inline), like reset.  core_host.h's
threading comment and the plan's boundary table say so.  Latent today: the
app never registers disc images with the core.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(app): thread-test fail patterns, README frame-skip wording, two comments (#967)

- README: the drift alone skips a frame about every 18 s, but frames finishing
  near the take point also repeat/skip (~1-2.5/s measured at 60 Hz); the
  display-locked mode planned later addresses it, hence off by default.
- emu_thread_flag_rejects, emu_thread_limits, netplay_refused_threaded and
  gdb_locked_threaded only had a pass regex (ctest then ignores the exit
  code): they now also fail on "ThreadSanitizer|owner violation".
- late_latch.h: kLockAfter counts tracked observations; single outliers do not
  reset it.  emu_thread.h: a call queued before an abandon may still run later
  on the detached thread, so captures must be by value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): emu-thread tests on CI runners: Windows timer, MSVC capture, libdbus TSan (#967)

- emu_thread_unit raises the Windows timer resolution to 1 ms (timeBeginPeriod,
  winmm), as SDL_Init does in the app; at the default ~15.6 ms, fast-forward
  could not beat normal speed (63 vs 97 frames/s on the Windows runners).
- Its "keeps running" check is a deadline poll instead of a fixed-window
  quarter-rate floor, which a starved macOS runner missed (6 of 36 frames).
- emu_commands_test: kEach is static constexpr, so MSVC does not need it
  captured (C3493).
- tsan.supp: suppress libdbus's own lock-order-inversion between SDL_Init and
  SDL_Quit on the main thread (system library, not instrumented); it was the
  only report in the Linux TSan job, on every threaded app run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): precise emulation-thread waits on MinGW (#967)

MinGW's timed condition wait (winpthreads) rounds short timeouts up to the
scheduler tick (~10-16 ms) even with the 1 ms timer resolution SDL sets, so
threaded fast-forward ran below normal speed on the MinGW Windows runner (67.5
vs 98.2 frames/s; MSVC was fine) -- and the shipped Windows packages are
MinGW builds.  EmuCommands::waitUntil now waits on the condition only while
more than 20 ms remain, then polls in 1 ms sleeps until the deadline or a
post()/notify().  Other platforms keep the plain condition wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): emu_thread_unit reports the host's timer precision for the fast-forward check (#967)

The MinGW runner still showed fast-forward at one frame per ~15.6 ms (64 vs 98
frames/s) after the polling wait, which points at the platform's timers rather
than EmuThread.  The test now measures sleep_for(1 ms) and the steady_clock
step and prints them with the rates.  The fast-forward ratio is still
asserted whenever it holds or the host can pace 2.5 ms frames; it is reported
instead only when it misses on a host whose sleeps exceed 6 ms or whose clock
steps exceed 1 ms (or that cannot even sustain normal speed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:01:55 -04:00

20 lines
544 B
C++

// Built only with -DVJ_APP_TSAN=ON: a deliberate data race. Registered WILL_FAIL, so the ThreadSanitizer job proves
// its sanitizer is really on -- a build without it exits 0 here and that fails the test.
#include <cstdio>
#include <thread>
static int g_shared = 0;
int main() {
std::thread a([] {
for (int i = 0; i < 100000; i++) g_shared++;
});
std::thread b([] {
for (int i = 0; i < 100000; i++) g_shared++;
});
a.join();
b.join();
std::printf("tsan_canary: %d\n", g_shared);
return 0;
}