Skip to content

MOB-111: listener process — single inbound entry point from native - #97

Merged
GenericJam merged 2 commits into
masterfrom
feat/mob-111-listener
Aug 28, 2026
Merged

MOB-111: listener process — single inbound entry point from native#97
GenericJam merged 2 commits into
masterfrom
feat/mob-111-listener

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Third step of epic MOB-108. Follows #95 (MOB-109) and #96 (MOB-110).

Problem

Mob.Renderer named a screen process directly at ~35 call sites, one per interactive prop: nif.register_tap({screen_pid, tag}). That hard-wires the inbound path to whichever process rendered the tree, and it looked like the half of the epic that would force a native change.

It doesn't force one

nif_register_tap stores an arbitrary term as the handle's tag (enif_make_copy into the handle's own env) and the senders echo it back verbatim. So the tag can carry an envelope:

{listener_pid, {:mob_route, screen_pid, tag}}

Native delivers {:tap, {:mob_route, screen_pid, tag}} to Mob.Listener, which forwards {:tap, tag} to the screen. The screen sees exactly the message it saw before. No .m, .zig, or generator-template change — the epic's constraint.

:mob_screen is untouched: the back gesture, alert actions and launch-notification fallback resolve through enif_whereis_pid on both platforms, and MOB-113's router takes that name over, keeping the two changes separable.

The envelope carries a pid, not a ref

The epic sketched {listener_pid, {screen_ref, tag}}. Carrying the pid needs no registry and produces the behaviour the epic asked for: a handle registered by a screen that has since stopped delivers to a dead pid, which the BEAM drops, rather than landing in whatever screen is current with that screen's socket. That is the MOB-107 misrouting fix. A ref earns its cost at MOB-112, when a screen can restart and keep identity across a new pid.

A regression the review caught

The first commit unwrapped one shape, {event, tag}. Native has two families, and the second was silently dropped:

shapesenders
{event, tag}mob_send_tap, mob_send_event, mob_send_scrolled_past
{event, tag, payload}mob_send_change, mob_send_compose, mob_send_swipe_with_direction, mob_send_scroll, mob_send_drag, mob_send_pinch, mob_send_rotate, mob_send_pointer_move

That is every text field / toggle / slider on_change, every tab selection (on_tab_select is wired to mob_send_change_str), and every gesture stream — dead on both platforms, no crash, no log.

Two things let it through: the device verification was a button tap, the one shape that still worked; and the test double modelled only the 2-tuple family, so a test using :change passed while asserting the opposite of production behaviour.

Fixed in the second commit: a second handle_info/2 clause, FakeNative.fire/3 replaying the value-carrying senders, and a round-trip test for all eight. Unmodelled shapes now log at error instead of vanishing. No fourth arity exists — every enif_make_tuple4 in ios/mob_nif.m is a NIF return value, not a message.

Scope note

With one screen process the hop buys nothing on its own. The value is the indirection: the inbound path stops naming a screen process in 35 places, so MOB-112 and MOB-113 change one function. The escape hatch for high-frequency streams is simply not calling handler/1; nothing uses it, because the hop hasn't been measured.

Verification

  • 20 new tests, including a round trip that replays what the native senders actually do
  • Negative control: both 3-tuple tests fail without the clause
  • Suite 1201 passed, format / credo --strict / --warnings-as-errors clean
  • Device-verified on the broken path, not just a tap: typing into a text_field on the iOS simulator echoes on_change=Change wor back through the screen. Android's sendChange builds the identical 3-tuple. Earlier runs verified cold boot, first frame and taps on both platforms.

Found while verifying

mix mob.deploy hot-pushes over dist without writing BEAMs to disk, so changes revert on restart — filed as MOB-118 with evidence. That's the long-standing "deploy doesn't really work" behaviour, and why --native (which sets force_fs: true) always worked.

GenericJamand others added 2 commits August 28, 2026 14:31
Mob.Renderer named a screen process directly at ~35 call sites, one per
interactive prop: nif.register_tap({screen_pid, tag}). That hard-wires the
inbound path to whichever process rendered the tree, and it looked like the
half of the epic that would force a native change, since native stores and
dispatches those handles.
It does not. nif_register_tap stores an arbitrary term as the handle's tag
(enif_make_copy into the handle's own env) and mob_send_tap / mob_send_event
echo it back verbatim as {event, tag}. So the tag can carry more than a
label. The renderer now registers
{listener_pid, {:mob_route, screen_pid, tag}}
and Mob.Listener forwards {:tap, tag} to the screen. The screen sees exactly
the message it saw before, and no .m, .zig, or generator-template change is
needed — the epic's constraint.
The envelope is unwrapped on the event atom, so :tap, :change, :focus,
:blur, :submit, :dismiss, :select, :scroll, :drag and the rest go through
one clause. A new native event kind needs no listener change.
The envelope carries a pid rather than the screen ref the epic sketched. It
needs no registry, and it produces the behaviour the epic asked for: a
handle registered by a screen that has since stopped delivers to a dead pid,
which the BEAM drops, instead of being delivered into whatever screen is
current with that screen's socket. That is the MOB-107 misrouting. A ref
earns its cost when a screen can be restarted and keep identity across a new
pid, which is MOB-112.
:mob_screen is deliberately untouched. The back gesture, alert actions and
launch-notification fallback resolve through enif_whereis_pid on both
platforms, and MOB-113's router takes that name over — keeping the two
changes separable.
handler/1 returns its target unchanged when no listener is running, so any
boot path without one behaves exactly as before. That is why the renderer's
existing tests needed no changes.
Being honest about what this buys: with one screen process the hop is pure
overhead. The value is the indirection — the inbound path stops naming a
screen process in 35 places, so MOB-112 and MOB-113 change one function
instead. The escape hatch for high-frequency streams is simply not calling
handler/1; nothing uses it, because the hop has not been measured and
carving out an exception first would be guessing.
Rationale in decisions/2026-08-28-listener-single-inbound-entry.md.
Device-verified both platforms, since this rewires how every interaction
reaches Elixir: sheetprobe cold-started on the iOS simulator and the Android
emulator, and a real tap on each routed native -> listener -> screen ->
re-render -> sender -> native, presenting the correct sheet.
Tests: 17 new, including a round trip that replays what mob_send_tap does.
Suite 1198 passed, format and credo --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The listener unwrapped one envelope shape, {event, tag}, on the assumption
that every handle-addressed native event looked alike. Native has two
families, and the second was silently dropped:
{event, tag} mob_send_tap, mob_send_event, mob_send_scrolled_past
{event, tag, payload} mob_send_change, mob_send_compose,
mob_send_swipe_with_direction, mob_send_scroll,
mob_send_drag, mob_send_pinch, mob_send_rotate,
mob_send_pointer_move
So on the previous commit every text field, toggle and slider on_change,
every tab selection (on_tab_select is wired to mob_send_change_str), and
every gesture stream stopped working on both platforms — no crash, no log,
the control simply did nothing. Mob.Listener is started unconditionally by
Mob.App.start/0, so every real app was affected.
Two things let it through. The device verification was a button tap, the one
shape that still worked. And the test double modelled only the 2-tuple
family, so `test "other event kinds round trip too"` passed with :change —
a green test asserting the opposite of production behaviour, which is worse
than no test at all.
Fixed: a second handle_info/2 clause for the 3-tuple family. FakeNative now
has fire/3 replaying the value-carrying senders, and all eight have a
round-trip test. Verified as a negative control — both new tests fail
without the clause.
An unmodelled envelope shape is now logged at error rather than discarded,
since that failure is otherwise invisible. Checked that no fourth arity
exists: every enif_make_tuple4 in ios/mob_nif.m is a NIF return value, not a
message.
Also from the review: envelope/1 gained a catch-all so handler/1's two
branches agree on what they accept (previously a non-pid target passed
through with no listener and raised mid-render with one), and the test
teardown no longer uses the racy `if Process.alive?, do: GenServer.stop`
idiom, registering on_exit at start so a mid-test failure cannot leak a
globally-named listener into unrelated files.
Device-verified the actual broken path this time, not just a tap: typing
into a text_field on the iOS simulator shows `on_change=Change wor` echoed
back through the screen. Android's sendChange builds the identical 3-tuple,
so the same Elixir path covers it.
Suite 1201 passed, format and credo --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam merged commit 733cf3f into masterAug 28, 2026
4 checks passed
GenericJam added a commit that referenced this pull request Aug 29, 2026
First four steps of the screen-process architecture (MOB-108): multi-stack
navigation state (#95), the sender (#96), the listener (#97), and one process
per live screen (#99). None of the four required a .m, .zig, or template
change — the whole move happens above the native boundary.
The user-visible headlines are crash isolation that is finally real (a
crashing handle_event no longer takes navigation and every sibling screen
with it, which Mob.Screen's moduledoc claimed for a long time and mob#76 had
to correct), tab_bar/1 and drawer/1 backed by a runtime that can actually
hold more than one history, and MOB-107 fixed at the root: work started by a
screen now goes to that screen's own pid instead of whichever screen happens
to be current.
self() inside a screen callback now means that screen's own pid. That is
what user code already assumed when writing on_tap: {self(), :save}.
Called out as known gaps rather than left to be discovered: nothing renders
tab bar or drawer chrome yet, so switch_tab/2 is programmatic for now; and
MOB-115/116/117 remain open on reset_to/2's stack derivation, parked screens
missing terminate/2, and re-selecting the active tab not popping to root.
Release review of v0.7.32..HEAD: 1233 tests pass, format, credo --strict and
--warnings-as-errors clean. Spot-checked the merged MOB-112 against the
failure modes this architecture invites — per-screen render refs (so a
background screen cannot commit over the foreground), every owner-to-screen
call wrapped, render/1 running in the screen rather than the owner, a restart
ceiling, linked-and-trapping lifecycles, and drop_parked/2 wired to both call
sites. None outstanding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@GenericJam