Fix component handle pool exhaustion crashing the screen process (MOB-100) - #85
Conversation
…-100)
Three compounding defects reported from a physical-device catalog screen
rendering ~60 native components after a few prior-screen navigations:
1. A full pool returned the same badarg as a malformed pid, crashing
Mob.ComponentServer.init (and, via the unmatched {:error, _} in
Mob.Component.ensure_started, the whole screen process). Both native
bridges (ios/mob_nif.m, android/jni/mob_nif.zig) now return
{:error, :component_slots_exhausted}; ComponentServer logs and fails
just that component, leaving the screen alive.
2. Slot 0 (a legitimate pool index) was conflated with the :no_render
sentinel (also 0), so terminate/2's `if handle != 0` guard skipped
deregistering it — permanent leak. Unified sentinel is now -1.
3. Discovered while writing a regression test against the real stop
path: Mob.ComponentRegistry.reconcile/2 stops components via
Process.exit(pid, :shutdown), but ComponentServer never trapped
exits — terminate/2 (and its deregister_component call) never ran
for ANY component leaving a screen's tree, not just slot 0. This was
the dominant leak, not an edge case. Fixed by trapping exits and
routing the resulting {:EXIT, ...} message through {:stop, ...}.
Also bumped MAX_COMPONENT_HANDLES 64 -> 256 on both platforms as
headroom (still fixed-size — a growable pool is a longer-term
follow-up, noted in the decision doc).
Device-verified on a physical Android phone and the iOS simulator:
mounting 300 native components (44 over the old cap) leaves the screen
fully responsive, allocates exactly 255 unique handles bounded within
the pool, and unmount/remount cycling reuses the identical slot range
with zero monotonic growth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…xhaustion Two findings from PR #85 review, both verified and fixed: 1. register_native_handle/4's case had no catch-all. mix mob.push can hot-deploy a newer BEAM onto native code that wasn't rebuilt (mix mob.deploy --native is a separate, opt-in step), so a native binary predating this fix could still pair with this BEAM — it returns a bare int on success and raises (enif_make_badarg) on exhaustion, matching neither {:ok, _} nor {:error, :component_slots_exhausted}. That raised CaseClauseError/ArgumentError in init/1, reintroducing the exact screen-crashing failure this PR exists to fix, via version skew. Now degrades to the -1 sentinel and logs instead, covering both the bare-int and the raising-badarg legacy shapes. 2. nif_register_tap (ios/mob_nif.m, android/jni/mob_nif.zig) still returned badarg on exhaustion of the 256-slot tap pool — the identical bug this PR just fixed for component handles, but for on_tap/on_change/ on_focus/etc. Since lib/mob/renderer.ex calls register_tap inline for every interactive element on every render, a screen with >256 tappable elements (unvirtualized long list, big form) crashed the same way. Every mob_send_* sender on both platforms already no-ops on an out-of-range handle, so the fix is a plain -1 return instead of badarg — no Elixir-side contract change needed, renderer.ex is unmodified. Device-verified on the physical Android phone and iOS simulator: a screen rendering 301 tappable buttons (45 over the cap) boots and stays fully responsive; the native "pool exhausted" log fires repeatedly across re-renders on both platforms with zero crashes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Both findings verified and fixed in cd4b59f:
Full details in the commit message. |
Summary
:mob_screenprocess withbadargfromregister_component, going tap-dead until force-kill.badargas a malformed pid, crashingMob.ComponentServer.initand the whole screen. Both native bridges now return{:error, :component_slots_exhausted}; the component fails individually, the screen survives.:no_rendersentinel (also 0), soterminate/2'sif handle != 0guard never deregistered it. Unified sentinel is now-1.Mob.ComponentRegistry.reconcile/2stops components viaProcess.exit(pid, :shutdown), butMob.ComponentServernever trapped exits —terminate/2(and itsderegister_componentcall) never ran for any component leaving a screen's tree, not just slot 0. This was the dominant leak. Seedecisions/2026-08-26-component-pool-trap-exit.md.MAX_COMPONENT_HANDLES64 → 256 on both platforms as headroom (still fixed-size; growable pool tracked as a follow-up).Test plan
mix test— 1034 passed, including new regression coverage intest/mob/component_server_test.exs(graceful exhaustion, slot-0 reuse, and a test exercising the realMob.ComponentRegistry.reconcile/2stop path to prove no leak)mix format/mix credo --strict/mix erlfmt --check src//clang-format/swiftlint— all cleantapscounter kept incrementing), allocated exactly 255 unique handles bounded within the pool, unmount/remount cycling reused the identical slot range (max 255) with zero monotonic growth.Linear: MOB-100