Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions decisions/2026-08-28-listener-single-inbound-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# The listener: one inbound entry point, without touching native

- Date: 2026-08-28
- Status: accepted
- Implements: MOB-111, third step of MOB-108
- Builds on: `2026-08-27-screen-process-architecture.md`

## Context

`Mob.Renderer` registered interaction handlers by naming a screen process
directly — `nif.register_tap({screen_pid, tag})`, at ~35 call sites, one per
interactive prop. That hard-wires the inbound path to whichever process rendered
the tree. It is also the half of the epic that looked like it would force a
native change, since native is what stores and dispatches those handles.

## Decision

It does not force one. `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}`. The tag can therefore
be a nested tuple carrying more than a screen's label.

`Mob.Renderer` now registers

{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**, which was the epic's
constraint.

### Two shapes, not one

The first cut of this change unwrapped a single shape, `{event, tag}`, on the
assumption that every handle-addressed native event looked alike. It does not,
and the cost of being wrong is invisible: an unmatched envelope reaches the
screen as nothing at all, so the control simply stops working with no crash and
no log.

Native has two families, both reading a tap handle:

* `{event, tag}` — `mob_send_tap`, `mob_send_event`, `mob_send_scrolled_past`:
`:tap`, `:focus`, `:blur`, `:submit`, `:dismiss`, `:select`.
* `{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`: every text
field, toggle and slider `on_change`, tab selection (which is wired to
`mob_send_change_str`), and every gesture stream.

Both are unwrapped on the event atom, so a new event *kind* needs no change
here — but a new *arity* would, which is why anything else carrying a
`{:mob_route, _, _}` is now logged at error rather than discarded. No sender
uses a 4-tuple: every `enif_make_tuple4` in `ios/mob_nif.m` is a NIF return
value, not a message.

### The envelope carries a pid, not a screen ref

The epic sketched `{listener_pid, {screen_ref, tag}}` with the listener
resolving the ref. Carrying the pid needs no registry and no resolution step,
and it produces the behaviour the epic actually asked for: a handle registered
by a screen that has since been stopped delivers to a dead pid, which the BEAM
drops. That is precisely the MOB-107 fix — the event is dropped rather than
delivered into whatever screen happens to be current with that screen's socket.

A ref becomes worth its cost when a screen can be *restarted* and keep its
identity across a new pid, which is MOB-112. The change is confined to
`handler/1` and `handle_info/2`.

### `:mob_screen` is untouched

The other thing native knows is `enif_whereis_pid("mob_screen")` — back gesture,
alert actions, launch-notification fallback, both platforms. That name still
belongs to the screen process. MOB-113's router takes it over; this step
deliberately leaves it alone so the two changes stay separable.

### No listener means no envelope

`handler/1` returns its target unchanged when no listener is running, so events
go straight to the screen exactly as before. That keeps every boot path working
whether or not it starts a listener, and it is why the renderer's existing tests
needed no changes.

## Consequences

- The ~35 call sites now go through one `register_handler/2`. That indirection,
not the listener itself, is what makes MOB-112 and MOB-113 tractable: the
inbound path stops naming a screen process in 35 places.
- **The hop buys nothing yet.** With one screen process, carrying its pid
through the listener and forwarding is pure overhead. It is worth stating
plainly rather than implying otherwise — the value is entirely in where the
next two steps get to make their change.
- **The escape hatch is "do not call `handler/1`."** A high-frequency stream
(drag, scroll, `mob_touch` at display rate) pays one hop and one copy per
event; registering `{screen_pid, tag}` directly bypasses the listener and
still works, because that is what the renderer did before. Nothing bypasses it
today: the hop has not been measured, and carving out an exception before
there is a number would be guessing.
- `Mob.Event.Bridge` is unaffected. It translates the `{:tap, tag}` a screen
receives, and the listener unwraps before the screen sees anything.
- The listener is started by `Mob.App.start/0` and, for boot paths that skip it,
by `Mob.Screen.init/1` — unlinked, for the same reason as `Mob.Sender`: the
caller is a screen, and a screen crash must not take down the process every
screen's events arrive through.
- **The native double in the tests has to model both families.** The version
that modelled only `{event, tag}` produced a passing test asserting that
`on_change` worked while it was in fact being dropped — worse than no test.
`FakeNative.fire/3` now replays the 3-tuple senders, and every one of the
eight has a round-trip test.
- Like the sender, it has no supervisor. Its death is less severe — `handler/1`
falls back to direct registration on the *next* render — but handles already
baked with the dead listener's pid go nowhere until then.
8 changes: 8 additions & 0 deletions lib/mob/app.ex
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ defmodule Mob.App do
{:error, {:already_started, _}} -> :ok
end

# The single inbound entry point from native. Must also be up before the
# first render, because that render is what bakes the listener's pid
# into the native tap handles.
case Mob.Listener.start_link() do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
end

# Mob.Device dispatcher + platform fan-out modules. Order matters:
# the IOS / Android modules must exist before Mob.Device starts,
# because Mob.Device forwards platform-tagged messages to them.
Expand Down
163 changes: 163 additions & 0 deletions lib/mob/listener.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
defmodule Mob.Listener do
@moduledoc """
The single process the native layer delivers interaction events to.

Native knows two things and neither of them is a screen: the registered name
`:mob_screen` (used by `enif_whereis_pid` for the back gesture, alert actions
and the launch-notification fallback, on both platforms) and whatever pid was
stored in a tap handle by `register_tap/1`. This module takes over the second.

## The envelope

`nif_register_tap` stores an arbitrary term as the handle's tag and echoes it
back verbatim — `mob_send_tap` sends `{:tap, tag}`, `mob_send_event` sends
`{event, tag}`. The tag is copied with `enif_make_copy`, so it can be any
shape, including a nested tuple.

So instead of registering `{screen_pid, tag}`, `Mob.Renderer` registers

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

Native then delivers `{:tap, {:mob_route, screen_pid, tag}}` here, and the
listener forwards `{:tap, tag}` to the screen. Native remains ignorant that
screens exist, and **no `.m`, `.zig` or generator-template change is
required** to move the inbound path off a single hard-wired screen process.

Native has **two** message shapes for handle-addressed events, and the
listener has to unwrap both:

* `{event, tag}` — `mob_send_tap`, `mob_send_event`, `mob_send_scrolled_past`.
Covers `:tap`, `:focus`, `:blur`, `:submit`, `:dismiss`, `:select` and the
other payload-free events.
* `{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`. This is
everything carrying a value: text-field and toggle and slider `on_change`,
tab selection, and every gesture stream.

Both are unwrapped on the event atom rather than one clause per event, so a
new event kind needs no change here — but a new *arity* would. Anything else
is logged rather than silently discarded, because an unmodelled shape is
invisible otherwise: the widget simply stops working.

## Why a hop at all

Today there is one screen process, so carrying its pid through the envelope
and forwarding is, on its own, a hop that buys nothing. What it buys is that
the ~35 `register_tap` call sites in `Mob.Renderer` stop naming a screen
process directly. When MOB-112 makes screens processes and MOB-113 adds the
router, the change is confined to `handler/1` and `handle_info/2` here rather
than spread across every interactive prop in the renderer.

## The escape hatch

A high-frequency stream — drag, scroll, `mob_touch` at display rate — pays one
extra hop and one extra copy per event. Registering the screen pid directly
bypasses this module entirely and still works, because that is exactly what
the renderer did before:

nif.register_tap({screen_pid, tag}) # direct, no listener

Nothing bypasses it today. The hop has not been measured, and adding an
exception before there is a number to point at would be guessing.
"""

use GenServer

require Logger

@doc "Start the listener. Named, so there is exactly one."
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

@doc "Whether the listener is running."
@spec running?() :: boolean()
def running?, do: is_pid(Process.whereis(__MODULE__))

@doc """
Start the listener if it is not already running.

Unlinked, for the same reason `Mob.Sender.ensure_started/0` is: the caller is
usually a screen, and a screen crash must not take down the process every
screen's events arrive through.
"""
@spec ensure_started() :: :ok
def ensure_started do
if running?() do
:ok
else
case GenServer.start(__MODULE__, [], name: __MODULE__) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
end
end
end

@doc """
Wrap a `register_tap/1` target so native delivers the event here instead of
straight to the screen.

Accepts either shape the renderer uses — a bare pid, or `{pid, tag}` — and
returns the term to hand to `register_tap/1`.

Returns the target **unchanged** when the listener is not running, so events
go directly to the screen exactly as they did before this module existed.
That is the fallback for any boot path that does not start a listener, and it
is what keeps the renderer's own tests working without one.
"""
@spec handler(pid() | {pid(), term()}) :: pid() | {pid(), term()}
def handler(target) do
case {Process.whereis(__MODULE__), envelope(target)} do
{nil, _} -> target
{_listener, ^target} -> target
{listener, envelope} -> {listener, envelope}
end
end

# A bare pid registers with no tag; native substitutes the atom :ok and the
# screen receives {:tap, :ok}. Preserved exactly.
defp envelope(pid) when is_pid(pid), do: {:mob_route, pid, :ok}
defp envelope({pid, tag}) when is_pid(pid), do: {:mob_route, pid, tag}
# Anything else passes through untouched, matching what the no-listener
# branch does. The two branches disagreeing would mean a shape that works
# without a listener and raises mid-render with one.
defp envelope(other), do: other

# ── GenServer ─────────────────────────────────────────────────────────────

@impl GenServer
def init(_opts), do: {:ok, %{}}

@impl GenServer
def handle_info({event, {:mob_route, pid, tag}}, state) when is_atom(event) do
# Sending to a dead pid is a no-op in the BEAM, which is the behaviour we
# want: a handle registered by a screen that has since been popped and
# stopped drops its event rather than delivering it to whatever screen
# happens to be current. That is the misrouting MOB-107 reported.
send(pid, {event, tag})
{:noreply, state}
end

def handle_info({event, {:mob_route, pid, tag}, payload}, state) when is_atom(event) do
send(pid, {event, tag, payload})
{:noreply, state}
end

def handle_info(message, state) do
# An envelope shape we do not model reaches the screen as nothing at all —
# the control just stops responding, with no crash and no log. Say so.
if routed?(message) do
Logger.error("[mob] Mob.Listener received an unhandled routed event: #{inspect(message)}")
end

{:noreply, state}
end

defp routed?(message) when is_tuple(message) do
message
|> Tuple.to_list()
|> Enum.any?(&match?({:mob_route, _pid, _tag}, &1))
end

defp routed?(_message), do: false
end
Loading
Loading