From e0d887f0b78915d4c7d8dd1ab9a8a40673a78c77 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 27 Jul 2026 14:01:08 -0600 Subject: [PATCH 1/3] iOS: bound safe_area()'s boot-path dispatch_sync to avoid a launch deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mob.Screen.init/1 calls safe_area() synchronously before the first screen mounts. A plain dispatch_sync to the main queue there is a deadlock risk if the main thread hasn't reached an idle run-loop tick yet — observed via a device-family/compatibility-mode boot hang on iPad. Switch to dispatch_async + a bounded semaphore wait, falling back to zero insets on timeout, and mark the NIF dirty (IO-bound) so a slow main thread can no longer stall a regular BEAM scheduler either. Co-Authored-By: Claude Sonnet 5 --- ios/mob_nif.m | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 23ab1d6..22d5151 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -1827,10 +1827,19 @@ static ERL_NIF_TERM nif_device_keep_awake(ErlNifEnv *env, int argc, const ERL_NI // ── NIF: safe_area/0 ───────────────────────────────────────────────────────── // Returns {Top, Right, Bottom, Left} in logical points (not pixels). // Must read UIWindow.safeAreaInsets on the main thread. - +// +// Called from Mob.Screen.init/1 — i.e. on the boot path, before the first +// screen ever mounts. A plain dispatch_sync here is a deadlock risk: if the +// main thread hasn't reached an idle run-loop tick yet (observed during +// scene-attachment on iPad, including the compatibility-mode window an +// iPhone-only app runs in there), this blocks the BEAM boot thread forever — +// the app never finishes launching. Bound the wait and fall back to zero +// insets on timeout: a screen with wrong insets once is a far smaller bug +// than an app that never boots. static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIEdgeInsets insets = UIEdgeInsetsZero; - dispatch_sync(dispatch_get_main_queue(), ^{ + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ UIWindow *window = nil; for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { if ([scene isKindOfClass:[UIWindowScene class]]) { @@ -1841,7 +1850,10 @@ static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM a } if (window) insets = window.safeAreaInsets; + dispatch_semaphore_signal(done); }); + dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)); + dispatch_semaphore_wait(done, deadline); return enif_make_tuple4( env, enif_make_double(env, insets.top), enif_make_double(env, insets.right), enif_make_double(env, insets.bottom), enif_make_double(env, insets.left)); @@ -6327,7 +6339,7 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"register_tap", 1, nif_register_tap, 0}, {"clear_taps", 0, nif_clear_taps, 0}, {"exit_app", 0, nif_exit_app, 0}, - {"safe_area", 0, nif_safe_area, 0}, + {"safe_area", 0, nif_safe_area, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"haptic", 1, nif_haptic, 0}, {"torch", 1, nif_torch, 0}, {"clipboard_put", 1, nif_clipboard_put, 0}, From f9dcb7dc9eeae0f70cc4902189d3988c7ec5fb50 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 28 Jul 2026 19:18:59 -0600 Subject: [PATCH 2/3] iOS: add a boot watchdog so a stuck launch shows an error, not a black screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If BEAM boot doesn't produce a first screen within 15s, surface an explicit "Startup Error" state instead of leaving the black startup spinner running forever. An indefinite spinner and a persistent error look identical to "frozen" from the outside — this makes any future boot-path stall diagnosable (and visibly not "blank") rather than ambiguous. Follow-up to the safe_area() dispatch_sync fix: that closed the one known boot-path deadlock, but a second App Store review on the fixed build still reported a black/blank launch on iPad. Root-cause without physical iPad hardware isn't possible from here, so this is a defensive net for whatever is still stalling boot on that device/OS combination, verified not to false-positive on a normal boot (simulator, both iPhone and iPad). Co-Authored-By: Claude Sonnet 5 --- ios/MobViewModel.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ios/MobViewModel.swift b/ios/MobViewModel.swift index 2816893..52318fa 100644 --- a/ios/MobViewModel.swift +++ b/ios/MobViewModel.swift @@ -7,6 +7,13 @@ import Combine @objc public class MobViewModel: NSObject, ObservableObject { @objc public static let shared = MobViewModel() + // How long to wait for BEAM boot to produce a first screen before treating + // it as stuck. An indefinite spinner and a persistent error look identical + // to "frozen" from the outside — this turns a silent hang into an explicit, + // diagnosable state instead of leaving the app on the black startup screen + // forever (seen: App Store review reporting an indefinite/blank launch). + private static let bootWatchdogSeconds: TimeInterval = 15 + @Published public var root: MobNode? /// Increments on every setRoot call; views use onChange(of: rootVersion) to /// trigger withAnimation rather than watching root directly (root identity @@ -25,6 +32,17 @@ import Combine /// Non-nil when a fatal startup error has occurred; the error screen stalls here. @Published public var startupError: String? + override init() { + super.init() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.bootWatchdogSeconds) { [weak self] in + guard let self, self.root == nil, self.startupError == nil else { return } + self.startupError = + "Startup is taking longer than expected — this usually means a " + + "native call blocked the main thread during boot. Please force-quit " + + "and relaunch the app." + } + } + @objc public func setRoot(_ node: MobNode?, transition: String) { DispatchQueue.main.async { self.transition = transition From a9e0f981ae6834d8638d884e2265ad34320799b6 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 28 Jul 2026 19:29:22 -0600 Subject: [PATCH 3/3] iOS: include the app version/build number in the boot-watchdog error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the watchdog fires, show "Version ()" alongside the error text — a cheap way to confirm whoever sees it (us, a tester, a reviewer) is actually looking at the build they think they are, rather than a stale cached copy. Co-Authored-By: Claude Sonnet 5 --- ios/MobViewModel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ios/MobViewModel.swift b/ios/MobViewModel.swift index 52318fa..87be821 100644 --- a/ios/MobViewModel.swift +++ b/ios/MobViewModel.swift @@ -36,10 +36,13 @@ import Combine super.init() DispatchQueue.main.asyncAfter(deadline: .now() + Self.bootWatchdogSeconds) { [weak self] in guard let self, self.root == nil, self.startupError == nil else { return } + let info = Bundle.main.infoDictionary + let shortVersion = (info?["CFBundleShortVersionString"] as? String) ?? "?" + let build = (info?["CFBundleVersion"] as? String) ?? "?" self.startupError = "Startup is taking longer than expected — this usually means a " + "native call blocked the main thread during boot. Please force-quit " + - "and relaunch the app." + "and relaunch the app.\n\nVersion \(shortVersion) (\(build))" } }