fix: cold-start launch notifications dropped before nif_load (Android + iOS) - #77
fix: cold-start launch notifications dropped before nif_load (Android + iOS)#77asheehan wants to merge 1 commit into
Conversation
… iOS) mob_set_launch_notification (zig) and mob_set_launch_notification_json (objc) returned early when their mutex didn't exist yet — but the mutex is created in nif_load, and the cold-start path (MainActivity.onCreate / app delegate on a notification tap that launches the app) stores the payload BEFORE the BEAM boots. The tapped notification was silently discarded every time: tap-to-open from a killed app never worked on either platform. Warm and backgrounded taps (onNewIntent / running delegate) were unaffected. Store pre-mutex instead, exactly the pattern mob_set_opened_document has always used and documented: nothing reads the global until take_launch_notification, which can only run after nif_load, so the unguarded window has no concurrent reader. Post-load stores still lock. Found adversarially reviewing BuyFrost's push enablement: the opened-document sibling's comment describes the cold-launch window explicitly, which is what exposed the launch-notification variant as a latent bug rather than a design choice.
asheehan
commented
Aug 16, 2026
Filed #81 to track the underlying bug. |
The matrix had drifted in both directions — overstating something broken and understating something shipped, twice. - **Notification tap handling: ✅ → 🟡.** It claimed "Foreground + background + cold-start". Cold-start does not work: the payload is handed to mob_set_launch_notification before nif_load has created the mutex, so the setter returns early and take_launch_notification/0 gets nil. On Android the host calls it ~75 lines before the BEAM thread is even created, so the drop is guaranteed, not racy; on iOS the integration point mob_beam.h documents (didFinishLaunchingWithOptions:) is likewise pre-BEAM. Points at #81 and the community fix in #77, which will flip this back to ✅ when it lands. - **`<Modal>` (sheet presentation): 🟡 → ✅.** It said "full sheet-style modal is plugin territory". Mob.UI.sheet/2 has been core since 0.7.29 (iOS .sheet), with the Android Material 3 ModalBottomSheet renderer published in mob_new 0.4.24 — noted, since Android needs a project generated by that version or newer. - **Bottom sheets: ❌ → ✅.** Listed as a plugin candidate; it is literally the same primitive, with :detents. Notes the documented iOS scrim-opacity limitation rather than implying parity. Verified rather than assumed: Mob.UI.sheet/2 at lib/mob/ui.ex:336, MobSheetView in ios/MobRootView.swift, and MobSheet in the published mob_new 0.4.24 template. Swept the neighbouring rows too — Bluetooth Classic and QR/barcode are accurate. Separately, mob_video (0.1.0), mob_touch (0.1.0) and mob_screencast (0.1.1) are published on Hex but have no rows at all, so the matrix reads as though those capabilities don't exist. Left out of this commit rather than guessed at; each needs its platform support checked before it earns a row. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
GenericJam
commented
Aug 30, 2026
Reviewed, verified, and landed — thank you for an excellent fix and the precise diagnosis in #81. The review confirmed everything: both setters bailed pre-nif_load on master, the cold-start ordering means the unguarded pre-mutex write has no concurrent reader (onCreate stores before the BEAM thread spawns; the only reader can't run before nif_load), and it's byte-for-byte the accepted mob_set_opened_document pattern. We A/B-verified on an Android emulator: baseline NIF drops the payload on a true cold start (take_launch_notification → :none, confirmed down to the early-return in the disassembly); with your fix the same cold start delivers the payload end-to-end into a screen's handle_info. 1280 tests pass on the rebased tree. Your branch had drifted 48 commits (only CHANGELOG conflicted), and pushing a rebase back to your fork wasn't possible from this side, so your commit was rebased and pushed to master directly with your authorship preserved: ed1e275. Closing this PR as landed — the fix ships in the next release. One tiny follow-up we're tracking separately (pre-existing pattern, also in mob_set_opened_document): the setters read the mutex global twice, so capturing it once into a local would close a vanishingly narrow TOCTOU window across all four functions. |
Written minutes before #80 merged, the honesty-contract and evidence-matching sections claimed no pixel-sampling API existed and leaned solely on state-change assertions. Now that tap_xy/3 reports observed effect, both guides document the contract: :ok only when an event reached the BEAM within 300ms, else {:error, :no_view_at_point | :no_element_at_point | :no_effect} — plus the platform limits that make :no_effect legitimate (SwiftUI on_tap containers, physical-device injection) and the serial-harness assumption the 300ms window shares with state-change checks. Evidence matching now splits exact-color decisions (sample_color/2: real pixels, dominant/average as 0xAARRGGBB, iOS debug-build only) from holistic visual parity (screenshots with tolerance). testing.md gains matching sections. No change needed for #77: push_notifications.md already described tap-to-open from a killed app, which that fix made true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eature coverage (#105) * docs(navigation): multi-stack state, honest tab/drawer claims, back semantics navigation.md still described tab_bar/drawer as rendering native chrome (UITabBarController / NavigationBar) — since 0.7.33 the runtime backs the declaration with real per-stack state but draws no chrome; switching is programmatic. New 'Tabs and multi-stack state' section documents lazy materialization, parking, independent histories, back-at-secondary-root, the orphan stack, and the MOB-115/116/117 gaps. Directional-reset docs gain the ArgumentError validation and transition-survives-coalescing behavior (#100/#103). screen_lifecycle.md gains crash/restart semantics (per-screen isolation, restart cap, re-mount + load_state), per-screen self() and message delivery, terminate/2 reality (pop stops the leaving screen only), and multi-stack system-back. Mob.App.tab_bar/1 and drawer/1 docstrings no longer claim chrome that is not drawn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(testing): document Mob.ScreenCase and settle/2; remove nonexistent screen_pid/1 The testing guide never mentioned Mob.ScreenCase (#44), the blessed in-BEAM unit-test path — it now leads the guide. The old sync-point advice referenced Mob.Test.screen_pid/1, which does not exist, and :sys.get_state on :mob_screen, which stopped being sufficient when rendering moved to Mob.Sender (MOB-110) and :mob_screen became the navigation owner (MOB-112); both are replaced with Mob.Test.settle/2 and an explanation of the three processes it drains. Unit-test examples updated for the process model (dispatch is synchronous; get_socket is the natural sync point after send). Mob.Test's moduledoc claimed tap/2 goes through handle_event/3; it sends {:tap, tag} to handle_info/2 like a real native tap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(components,theming): Sheet section, handle-pool limits, font tokens components.md had no coverage of Mob.UI.sheet/2 (0.7.29) or intrinsic content detents (0.7.32) beyond the surface-matrix row — new ':sheet' section documents presence-is-presentation, detents including [:content] / [{:content, max_height: n}], exactly-once {:dismiss, tag}, and the iOS scrim limitation. New 'Handle limits' section covers the 256-handle tap pool and the 256-slot native component pool with {:error, :component_slots_exhausted} (0.7.28 behavior). :text gains the font prop (named font tokens, 0.7.25). theming.md never mentioned fonts — adds the fonts:/font_fallback: token type with a pointer to Styling → Custom fonts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix tap examples to handle_info/2 and stale module/function names README and several guides showed UI taps handled by handle_event("tap", %{"tag" => ...}) — a real tap delivers {:tap, tag} to handle_info/2, so those example screens would never respond on a device. README's diagram and testing snippet updated for the per-screen process model and Mob.ScreenCase. getting_started referenced Mob.Nav.push/2, which does not exist (Mob.Socket.push_screen is the API). event_audit's list-select re-emitter is Mob.Screen.Server since MOB-113. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(agentic): split into single-agent and agent-team halves; new practices Part 1 (Working with one agent) keeps the existing content in order and adds: verify effects not exit codes (assert the app answers after a deploy), the honesty contract (success = a handler ran — assert on state change after a tap, settle-window caveat), match the evidence to the question (frames for layout, screenshots-with-tolerance for appearance, recordings for motion), lifecycle-event simulation recipes (simctl push .apns, adb broadcast / cmd notification post), and environment discipline (complete .tool-versions incl. zig/JDK, the MOB_DIR/MOB_DEV_DIR/MOB_NEW_DIR override chain). Part 2 (Working with agent teams) is new: one driver per device with lease discipline (humans outrank agents), unique node names per session (mob.connect --name), per-task git worktrees, the mob.push/mob.watch fan-out hazard (they reach every live node, no device scoping — fleets deploy per device or push over their own dist connection), and durable artifacts as the handoff medium between context windows. The standard agent loop gains a settle/2 step before native-side reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(mix): group new modules in hexdocs sidebar Mob.Router joins Navigation; Mob.Screen.Server, Mob.Listener and Mob.Sender get a Runtime Processes group; Mob.ScreenCase joins Testing & Debugging. All five were shipping ungrouped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document #80's honest tap returns and pixel sampling Written minutes before #80 merged, the honesty-contract and evidence-matching sections claimed no pixel-sampling API existed and leaned solely on state-change assertions. Now that tap_xy/3 reports observed effect, both guides document the contract: :ok only when an event reached the BEAM within 300ms, else {:error, :no_view_at_point | :no_element_at_point | :no_effect} — plus the platform limits that make :no_effect legitimate (SwiftUI on_tap containers, physical-device injection) and the serial-harness assumption the 300ms window shares with state-change checks. Evidence matching now splits exact-color decisions (sample_color/2: real pixels, dominant/average as 0xAARRGGBB, iOS debug-build only) from holistic visual parity (screenshots with tolerance). testing.md gains matching sections. No change needed for #77: push_notifications.md already described tap-to-open from a killed app, which that fix made true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The matrix had drifted in both directions — overstating something broken and understating something shipped, twice. - **Notification tap handling: ✅ → 🟡.** It claimed "Foreground + background + cold-start". Cold-start does not work: the payload is handed to mob_set_launch_notification before nif_load has created the mutex, so the setter returns early and take_launch_notification/0 gets nil. On Android the host calls it ~75 lines before the BEAM thread is even created, so the drop is guaranteed, not racy; on iOS the integration point mob_beam.h documents (didFinishLaunchingWithOptions:) is likewise pre-BEAM. Points at #81 and the community fix in #77, which will flip this back to ✅ when it lands. - **`<Modal>` (sheet presentation): 🟡 → ✅.** It said "full sheet-style modal is plugin territory". Mob.UI.sheet/2 has been core since 0.7.29 (iOS .sheet), with the Android Material 3 ModalBottomSheet renderer published in mob_new 0.4.24 — noted, since Android needs a project generated by that version or newer. - **Bottom sheets: ❌ → ✅.** Listed as a plugin candidate; it is literally the same primitive, with :detents. Notes the documented iOS scrim-opacity limitation rather than implying parity. Verified rather than assumed: Mob.UI.sheet/2 at lib/mob/ui.ex:336, MobSheetView in ios/MobRootView.swift, and MobSheet in the published mob_new 0.4.24 template. Swept the neighbouring rows too — Bluetooth Classic and QR/barcode are accurate. Separately, mob_video (0.1.0), mob_touch (0.1.0) and mob_screencast (0.1.1) are published on Hex but have no rows at all, so the matrix reads as though those capabilities don't exist. Left out of this commit rather than guessed at; each needs its platform support checked before it earns a row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written minutes before #80 merged, the honesty-contract and evidence-matching sections claimed no pixel-sampling API existed and leaned solely on state-change assertions. Now that tap_xy/3 reports observed effect, both guides document the contract: :ok only when an event reached the BEAM within 300ms, else {:error, :no_view_at_point | :no_element_at_point | :no_effect} — plus the platform limits that make :no_effect legitimate (SwiftUI on_tap containers, physical-device injection) and the serial-harness assumption the 300ms window shares with state-change checks. Evidence matching now splits exact-color decisions (sample_color/2: real pixels, dominant/average as 0xAARRGGBB, iOS debug-build only) from holistic visual parity (screenshots with tolerance). testing.md gains matching sections. No change needed for #77: push_notifications.md already described tap-to-open from a killed app, which that fix made true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both platform stores for the launch notification bail out when called before
nif_loadhas created their mutex:mob_set_launch_notification—const mutex = g_launch_notif_mutex orelse return;mob_set_launch_notification_json—if (!g_launch_notif_mutex) return;But the mutex is created in
nif_load, and the cold-start path runs strictly before that: on a notification tap that launches a killed app,MainActivity.onCreate(Android) / the app delegate (iOS) store the payload before the BEAM thread boots. The payload is silently discarded every time — tap-to-open from a killed app has never worked on either platform. Warm/backgrounded taps (onNewIntent/ running delegate) are unaffected, which makes the bug easy to miss in testing.The fix mirrors
mob_set_opened_document, which has always stored pre-mutex and documents why it's safe: nothing reads the global untiltake_launch_notification, which can only run afternif_load, so the unguarded window has no concurrent reader. Post-load stores still lock. (zig ast-checkpasses; the objc change is the same two-line guard restructure.)Found while wiring FCM push in a mob app (BuyFrost): an adversarial review noticed the opened-document sibling's comment describes exactly this cold-launch window, exposing the launch-notification variant as a latent bug rather than a design choice.
CHANGELOG entry added under Unreleased.