From 74cc0487675af02dd2baf9ed310abb2a42e9052b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 29 Mar 2026 21:15:49 -0700 Subject: [PATCH] fix: watchdog thread called SDL video functions from non-main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDL2 requires video/window functions to be called from the main thread (the one that called SDL_Init). The watchdog thread was calling SDL_SetRelativeMouseMode, SDL_ShowCursor, and SDL_SetWindowGrab directly on stall detection — undefined behavior on macOS (Cocoa requires main- thread UI calls) and unsafe on other platforms. Now the watchdog sets an atomic flag, and the main loop checks it at the top of each iteration, executing the SDL calls on the correct thread. --- src/core/application.cpp | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 3ceaf76d..e4ee3342 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -679,8 +679,13 @@ void Application::run() { std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count() }; - std::thread watchdogThread([this, &watchdogRunning, &watchdogHeartbeatMs]() { - bool releasedForCurrentStall = false; + // Signal flag: watchdog sets this when a stall is detected, main loop + // handles the actual SDL calls. SDL2 video functions must only be called + // from the main thread (the one that called SDL_Init); calling them from + // a background thread is UB on macOS (Cocoa) and unsafe on other platforms. + std::atomic watchdogRequestRelease{false}; + std::thread watchdogThread([&watchdogRunning, &watchdogHeartbeatMs, &watchdogRequestRelease]() { + bool signalledForCurrentStall = false; while (watchdogRunning.load(std::memory_order_acquire)) { std::this_thread::sleep_for(std::chrono::milliseconds(250)); const int64_t nowMs = std::chrono::duration_cast( @@ -688,21 +693,15 @@ void Application::run() { const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire); const int64_t stallMs = nowMs - lastBeatMs; - // Failsafe: if the main loop stalls while relative mouse mode is active, - // forcibly release grab so the user can move the cursor and close the app. if (stallMs > 1500) { - if (!releasedForCurrentStall) { - SDL_SetRelativeMouseMode(SDL_FALSE); - SDL_ShowCursor(SDL_ENABLE); - if (window && window->getSDLWindow()) { - SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE); - } + if (!signalledForCurrentStall) { + watchdogRequestRelease.store(true, std::memory_order_release); LOG_WARNING("Main-loop stall detected (", stallMs, - "ms) — force-released mouse capture failsafe"); - releasedForCurrentStall = true; + "ms) — requesting mouse capture release"); + signalledForCurrentStall = true; } } else { - releasedForCurrentStall = false; + signalledForCurrentStall = false; } } }); @@ -714,6 +713,17 @@ void Application::run() { std::chrono::steady_clock::now().time_since_epoch()).count(), std::memory_order_release); + // Handle watchdog mouse-release request on the main thread where + // SDL video calls are safe (required by SDL2 threading model). + if (watchdogRequestRelease.exchange(false, std::memory_order_acq_rel)) { + SDL_SetRelativeMouseMode(SDL_FALSE); + SDL_ShowCursor(SDL_ENABLE); + if (window && window->getSDLWindow()) { + SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE); + } + LOG_WARNING("Watchdog: force-released mouse capture on main thread"); + } + // Calculate delta time auto currentTime = std::chrono::high_resolution_clock::now(); std::chrono::duration deltaTimeDuration = currentTime - lastTime;