MOB-110: sender process — serialise render and the tap-table commit - #96
Merged
Conversation
Rendering happened inline in whichever process handled the message. That is safe only because there is exactly one screen process; MOB-112 makes screens processes and two of them can then render at once. The native contract does not tolerate that. clear_taps prepares the inactive tap table and resets tap_build_count, register_tap appends at that cursor, and set_root swaps atomically. The double buffering exists so a concurrent *reader* — a drag arriving mid-render — resolves against the last committed table; it does nothing for concurrent *writers*, because there is one global build cursor. Two renders in flight interleave into the same building table and one screen's tree is never committed at all. Mob.Sender is now the only caller of the render NIFs. Screens still build their own tree — Composite/List/Component expansion all take self() — and hand the finished tree over. Mob.Renderer uses self() nowhere outside doc examples, so the sender can own the whole render/4 call. Queueing rather than executing in the caller buys coalescing: for one screen only the newest tree is committed, and a tree for a screen that is not active is dropped. That second part is what will let an inactive tab hold state without rendering. The first cut of sync/1 was wrong and the tests caught it. It relied on the render's self-sent :flush being queued ahead of a later sync call, but a self-send appends to the BACK of the mailbox — behind a sync the caller had already queued — so sync returned before the frame was committed. sync/1 now performs the flush itself, which also strengthens coalescing: a burst of renders followed by one sync produces a single commit. The barrier is on the call paths only. Mob.Test documents tap/2 and back/1 as fire-and-forget and its navigation helpers as synchronous, so Mob.Screen syncs in the handle_call paths and leaves handle_info asynchronous — which is what leaves anything to coalesce. commit/1 rescues: every screen renders through this process, so dying on a malformed tree would freeze the whole UI rather than one screen. No .m or .zig change — this is entirely about who calls the existing NIFs. Rationale in decisions/2026-08-28-sender-serialises-render.md; the never-call-render-NIFs-directly invariant added to AGENTS.md's pre-empt-failure rules, since it is easy to break by accident. Tests: 10 new. Suite 1171 passed, format and credo --strict clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oth platforms 1. set_active/1 was announced on every render. Reads harmless, is not: every screen process runs the same do_render/4, so at MOB-112 a background screen whose timer fires would promote itself and commit over the foreground screen — disarming the exact mechanism this step exists to build. It also guaranteed a fight with MOB-113's router, both writing `active` with no ownership rule. Now called only where navigation establishes the active stack: init/1 and apply_switch_tab/4, which are the two call sites the router takes over unchanged. 2. A boot path documented in this repo silently rendered nothing. liveview_notes.md prescribes skipping Mob.App entirely, and renders are casts — a cast to an unregistered name is :ok — so a missing sender gave a blank screen with no log until the first synchronous render exited :noproc. Mob.Screen.init/1 now calls Mob.Sender.ensure_started/0, started unlinked so a screen crash cannot take down the process every other screen renders through. 3. Coalescing silently swallowed navigation transitions. A :push rendered from handle_info (forward_to_screen, back gesture, list select) superseded by an ordinary :none re-render from a timer tick lost its animation: correct content, no transition, intermittently. A pending non-:none transition now survives being superseded; a newer explicit one still wins. 4. sync/1's default 5s timeout would newly kill the screen on a slow frame — rendering was unbounded when it ran inline. Mob.Screen passes :infinity. 5. Mob.Test's documented sync point was broken. :sys.get_state(:mob_screen) no longer implies the frame is on screen, which matters for the functions reading the native side (view_tree, screenshot, tap_id, element_frames). Added Mob.Test.settle/2, which drains both the screen and the sender, and corrected the docs. 6. The sender's moduledoc still taught the mailbox-ordering design the ADR and the previous commit message both identify as the bug — the worst kind of stale doc, since AGENTS.md points readers there as the authority. Also adds the screen-to-sender wiring test the review correctly identified as missing: nothing asserted who declares the active screen or when. One assertion fails if set_active/1 ever moves back into do_render/4. Two findings are carried into MOB-112 rather than fixed, recorded in the ADR: ComponentRegistry.reconcile/2 still runs screen-side and destructively before the commit, so a dropped tree leaves the displayed frame holding handles to dead pids (narrow now, routine once background renders are supposed to be dropped); and the sender has no supervisor, consistent with how Mob.App starts every other service but uniquely bad when it dies. Device-verified, since this changes when the first frame is committed. sheetprobe cold-started on the iOS simulator (BEAM log to `step 5 => ok`) and the Android emulator, both rendering the first frame through the sender, and a tap presented a sheet on each — exercising the sync path and a real transition. The first iOS attempt died with eaddrinuse, which was the documented adb/iOS-sim port collision, not this change. Tests: 10 more (16 sender, 4 wiring). Suite 1181 passed, format and credo --strict clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 28, 2026
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.
Second step of epic MOB-108. Follows #95 (MOB-109).
Problem
Mob.Screen.do_render/4calledMob.Renderer.render/4inline, so rendering happened in whichever process handled the message. That is safe today only because there is exactly one screen process — MOB-112 makes screens processes, and then two can render at once.The native contract does not tolerate that.
ios/mob_nif.m:The double buffering protects a concurrent reader — a drag arriving mid-render resolves against the last committed table. It does nothing for concurrent writers: one global build cursor means two renders in flight interleave their handles into the same building table, and whichever reaches
set_rootfirst commits a table holding both screens' handles while the other screen's tree is never committed at all.Change
Mob.Senderis the only caller of the render NIFs. Screens still build their own tree —Composite/List/Componentexpansion all takeself()— and hand the finished tree over.Mob.Rendererusesself()nowhere outside doc examples, so the sender can own the whole call.Queueing rather than executing in the caller buys coalescing: only the newest tree per screen is committed, and a tree for a non-active screen is dropped. That second half is what will let an inactive tab hold state without rendering.
No
.mor.zigchange — this is entirely about who calls the existing NIFs.Rationale in
decisions/2026-08-28-sender-serialises-render.md. The invariant is now in AGENTS.md's pre-empt-failure rules, since it is easy to break by accident and the failure mode (one screen's tree silently never committing) is hard to diagnose.A bug the tests caught
The first
sync/1relied on the render's self-sent:flushbeing queued ahead of a latersynccall. A self-send appends to the back of the mailbox — behind asyncthe caller already queued — sosync/1returned before the frame was committed. Five tests failed on it.sync/1now performs the flush itself, which also strengthens coalescing.Review
An adversarial pass found six more, all fixed in the second commit:
set_active/1on every render let any screen promote itself. At MOB-112 a background screen's timer would commit over the foreground one, disarming the mechanism this step exists to build. Now called only where navigation establishes the active stack.liveview_notes.mdskipsMob.App, and casts to a missing sender are:ok.Mob.Screen.init/1now ensures the sender, started unlinked.:pushsuperseded by a:nonere-render lost its animation.sync/1's 5s default would newly kill the screen on a slow frame;Mob.Screenpasses:infinity.Mob.Test's documented sync point broke for the native-reading helpers. AddedMob.Test.settle/2.Two findings are carried into MOB-112 and recorded in the ADR:
ComponentRegistry.reconcile/2runs screen-side and destructively before the commit, and the sender has no supervisor.Verification
credo --strict/--warnings-as-errorscleanstep 5 => ok) and the Android emulator, both rendering the first frame through the sender, and a tap presented a sheet on eachScope note
Coalescing is not observable in production yet — with one screen process the synchronous call paths flush every render. It becomes load-bearing at MOB-112, which is also when the serialisation stops being redundant.