Skip to content

Fix iOS startup crash: retry instead of panicking when the winit window is not registered yet - #14

Merged
extrawurst merged 2 commits into
rustunit:mainfrom
eriksixt:fix/ios-window-handle-startup-race
Jul 28, 2026
Merged

Fix iOS startup crash: retry instead of panicking when the winit window is not registered yet#14
extrawurst merged 2 commits into
rustunit:mainfrom
eriksixt:fix/ios-window-handle-startup-race

Conversation

@eriksixt

Copy link
Copy Markdown
Contributor

Problem

IosSafeAreaPlugin schedules its init system in Startup, which runs exactly once, and does:

let raw_window = windows.get_window(*window).expect("invalid window handle");

On iOS the winit UIWindow is not guaranteed to be registered in WINIT_WINDOWS by the time Startup runs — window creation is event-loop-driven and can complete a frame or two after Bevy's first update. When the timing lands wrong, get_window returns None and the .expect panics on the main thread. The unwind then hits winit's non-unwinding extern "C" CFRunLoop observer (panic_cannot_unwind), so the app dies with SIGABRT.

In our app (Bevy 0.18.1, iPhone 12 Pro Max, iOS 26.5, TestFlight release builds) this reproduced on ~50% of cold launches — a coin flip per launch depending on whether the OS window registration beat Bevy's first update. Sentry backtrace of the panic origin:

expect<T> (option.rs:971)
{closure#0} (plugin.rs:98)
init (plugin.rs:97)

The PrimaryWindow entity exists at that point (the Single param resolves) — it's specifically the winit-side registry entry that isn't there yet, a precondition the plugin can't control. The code already handles the very next step (raw_window.window_handle()) gracefully with if let Ok(...); only the registry lookup panics.

Fix

  • Schedule init in Update instead of Startup, self-disabling via a Local<bool> latch once the insets have been read (a single bool check per frame afterwards).
  • Replace the .expect with a let Some(...) else { return None } skip-and-retry: if the window isn't registered yet, try again next frame.

Behavior is unchanged for every launch that works today; launches that previously crashed now pick up the safe-area insets one or two frames later. Design tradeoff: the system retries by polling rather than subscribing to WindowCreated events — simpler and the steady-state cost is negligible. Happy to rework it event-driven if you prefer.

Verification

  • Deployed as a vendored patch of 0.5.1 in our game: the ~50% cold-launch crash is gone across repeated TestFlight cold launches on the affected device, and safe-area-dependent UI still lays out correctly (insets land within the first frames, before the first user-visible screen).
  • cargo check --target aarch64-apple-ios clean on this branch.

A patch release with this fix would let us drop our vendored copy — thank you!

🤖 Generated with Claude Code

…s not registered yet

On iOS the winit UIWindow is not guaranteed to be registered in
WINIT_WINDOWS during the first frame, so the run-once Startup init
system could hit .expect("invalid window handle") and abort the app
(the panic cannot unwind through winit's extern "C" CFRunLoop
observer). Observed on ~50% of cold launches on iOS 26.5.

init now runs in Update, skips the frame when the window handle is not
yet available, and disables itself via a Local<bool> once the insets
have been read.
@extrawurst

extrawurst commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Great find!

I wonder if there is otherwise a reliable window-created message/event we could subscribe to instead

bevy_winit writes WindowCreated immediately after registering the
window in WINIT_WINDOWS (bevy_winit/src/system.rs create_windows), so
subscribing to that message is the earliest point where the handle is
guaranteed to be available. Replaces the Local<bool> polling latch.
@eriksixt

Copy link
Copy Markdown
Contributor Author

Good call — there is exactly such a signal on the Bevy side: bevy_winit writes the WindowCreated message immediately after registering the window in WINIT_WINDOWS (create_windows in bevy_winit/src/system.rs), so reacting to it gives a hard guarantee the handle is available — no polling needed.

Reworked in 6e5a7ad: init now takes a MessageReader<WindowCreated> and reads the insets when the primary window's creation message arrives; the Local<bool> latch is gone. The registry lookup stays panic-free as a belt-and-braces measure. cargo check --target aarch64-apple-ios is clean.

I considered subscribing to a native UIKit notification instead, but WindowCreated fires at exactly the right moment relative to WINIT_WINDOWS registration (which is the resource this crate actually reads), so staying on the Bevy side seemed both simpler and more precise.

@eriksixt
eriksixt marked this pull request as draft July 17, 2026 07:22
@eriksixt
eriksixt marked this pull request as ready for review July 17, 2026 07:48
@eriksixt

Copy link
Copy Markdown
Contributor Author

Validated the WindowCreated variant on device: ~10 TestFlight cold launches on the affected device (iPhone 12 Pro Max, iOS 26.5) — no crashes, safe-area insets land correctly. Marking ready for review.

@extrawurst
extrawurst merged commit cfe51a0 into rustunit:main Jul 28, 2026
4 of 6 checks passed
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.

2 participants