From 76d40164fcefdaaf87fd89e5f2bdb9ca7fd8ed3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9Fan=20=C3=96zcan?= <232228291+yorgunkral31@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:01:28 +0300 Subject: [PATCH] fix: survive app suspension and resume on iOS Backgrounding the app in any way (home, lock, control center, app switching) previously ended in a scene-update watchdog kill or a frozen image with audio still running. Debugging this against live thread dumps and crash reports on an iPhone 16 Pro surfaced a chain of independent failure modes, each fixed here: 1. The foreground wake-up was tied to SDL's app events, which are not reliably delivered while the render loop is idle. Lifecycle now also flows through NSNotificationCenter observers registered at startup, and SDL's WILLENTERFOREGROUND is handled as a fallback. 2. The suspend handler ran WaitForGPU inside the UIKit lifecycle callback. A command fence the executor has not signalled yet makes that an unbounded wait inside the callback, which the watchdog punishes with 0x8BADF00D. The handler is now flag-only; the drain happens at the render loop's own suspend gate, at a frame boundary where pending fences are real bounded GPU work. 3. While suspended, the render loop must neither keep running guest code (the background scene-update transaction starves) nor block on a bare atomic wait (the runloop starves and the wake-up can never be delivered). It now waits inside CFRunLoopRunInMode, which services the system's transactions, lets the process suspend cleanly, and delivers the foreground notification that clears the flag. 4. The handler also used to stop the command executor by clearing g_readyForCommands, stranding the guest thread on g_executedCommandList forever (and nothing ever restarted the executor after resume). The executor is now left running; presents are gated by the swap chain invalidation instead. 5. On resume, the forced swap chain resize skipped: its WaitForGPU can race a freshly queued command list that has not reached the executor yet. The GPU was already drained on the way into the suspend, so the resume resize now skips the redundant drain. 6. Command lists that straddle the suspend can render into drawables whose present was skipped, and their completion fences may never signal. The first NUM_FRAMES frames after resume get a grace pass on the frame pacing fence. Verified on an iPhone 16 Pro with the native Metal backend: repeated home/background cycles (30+ seconds), control center over active gameplay, lock/unlock, and app switching all resume cleanly with rendering intact. Pairs with a plume-side change that releases held drawables when the swap chain resizes. --- UnleashedRecomp/apple_utils.h | 5 ++ UnleashedRecomp/apple_utils.mm | 27 +++++++++ UnleashedRecomp/gpu/video.cpp | 97 +++++++++++++++++++++++++++--- UnleashedRecomp/main.cpp | 2 + UnleashedRecomp/ui/game_window.cpp | 1 + 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/UnleashedRecomp/apple_utils.h b/UnleashedRecomp/apple_utils.h index 643786e28..0ba2f84f3 100644 --- a/UnleashedRecomp/apple_utils.h +++ b/UnleashedRecomp/apple_utils.h @@ -16,6 +16,11 @@ std::filesystem::path GetApplicationSupportDirectory(); /// Returns absolute path suitable for temporary/cached data std::filesystem::path GetCachesDirectory(); +/// Register UIKit lifecycle observers that pause/resume the renderer. +/// SDL's app events are not reliably delivered while the render thread is +/// parked, so the foreground wake-up must come straight from UIKit. No-op on macOS. +void RegisterLifecycleObservers(); + /// Open a URL in the system browser /// Returns true if the URL was opened successfully, false otherwise bool OpenBrowser(const char* url); diff --git a/UnleashedRecomp/apple_utils.mm b/UnleashedRecomp/apple_utils.mm index e2ec2286c..15e4f23fa 100644 --- a/UnleashedRecomp/apple_utils.mm +++ b/UnleashedRecomp/apple_utils.mm @@ -9,6 +9,7 @@ #endif #include "apple_utils.h" +#include namespace apple { @@ -106,6 +107,32 @@ bool OpenBrowser(const char *url) { } } +void RegisterLifecycleObservers() { +#if TARGET_OS_IPHONE + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; + [center addObserverForName:UIApplicationDidEnterBackgroundNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *) { + Video::HandleApplicationBackgroundState(true); + }]; + // Both notifications resume the renderer; the handler is idempotent and + // whichever arrives first wins. + for (NSNotificationName name in @[ UIApplicationWillEnterForegroundNotification, + UIApplicationDidBecomeActiveNotification ]) { + [center addObserverForName:name + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *) { + Video::HandleApplicationBackgroundState(false); + }]; + } + }); +#endif +} + bool SupportsBCTextures() { static bool cachedResult = false; static dispatch_once_t onceToken; diff --git a/UnleashedRecomp/gpu/video.cpp b/UnleashedRecomp/gpu/video.cpp index 020527662..722f94475 100644 --- a/UnleashedRecomp/gpu/video.cpp +++ b/UnleashedRecomp/gpu/video.cpp @@ -766,6 +766,17 @@ static void DestructTempResources() static std::thread::id g_presentThreadId = std::this_thread::get_id(); static std::atomic g_readyForCommands; static std::atomic g_appSuspended = false; +static std::atomic g_renderThreadParked = false; +static bool g_pendingWaitOnSwapChain = true; +static bool g_skipNextSwapChainDrain = false; +static std::atomic g_resumeFenceGrace = 0; + +#ifdef __APPLE__ +#include +#endif +#if TARGET_OS_IPHONE +#include +#endif PPC_FUNC_IMPL(__imp__sub_824ECA00); PPC_FUNC(sub_824ECA00) @@ -1574,12 +1585,23 @@ static void CreateImGuiBackend() static void CheckSwapChain() { + // Never touch the swap chain while suspended: the layer's drawables are + // invalid and acquiring would block or fail. + if (g_appSuspended.load(std::memory_order_acquire)) + { + g_swapChainValid = false; + return; + } + g_swapChain->setVsyncEnabled(Config::VSync); g_swapChainValid &= !g_swapChain->needsResize(); if (!g_swapChainValid) { - Video::WaitForGPU(); + if (g_skipNextSwapChainDrain) + g_skipNextSwapChainDrain = false; + else + Video::WaitForGPU(); g_backBuffer->framebuffers.clear(); g_swapChainValid = g_swapChain->resize(); g_needsResize = g_swapChainValid; @@ -1675,7 +1697,38 @@ static void BeginCommandList() commandList->setGraphicsDescriptorSet(g_samplerDescriptorSet.get(), 3); if (g_appSuspended.load(std::memory_order_relaxed)) { - g_appSuspended.wait(true, std::memory_order_acquire); + g_swapChainValid = false; +#if TARGET_OS_IPHONE + // This is a consistent frame boundary: the executor handshake for the + // frame completed earlier, so the fences WaitForGPU blocks on are real + // in-flight GPU work with bounded completion. Drain here — never in the + // lifecycle handler. + g_pendingWaitOnSwapChain = false; + g_dirtyStates.viewport = true; + Video::WaitForGPU(); + + // The guest render loop runs on the UIKit main thread. While suspended, + // main must neither keep running guest code (the background scene-update + // transaction starves and the watchdog kills the app) nor block on a + // bare wait (the runloop starves and the wake-up notification can never + // be delivered). It has to sit inside the runloop, so the system can + // service its transactions, cleanly suspend the process, and deliver + // the foreground notification that clears the flag on resume. + while (g_appSuspended.load(std::memory_order_acquire)) + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.25, true); + // First frame after resume: force a resize so the stale drawable + // generation is dropped and reacquired. The GPU was already drained on + // the way into the suspend and nothing touched it while idling, so the + // resize must not drain again: at this point a freshly queued command + // list may not have reached the executor yet, and waiting on its fence + // here deadlocks the resume frame. + g_swapChainValid = false; + g_skipNextSwapChainDrain = true; + // Command lists that straddle the suspend can render into drawables + // whose present was skipped; their completion fences may never fire. + // Give the first frames after resume a pass on the pacing fence. + g_resumeFenceGrace.store(NUM_FRAMES, std::memory_order_release); +#endif } g_readyForCommands = true; @@ -2845,19 +2898,22 @@ static void ProcDrawImGui(const RenderCommand& cmd) // 3. Loading thread also waits on swap chain. // 4. Loading thread presents and quits. // 5. After the loading thread quits, application also presents. -static bool g_pendingWaitOnSwapChain = true; +// (g_pendingWaitOnSwapChain is declared near the suspend state at the top of the file.) void Video::HandleApplicationBackgroundState(bool isBackgrounded) { if (isBackgrounded) { g_appSuspended.store(true, std::memory_order_release); - g_readyForCommands.store(false, std::memory_order_release); - g_pendingWaitOnSwapChain = false; - g_swapChainValid = false; - g_dirtyStates.viewport = true; - Video::WaitForGPU(); + // Nothing else may happen here. This runs inside a UIKit lifecycle + // callback on the main thread, and any unbounded wait (WaitForGPU + // blocks on a command fence the executor may not signal yet) hangs the + // callback until the scene-update watchdog kills the app. The actual + // drain and swap chain invalidation happen at the render loop's own + // suspend gate, which sits at a consistent frame boundary. + // g_readyForCommands is also deliberately left alone: stopping the + // executor would strand the guest thread on g_executedCommandList. } else { g_appSuspended.store(false, std::memory_order_release); g_appSuspended.notify_all(); @@ -2902,7 +2958,21 @@ void Video::Present() g_shouldPrecompilePipelines = false; } +#if TARGET_OS_IPHONE + // This wait runs on the UIKit main thread. If the executor stalls around a + // suspend, a bare atomic wait would leave the runloop unserviced and the + // background scene-update watchdog kills the app — so while suspended, + // service the runloop between checks instead of sleeping blind. + while (!g_executedCommandList.load(std::memory_order_acquire)) + { + if (g_appSuspended.load(std::memory_order_acquire)) + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.25, true); + else + g_executedCommandList.wait(false); + } +#else g_executedCommandList.wait(false); +#endif g_executedCommandList = false; if (g_swapChainValid) @@ -2925,6 +2995,16 @@ void Video::Present() if (g_commandListStates[g_frame]) { + uint32_t grace = g_resumeFenceGrace.load(std::memory_order_acquire); + if (grace != 0) + { + // See the resume path: fences of lists that straddled a suspend may + // never signal, so skip the pacing wait for the first frames back. + g_resumeFenceGrace.store(grace - 1, std::memory_order_release); + g_commandListStates[g_frame] = false; + } + else + { g_frameFenceProfiler.Begin(); g_queue->waitForCommandFence(g_commandFences[g_frame].get()); g_frameFenceProfiler.End(); @@ -2934,6 +3014,7 @@ void Video::Present() g_queryPools[g_frame]->queryResults(); const uint64_t *frameTimestamps = g_queryPools[g_frame]->getResults(); g_gpuFrameProfiler.Set(double(frameTimestamps[1] - frameTimestamps[0]) / 1000000.0); + } } g_dirtyStates = DirtyStates(true); diff --git a/UnleashedRecomp/main.cpp b/UnleashedRecomp/main.cpp index e34d7e102..92efb1d7b 100644 --- a/UnleashedRecomp/main.cpp +++ b/UnleashedRecomp/main.cpp @@ -214,6 +214,8 @@ int main(int argc, char *argv[]) os::logger::Init(); #if TARGET_OS_IOS + apple::RegisterLifecycleObservers(); + if (!apple::SupportsBCTextures()) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, GameWindow::GetTitle(), Localise("System_iOS_UnsupportedGPU_BCTextures").c_str(), GameWindow::s_pWindow); std::_Exit(1); diff --git a/UnleashedRecomp/ui/game_window.cpp b/UnleashedRecomp/ui/game_window.cpp index 5f1259a28..474cfdeb3 100644 --- a/UnleashedRecomp/ui/game_window.cpp +++ b/UnleashedRecomp/ui/game_window.cpp @@ -39,6 +39,7 @@ int Window_OnSDLEvent(void*, SDL_Event* event) break; } + case SDL_APP_WILLENTERFOREGROUND: case SDL_APP_DIDENTERFOREGROUND: { Video::HandleApplicationBackgroundState(false); break;