MOB-112: one process per screen, owned and linked - #99
Merged
Conversation
A single Mob.Screen process held {module, socket, nav, render_mode} and
swapped the first two in place on navigation. Every screen shared one
mailbox, so a crash in any handle_event took down navigation and every other
screen with it. The moduledoc claimed the opposite for a long time; mob#76
corrected the docs, which documented the gap rather than closing it.
Mob.Screen.Server is now one process per live screen, owning that screen's
socket. Mob.Screen becomes the owner: it holds the navigation state, starts
and stops screens, monitors them, and keeps the :mob_screen registered name
so the native layer's enif_whereis_pid lookups are unaffected. Its state is
the same shape it was, with the socket replaced by the pid of the process
that now owns it — Mob.Nav needed no change, because it always treated
entries as opaque.
Screens are started unlinked and monitored, not linked. The first cut used
start_link and the tests caught it immediately: the owner stops a popped
screen with GenServer.stop(:shutdown), the link propagated that exit to the
owner, and the owner died — taking navigation and every sibling with it.
Exactly the coupling this step removes, reintroduced by the mechanism meant
to manage it.
Not a DynamicSupervisor: a supervisor restarting a screen produces a process
the owner knows nothing about, in a slot the supervisor cannot know. The
crashed screen might be current, in the active stack's history, or parked
under an inactive tab, and restoring it means putting the new pid back
exactly where the old one was. The owner is the only thing that knows that.
Every owner-to-screen call goes through safe_call/1. dispatch/3 is a call on
the owner which calls the screen, so a crash in handle_event would have come
back up that call and killed the owner — defeating the isolation on the one
path the acceptance criteria name explicitly.
A restart re-mounts and loses assigns, logged at error because it is visible
to the user. Popping stops the screen leaving the stack; the ones below stay
resident, which is what makes pop restore prior state without re-mounting.
Demonitoring before stopping matters: otherwise the shutdown the owner asked
for returns as a :DOWN and the screen is "restarted" right after being
deliberately discarded.
A callback that sets a nav action hands it to the owner and does NOT paint —
painting there would flash the outgoing tree for a frame before the
navigation replaced it, the same overlap that produced MOB-103. Only the
active screen may drive navigation, so a background timer cannot yank the
stack out from under the user.
self() inside a screen is now the screen's own pid, which is what user code
always assumed when writing on_tap: {self(), :save} or starting a task.
Public API unchanged: dispatch/3, get_socket/1, get_current_module/1 and
get_nav_history/1 keep their shapes. Adds get_screen_pid/1, since tooling now
needs a way to reach the process actually holding the screen.
Rationale in decisions/2026-08-28-screen-processes-and-supervision.md.
Device-verified both platforms: cold boot, first frame, tap, a navigation
push (the pushed screen renders its own pid — #PID<0.148.0> on iOS, distinct
from the owner), pop, and on_change through the new per-screen process. Zero
crashes in logcat.
Tests: 13 new covering crash isolation end to end. Suite 1214 passed, format
and credo --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>The two that defeated the step's own acceptance criteria:
- handle_call(:inspect) pattern-matched {:ok, socket} from safe_call/1, so the
one case safe_call exists for raised a MatchError inside the owner and
killed it. Reached by Mob.Test.inspect/1 and tree/1 against a screen that is
mid-crash or merely busy — an agent debugging a wedged screen destroyed the
app it was debugging.
- paint_sync was a raw GenServer.call, so a crash in the user's render/1 exited
the owner. That sits on the everyday "tap a button that pushes a screen"
path, more common than the handle_event crash the tests did cover.
Both are now wrapped. The ADR claimed "every owner-to-screen call goes through
safe_call/1"; two did not.
Linking took two wrong turns, each fix causing the next problem, so the
reasoning is recorded rather than just the result. Linking alone kills the
owner when it stops a popped screen. Unlinking fixes that and orphans every
screen when the owner dies — and an orphaned persisted screen keeps its 30s
timer, dumping to Mob.ScreenState under the same key as its live replacement.
The answer is both: linked, with owner AND screens trapping exits. The owner's
terminate/2 no longer stops screens, because calling GenServer.stop/3 there
lets the screen's :shutdown travel back up the link mid-terminate and replace
the owner's own exit reason.
Navigation entries carry params and ref, not just {module, pid}:
- a screen mounting on %{id: id} cannot come back from %{}; the re-mount raised,
the restart failed, and the owner kept a dead pid as `current` — every later
event called a corpse and returned :ok, freezing the app with no log after
the first line
- a parked screen restarted with the *active* ref commits over the foreground
tab on its next repaint, undoing MOB-110's drop-inactive mechanism
A failed re-mount no longer leaves a corpse: background screens are dropped
from their stack, a current screen pops to what is beneath it, and with nothing
beneath it says so.
Also: every no-op nav branch repaints (the screen deliberately does not paint
when it produced an action, so re-tapping the active tab updated assigns that
never reached the screen); switch_tab mounts before mutating nav and the
sender's active ref, so a failed mount cannot leave the sender addressing a
stack whose screen never started; owner-to-screen calls pass :infinity, since a
5s bound silently discarded the navigation the user asked for rather than
failing; deliberate stops are bounded so one wedged screen cannot block
teardown; and the double-monitor bookkeeping is gone with the monitors.
Mob.Test was in this change's blast radius even though it was not in the diff.
settle/2 drains three processes now — :mob_screen is the navigation owner, which
forwards to the screen, which the sender commits — and assigns/1 tolerates the
nil socket a mid-restart screen returns.
Corrected the moduledoc, which mob#76 had already had to fix once and which
this change made wrong in a new way, and a comment that said screens are linked
back when they were not.
Tests: 9 new covering background-screen restarts, params and ref preservation,
and a two-stack app — the gap that let both defects ship. Verified as negative
controls: reinstating either defect fails exactly the test written for it. The
three racy `if Process.alive?, do: GenServer.stop` teardowns are exit-safe.
Suite 1223 passed, format and credo --strict clean. Re-verified on the iOS
simulator with the final process model: cold boot, push (own pid
#PID<0.142.0>), pop, zero errors in the BEAM log.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>The blocking find from review #2 was a regression this change introduced, not a pre-existing gap. Making every screen a live process means the ones *below* the top of the stack now receive messages and repaint — but Mob.Sender was still addressed per navigation stack, and every screen in a stack shared one ref. So a Process.send_after tick, a Task reply, or a PubSub broadcast landing in a screen the user cannot see committed its tree — tap table included — over the screen they can. MOB-107's whole point is that those messages now arrive on the screen's own pid, so this is the ordinary case, not an exotic one. No test could see it: every test runs :no_render, where paint/3 short-circuits. The render ref is now unique per SCREEN, minted at start and preserved across restarts, and Mob.Sender.set_active/1 is called from exactly one place — the new make_current/2, the single point where `current` changes. The sender only ever commits the active ref, so a background repaint is dropped wherever that screen sits. This also subsumes the parked-screen-ref problem review #1 found, and fixes the same bug in the __mob_hot_reload__ broadcast, which repainted every live screen and let the last to arrive win. Mob.Sender's own @type doc anticipated this ("MOB-112 replaces it with a per-screen reference"); the first cut did not. Other confirmed findings: - drop_entry/2 called Mob.Nav.map_parked(& &1) — the identity function — so it dropped nothing from parked. A screen parked under an inactive tab whose re-mount failed stayed in nav as a dead pid, and switching to that tab restored a corpse, freezing it permanently. Mapping cannot express this: dropping a stack's *current* has to collapse the stack. Added Mob.Nav.drop_parked/2, which promotes the history head, or removes the stack entirely so the next switch mounts its root fresh. - Restarts had no ceiling. A screen that mounts cleanly and crashes on every render looped at ~6500 restarts/sec, one log line each. The owner restarts screens itself because it is the only thing that knows where one sat, so it now carries the max-restart-intensity a supervisor would have given: 5 in 10s per screen ref, then it gives up and falls back to the screen beneath. - handle_call(:inspect) still ran the user's render/1 in the OWNER. Review #1 fixed the MatchError above it and left this. A raise there — reached by Mob.Test.tree/1, the debugging path — killed navigation and every screen. The tree is now built in the screen's own process. - A stop that timed out left the screen alive, unlinked and untracked, still dumping to Mob.ScreenState under its replacement's key — verbatim the orphan hazard the ADR uses to justify linking. It is killed outright now. - Mob.Test.settle/2 drained the screen that was on its way OUT: a navigating tap moves owner -> old screen -> owner -> new screen, so settle returned before the incoming screen had rendered and tap -> settle -> screenshot read the stale frame. It drains twice now. - Screens trapping exits silently swallowed linked-task crashes into the user's default handle_info. They are logged before being forwarded. - The ADR claimed the cost of linking was orphaning on Process.exit(owner, :kill). That is wrong — :killed is trappable, so screens still run terminate/2. Replaced with the two real costs. Tests: 7 more (drop_parked, the restart ceiling, owner-safe inspection, and a background screen's ref). Verified as negative controls — reverting to a stack-wide ref fails exactly the test written for it. The clobber fix is proven in two parts that hold without :render mode: the sender drops non-active refs, and background screens have non-active refs. Suite 1231 passed, format and credo --strict clean, 10/10 clean runs across the touched test files. Re-verified on the iOS simulator: cold boot, push (own pid #PID<0.146.0>), pop, zero errors in the BEAM log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review #3 cleared the model review #2's fixes introduced — I could not make Mob.Sender's active ref disagree with state.current.ref down any path or race, including a screen that navigates then crashes, and a push processed before a crashed screen's EXIT. Two blockers remained, both small, both in code that predates the ref work. 1. A bad navigation destination raised inside the OWNER. push_screen/2 takes any atom and resolve_destination/1 raises on an unregistered one, so a typo killed navigation and every screen — falsifying the isolation this module's moduledoc and the ADR both assert, in the same commit that asserts it. It is caught now: logged, navigation untouched, current screen repaints. Same guard on decode_notification_json/1, which runs in the owner because a launch notification comes from native rather than a screen, and :json.decode/1 raises on malformed input. The existing test asserted the crash. It now asserts the app survives. 2. Giving up on a screen bricked a tab-bar app. recover_from_failed_restart/2's last branch fired when the current screen could not come back and its own stack had nothing beneath it — which in a tab-bar app is the ORDINARY shape, since every tab root has an empty history. The owner kept a dead pid as current, every later event was safe_call'd into a corpse and replied :ok, and the sender still pointed at the dead ref, all while a live screen sat parked under another tab. It now switches to a parked stack and drops the dead entry so nothing can restore a corpse later. Also from review #3: state.restarts grew for the owner's lifetime (nothing removed a key), pruned in stop_screen/2; and the types and prose describing the model this change replaced — Mob.Nav's `entry :: {module, socket}`, Mob.Screen.Server's `render_ref :: atom()` still calling itself a stack, and Mob.Sender's "MOB-112 replaces it with a per-screen reference" in the commit that is MOB-112. Mob.Nav.active_ref/1 lost its last caller when the render ref became per-screen and is removed rather than left as dead public API. Two findings are deferred to MOB-121 with the reasoning written down: the owner is now a shared serialisation point (:infinity calls mean one wedged screen blocks every other), and stopping a wedged screen costs 5s that pop_to_root and reset pay serially. Neither is a regression — before MOB-112 there was one process to block — but both change shape with N screens. Tests: 2 more, both verified as negative controls. Suite 1233 passed, format and credo --strict clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
My own test was flaky, and review #3 predicted exactly this: kill_and_settle/2 used :sys.get_state/1 as a barrier, which is not ordered against Process.exit(pid, :kill). A later kill could land on an already-dead pid and be a no-op, so the loop performed fewer restarts than intended and the ceiling sometimes did not trip. Caught it on run 4 of 8. Each kill now waits for the :DOWN before draining the owner, so every iteration lands on a live process. 12/12 clean runs of the restart file, 10/10 clean full-suite runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
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>
This was referenced Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourth step of epic MOB-108. Follows #95 (MOB-109), #96 (MOB-110), #97 (MOB-111).
Problem
One
Mob.Screenprocess held{module, socket, nav, render_mode}and swapped the first two in place on navigation. Every screen shared one mailbox, so a crash in anyhandle_eventtook down navigation and every other screen with it. The moduledoc claimed the opposite for a long time; mob#76 corrected the docs, which documented the gap rather than closing it.Change
Mob.Screen.Serveris one process per live screen, owning that screen's socket.Mob.Screenbecomes the owner: it holds navigation, starts/stops/restarts screens, and keeps the:mob_screenregistered name so native'senif_whereis_pidlookups are unaffected.Mob.Navneeded no change — it always treated entries as opaque.self()inside a screen is now the screen's own pid, which is what user code always assumed when writingon_tap: {self(), :save}or starting a task — and what stops screen A's task result landing in screen B (MOB-107).Public API is unchanged. Adds
get_screen_pid/1.Rationale in
decisions/2026-08-28-screen-processes-and-supervision.md.Three review passes, and what each found
This went through three adversarial reviews. Recording the sequence because each round's fix created the next round's bug, which is the useful part.
Review #1 (14 findings). Two unprotected owner→screen calls meant a crash in
handle_eventstill killed the owner — the exact acceptance criterion. Restarts discarded mount params, so a screen mounting on%{id: id}could never come back and the owner kept a dead pid ascurrentforever.Review #2 (1 critical). My fix for #1 introduced it: making every screen a live process means the ones below the top of the stack repaint, but
Mob.Senderwas still addressed per navigation stack and every screen in a stack shared one ref. AProcess.send_aftertick in a screen the user cannot see committed its tree — tap table included — over the screen they can. MOB-107's whole point is that those messages now arrive on the screen's own pid, so this is the ordinary case. No test could see it: every test runs:no_render, where painting short-circuits.The render ref is now unique per screen, and
Mob.Sender.set_active/1is called from exactly one place —make_current/2, the single point wherecurrentchanges.Review #3 (2 blockers). It cleared the new ref model — attacked every path and race and could not make the sender's
activedisagree withstate.current.ref. The blockers were older code:resolve_destination/1'sraiseruns in the owner, sopush_screen(socket, :typo)killed navigation and every screen; and the give-up branch bricked a tab-bar app, because every tab root has an empty history so "nothing beneath it" is the ordinary shape — the owner kept a dead pid ascurrentwhile a live screen sat parked under another tab.Notable decisions
Linked, with both sides trapping. Two wrong turns worth recording. Linking alone kills the owner when it stops a popped screen. Unlinking fixes that and orphans every screen when the owner dies — and an orphaned persisted screen keeps its 30s timer, dumping to
ScreenStateunder the same key as its replacement. The answer is both.Not a
DynamicSupervisor. A supervisor restarting a screen produces a process the owner knows nothing about, in a slot the supervisor cannot know — the crashed screen might be current, in history, or parked. The owner is the only thing that knows, so it restarts, and carries its own max-restart-intensity (5 in 10s per screen ref) since it gave up the one a supervisor provides.Verification
mix format,credo --strict,--warnings-as-errorscleanon_change. Zero errors in the BEAM logDeferred, with reasoning written down
MOB-121 — the owner is now a shared serialisation point (
:infinitycalls mean one wedged screen blocks every other), and stopping a wedged screen costs 5s thatpop_to_root/resetpay serially. Neither is a regression — before this there was one process to block — but both change shape with N screens. MOB-113 is the natural place, since keeping the router off the hot path is its premise.MOB-116 is resolved by this change: every screen now schedules its own state sync and dumps in its own
terminate/2, so parked and history screens finally get both.