Files
ppsspp/docs/DebuggerThreading.md

10 KiB

Debugger threading model (Core_RunOnCPUThread / g_frameMutex)

Which thread may touch CPU-thread-owned debugger state (breakpoints, symbol map, registers, memory, kernel threads), and in which order the locks have to be taken. Read this before touching Windows/Debugger/*, Core/Debugger/WebSocket* or anything else that reaches into core state from outside the CPU thread.

CPU-thread-owned debugger state (g_breakpoints, g_symbolMap, g_disassemblyManager, registers, memory, kernel threads) used to be touched directly from other threads (the WebSocket handler thread, and the legacy Win32 debugger's message-pump thread) with no synchronization. Two mechanisms now exist for doing this safely - pick based on whether you're mutating or just reading:

  • Core_RunOnCPUThread(func) (Core.h/Core.cpp) - queues func to run on the CPU thread, blocking the caller until it's done. Use for mutations (breakpoint add/remove, register/memory writes, symbol map edits, stepping requests, thread wake/kill). Drained at the top of every Core_RunLoopUntil() iteration, so it's reached whether the CPU is running or stepping/paused; runs immediately if already called from the CPU thread. Two hard rules learned the hard way: never put a modal Win32 dialog call (MessageBox, DialogBoxParam, InputBox_GetString, SomeDialog::exec()) inside the queued lambda - it would block the CPU thread on user input, so split the function into "read/decide", "show modal", "mutate" pieces instead. And never call SendMessage() targeting one of your own GUI-thread windows from inside the lambda - the calling GUI thread is blocked waiting on the CPU thread rather than pumping messages, so a cross-thread SendMessage() back to it deadlocks; keep such calls outside the lambda instead.
  • g_frameMutex (Core.h/Core.cpp) - a plain std::mutex, held by NativeFrame() (UI/NativeApp.cpp) only across the span where it actually touches that state (g_breakpoints.Frame() through g_screenManager->render() - where Core_RunLoopUntil()/actual CPU stepping happens - through runImDebugger/renderImDebugger), not across input handling or the present/frame-pacing waits. Use for reads invoked very frequently (WM_PAINT, a list reload triggered on every debugger-state-changed notification) where routing through Core_RunOnCPUThread would be too slow/heavy. The legacy Win32 debugger windows do this now - see CtrlRegisterList::onPaint, CtrlDisAsmView::onPaint, CtrlMemView::onPaint, CtrlBreakpointList::reloadBreakpoints, CtrlThreadList::reloadThreads, etc. in Windows/Debugger/*.cpp.

Architecture fact that makes g_frameMutex correct: regardless of graphics backend, NativeFrame() (and thus CPU emulation via Core_RunLoopUntil(), and the Dear ImGui debugger) always runs on the same thread - see Core/EmuThread.cpp. When a backend needs its own thread for actual graphics API calls (GraphicsContext::NeedsSeparateEmuThread() - true for OpenGL, SDL, headless, libretro, Qt; false for D3D11/Vulkan on Windows), the original thread stays behind purely to pump graphicsContext->ThreadFrame() (i.e. just executes queued graphics API calls), and a newly spawned thread takes over NativeFrame()/game logic/CPU duty. So UI/ImDebugger/*.cpp is always safe to read/write this state directly, without either mechanism - it's always on the same thread as Core_RunLoopUntil(). The legacy Win32 debugger is different: its dialogs are pumped by the original WinMain message-loop thread, which is a genuinely separate OS thread from whichever thread ends up running NativeFrame()/CPU, regardless of backend (this split happens one level above the NeedsSeparateEmuThread() branch) - that's the whole reason it needed this mechanism.

Update (2026-08-08): both BreakpointManager's and SymbolMap's internal mutexes have been removed after auditing every touchpoint across the codebase (WebSocket subscribers, Windows/Debugger/*.cpp, Windows/MainWindowMenu.cpp/Windows/MainWindow.cpp main-window menu items, Core/Core.cpp's free-threaded Core_Break/Core_Resume, UI/ImDebugger/*.cpp, JIT/interpreter backends, Core/Debugger/MemBlockInfo.cpp) and confirming each is covered by one of the two mechanisms above or is already on the CPU/NativeFrame thread. Notably, Windows/main.cpp's SortSymbols() calls (fired from System_Notify(BOOT_DONE)/System_Notify(SYMBOL_MAP_UPDATED)) turned out to already be safe without any change - both notifications are only ever fired from the CPU/NativeFrame thread (UI/EmuScreen.cpp, Core/HLE/sceKernelModule.cpp), despite an old comment there claiming reliance on the (now-removed) internal lock. Qt/mainwindow.cpp/Qt/QtMain.cpp still poke at g_symbolMap directly and unguarded on the Qt UI thread - a pre-existing issue, deliberately left alone since Qt isn't a maintained backend and is slated for removal; removing the lock doesn't change SymbolMap's public API, so Qt still builds, just without that safety net. GPU/Common/GPUDebugInterface.cpp (GE debugger expression evaluation) and Core/MemFault.cpp (crash-time diagnostics) also touch g_symbolMap and were deliberately not audited this round - different subsystem / best-effort-by-nature respectively, follow up if they ever come up.

Two more things found while doing this:

  • Core_RunOnCPUThread()'s queue is only drained where Core_RunLoopUntil() runs, which requires a game to be loaded (it's called from EmuScreen::render()). Calling Core_RunOnCPUThread() while at the main menu with nothing loaded used to hang forever. Fixed by also calling the (now public) Core_ProcessCPUQueue() directly from NativeFrame(), right before g_screenManager->render(), inside the same g_frameMutex-locked span - so it always runs, not just while a game is active.
  • Lock-ordering rule: because Core_ProcessCPUQueue() is called from inside NativeFrame()'s g_frameMutex-locked span, any Core_RunOnCPUThread() lambda that itself tries to lock g_frameMutex (directly, or indirectly - e.g. by calling something like CDisasm::NotifyMapLoaded(), which locks it internally) will deadlock. Keep such calls outside the queued lambda, same as the modal-dialog and SendMessage() rules above.

Lock ordering: g_frameMutex before Core_LockAgainstShutdown(), always

Core_LockAgainstShutdown() / CoreShutdownLock (a recursive_mutex, g_shutdownLock in Core/Core.cpp) is held across CPU_Shutdown() and Memory::Reinit(), i.e. while the core is going away. Take it on any thread other than the CPU thread before reading core state - emulated memory, the symbol map, kernel objects - so none of it is freed mid-read. It was called Memory::Lock() and only covered the memory map; the name misled people into thinking it was about memory access. When a function needs both it and g_frameMutex, take g_frameMutex first.

The CPU thread's order is structural and can't be changed: NativeFrame() wraps everything below it in g_frameMutex, and several things under there lock memory - Core_ProcessCPUQueue() running a queued WebSocket handler, and runImDebugger() -> ImMemView -> DisassembleRange(). So the GUI-thread side is the one that has to match. (Getting it backwards deadlocked for real: a paint handler held the memory lock and waited for g_frameMutex while the CPU thread did the reverse.)

Also: a Core_RunOnCPUThread() callback does not need Core_LockAgainstShutdown() - teardown only happens on the CPU thread itself (Memory::Shutdown() via CPU_Shutdown() <- PSP_Shutdown(), all callers on that thread; Memory::Reinit() from Memory::DoState() on savestate load). Don't add one.

The general rule behind both of these: never make the CPU thread wait for a thread that is (or may be) waiting on the CPU thread. Core_RunOnCPUThread() blocks until the CPU thread drains the queue, so anything the CPU thread might block on must not be held across such a call. The WebSocket debugger's lifecycleLock hit exactly this - it was held across a whole event handler, and the CPU thread took it in Core_NotifyLifecycle(STOPPING), so stopping a game with a debugger request in flight hung both threads. That lock is gone now; the rule is what's left of it.

The WebSocket debugger no longer has any lock guarding it against startup/shutdown, and must not grow one back. The invariant instead is: a handler either does its emulator-state access inside Core_RunOnCPUThread() - which serializes it against startup and teardown, since those run on the CPU thread too - or touches only state that carries its own lock (the log ring buffer, ctrlMutex, GPUStepping's pause-action rendezvous). When adding a subscriber, put the core access in the queued callback, including the isAlive()/IsValidAddress() checks: answering those outside it just means acting on an answer that may already be stale.

game.* and cpu.stepping/cpu.resume are pushed rather than polled - WebSocketDebuggerTick() (called from Core_ProcessCPUQueue()) notices the transition on the CPU thread, formats the event there, and drops it in a per-connection mailbox. Don't add a broadcaster that reads emulator state from the connection's own thread; produce the event on the CPU thread and push it instead.

The Win32 debugger's GUI-thread readers do still need it, so don't "simplify" those away: teardown is not yet fully inside the g_frameMutex span. EmuScreen::render()'s PSP_Shutdown() is inside it, but the ones in EmuScreen::sendMessage() (REQUEST_GAME_RESET, loading a new game) run from g_screenManager->sendMessage() in NativeFrame(), which sits above where the guard is taken. Closing that hole - moving those shutdowns inside the span, or deferring them to render time - is the prerequisite for dropping the shutdown lock from the debugger entirely.

Painting-problem design history, in case a similar tradeoff comes up elsewhere: routing every paint through Core_RunOnCPUThread was rejected as too slow for something invoked continuously. A per-window snapshot/cache with a per-row-rechecked Core_IsStepping() guard was tried first and worked, but still had a narrow TOCTOU race (the CPU could resume between the check and that row's reads) and the per-row-recheck pattern itself wasn't liked. Settled on g_frameMutex instead - simpler, and actually race-free rather than just lower-risk. CtrlRegisterList shows live values always now, grayed out by color alone (not cached) while the core is running, since a constantly-moving value isn't meaningful to read closely anyway.