Skip to content

Fix component handle pool exhaustion crashing the screen process (MOB-100) - #85

Merged
GenericJam merged 2 commits into
masterfrom
fix/mob-100-component-pool-exhaustion
Aug 26, 2026
Merged

Fix component handle pool exhaustion crashing the screen process (MOB-100)#85
GenericJam merged 2 commits into
masterfrom
fix/mob-100-component-pool-exhaustion

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Summary

  • Fixes a downstream-reported bug: a screen registering ~60 native components (icon catalog) crashed its :mob_screen process with badarg from register_component, going tap-dead until force-kill.
  • Three compounding defects, all fixed:
    1. Graceful exhaustion — full pool returned the same badarg as a malformed pid, crashing Mob.ComponentServer.init and the whole screen. Both native bridges now return {:error, :component_slots_exhausted}; the component fails individually, the screen survives.
    2. Slot-0 leak — slot 0 (a valid pool index) was conflated with the :no_render sentinel (also 0), so terminate/2's if handle != 0 guard never deregistered it. Unified sentinel is now -1.
    3. Root cause found while writing the regression test: Mob.ComponentRegistry.reconcile/2 stops components via Process.exit(pid, :shutdown), but Mob.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. See decisions/2026-08-26-component-pool-trap-exit.md.
    4. Bumped MAX_COMPONENT_HANDLES 64 → 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 in test/mob/component_server_test.exs (graceful exhaustion, slot-0 reuse, and a test exercising the real Mob.ComponentRegistry.reconcile/2 stop path to prove no leak)
  • mix format / mix credo --strict / mix erlfmt --check src/ / clang-format / swiftlint — all clean
  • Device-verified on a physical Android phone (moto g power) and the iOS simulator: mounted 300 native components (44 over the old 64 cap, 44 over even the new headroom), screen stayed fully responsive (taps counter 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

GenericJam and others added 2 commits August 26, 2026 02:19
…-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>
@GenericJam

Copy link
Copy Markdown
Owner Author

Both findings verified and fixed in cd4b59f:

  1. Version-skew catch-all: register_native_handle/4 now handles a stale native binary's bare-int success return and raising badarg/ArgumentError on exhaustion (both the pre-fix contract), degrading to the -1 sentinel + a logged warning instead of crashing. Covered by two new tests simulating each legacy shape.

  2. Sibling tap-pool exhaustion: nif_register_tap on both platforms now returns -1 instead of badarg when the 256-slot tap pool is full. Turned out to need zero Mob.Renderer changes — every mob_send_* sender already no-ops on an out-of-range handle, so the existing bare-int contract already accommodates a sentinel. Device-verified on physical Android + iOS simulator: a screen with 301 tappable buttons (45 over cap) boots and stays fully responsive; the native "pool exhausted" log fires repeatedly with zero crashes.

Full details in the commit message.

@GenericJam
GenericJam merged commit aededa3 into master Aug 26, 2026
4 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.

1 participant