Skip to content

Survive app suspension and resume on iOS - #10

Closed
yorgunkral31 wants to merge 1 commit into
Markos-Th09:iosfrom
yorgunkral31:ios-suspend-fix
Closed

yorgunkral31 wants to merge 1 commit into
Markos-Th09:iosfrom
yorgunkral31:ios-suspend-fix

Conversation

@yorgunkral31

Copy link
Copy Markdown

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; WaitForGPU inside 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 a 0x8BADF00D crash 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:

  • This pairs with a small plume-side change that releases held drawables in MetalSwapChain::resize() (yorgunkral31/plume@a19f137); without it, drawable references from the pre-suspend generation linger.
  • A couple of other things living in my branch that might be useful to you, in case you want them: the 120 FPS / ProMotion unlock (it needs three parts together — the in-game FPS setting, CADisableMinimumFrameDurationOnPhone in the Info.plist template, and an idle CADisplayLink requesting 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. 🙏

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.
@Markos-Th09

Copy link
Copy Markdown
Owner

Thanks for the contribution. I will review the fix as soon as I can.

and it should also fix the well-known 2/3-refresh cap after interruptions

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 CADisplayLink callback in general as a long-term solution.

@yorgunkral31

Copy link
Copy Markdown
Author

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.

@anteyeintelligence

anteyeintelligence commented Sep 25, 2026 •

Copy link
Copy Markdown

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.
I will try different iPadOS versions, once I confirm there is no harm to sideloading functionality, and see if this solves it.

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.

@yorgunkral31

Copy link
Copy Markdown
Author

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.

yorgunkral31 referenced this pull request in DevZer0D4Y/UnleashedRecompilediOS Sep 26, 2026
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
@Markos-Th09

Copy link
Copy Markdown
Owner

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!

This branch was successfully deployed

1 active deployment
external — 76d40164 Deployed Sep 24, 2026 by yorgunkral31 via authorize #1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants