Survive app suspension and resume on iOS - #10
yorgunkral31 wants to merge 1 commit into
Conversation
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.
|
Thanks for the contribution. I will review the fix as soon as I can.
I have found in my testing that this unfortunately is not enough to resolve the 2/3 issue and has minimal effect on its own. I am also unsure about the general effectiveness of this method with a dummy |
|
Thanks for taking a look so quickly! Fair point on the 2/3 cap — that part was my extrapolation, I only actually verified the above-60 unlock, and I agree it does very little on its own: in my testing nothing went above 60 until all three pieces (the in-game setting, the plist key, and the range request) were in place together. Happy to drop or rework the display-link part however you prefer — e.g. requesting the range only while a >60 target is active, or moving it off the dummy-callback approach entirely. The suspend fix itself is independent of all that; looking forward to your review. |
|
Hi, I have tested these changes on iPad Pro M4 8GB. Firstly, as Markos stated above, this unfortunately does not fix the 2/3FPS bug. I can subjectively say that the frame-rate cap stays at 60 far more than it did before, but it still randomly changes to 40 very often for extended periods of time. I did notice that standing in one spot without any changes to environment, the frame-rate cap does change from 60 to 40 and back without any intentional interruptions. This didn't happen before, but this could just be a coincidence. It would be valuable to test this on ProMotion iPhones, behavior could likely be different. One pattern I noticed is that sideloaded games over 4-5 years old tend to not have this issue. I don't know why. Secondly, suspension seems to be almost fully fixed. Screenshots did not crash the game before and after this fix on iPad Pro M4 8GB, but I think that it did on iPhone 17. Now, opening notification center does not crash the game. Game remains responsive. Opening control center does not crash the game. Game remains responsive. Swiping to home screen does not crash the game. Game remains responsive. Switching apps does not crash the game. Game remains responsive. Lock/unlock is very inconsistent. In general, everything is inconsistent. Sometimes, lock/unlock crashes it, sometimes it doesn't. Sometimes changing 3D resolution crashes it, sometimes it doesn't. Sometimes exiting to title screen crashes it, sometimes it doesn't. I'm not sure if I can provide logs right now. When exiting the app, the game will play its audio for around one minute until it shuts down and needs to be re-launched. This is typical behavior, even for App Store games. I'm not suggesting that this should or can be fixed, but Super Mario 64 iOS doesn't do this. It just stays in the background playing the games ambient noise of birds chirping and Mario dreaming of spaghetti. Interesting that this is possible on iOS. Tested on LiveContainer, I can test opening the app directly through SideStore at some later time (.ipa loaded directly onto the device), but I won't test other sideloading managers. There is most likely no difference between LiveContainer and directly installed .ipa's, however. Between different sideloading managers (SideStore, AltStore, etc) there is no difference. The .ipa is installed directly to the device, it is like any other app. |
|
Thank you for the thorough testing — really glad the main suspend paths hold up on the iPad as well. The remaining inconsistent cases you found (lock/unlock, 3D resolution change, exiting to the title screen) are on me to figure out: they all pass through the swap chain recreation path, so I suspect a shared cause. I'll investigate and follow up here; logs whenever convenient would help a lot, but no rush at all. On the frame rate side I'll defer to Markos — happy to drop or rework the display-link piece in whatever direction he prefers, and going forward I'll keep any claims strictly to what I've directly measured. |
iOS limits apps to 60 Hz on iPhones with ProMotion displays unless they declare CADisableMinimumFrameDurationOnPhone, so frame rates above 60 FPS had no effect there. iPads don't need it. Based on upstream pull request hedge-dev#1767 by Çağan Özcan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBtL1JSminRPnypJpP2HHW
|
I appreciate the effort but I have identified a simpler way of resolving the issue. Because of that, I’m going to go ahead and close this PR. If in the future I identify any issues with the current solution I will reconsider this. Thank you for the contribution! |
First of all — thank you for making this port exist at all. Getting Unleashed running on iOS in the first place is the hard part, and everything below only exists because your foundation made it possible to build on. 💙
This PR fixes the suspend issue end to end — the one you called out as the current focus. The full write-up is in the commit message; the short version: it turned out to be six independent failure modes stacked on top of each other (SDL-only wake-up delivery;
WaitForGPUinside the lifecycle callback; the render loop starving either the scene-update transaction or the runloop depending on how it waited; the executor being stopped and never restarted; a resume-time drain racing a fresh command list; and pacing fences of suspend-straddling lists never signalling). Each one was identified from a live thread dump or a0x8BADF00Dcrash report on an iPhone 16 Pro before being fixed — happy to share any of the reports.Verified on device with the native Metal backend: repeated 30+ second home/background cycles, control center opened over active gameplay, lock/unlock, and app switching all resume cleanly with rendering intact.
Two notes:
MetalSwapChain::resize()(yorgunkral31/plume@a19f137); without it, drawable references from the pre-suspend generation linger.CADisableMinimumFrameDurationOnPhonein the Info.plist template, and an idleCADisplayLinkrequesting the panel's full frame rate range on the plume side, yorgunkral31/plume@c7e2c32 — verified above 60 on the 16 Pro, and it should also fix the well-known 2/3-refresh cap after interruptions). Take whatever is useful, in whatever shape works for you!I'd only kindly ask that authorship is preserved when it lands — and thanks again for the port. 🙏