Skip to content

MOB-110: sender process — serialise render and the tap-table commit - #96

Merged
GenericJam merged 2 commits into
masterfrom
feat/mob-110-sender
Aug 28, 2026
Merged

MOB-110: sender process — serialise render and the tap-table commit#96
GenericJam merged 2 commits into
masterfrom
feat/mob-110-sender

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Second step of epic MOB-108. Follows #95 (MOB-109).

Problem

Mob.Screen.do_render/4 called Mob.Renderer.render/4 inline, 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:

staticTapHandletap_tables[2][MAX_TAP_HANDLES];
staticinttap_active=0;
staticinttap_build_count=0; // cursor into the BUILDING table

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_root first commits a table holding both screens' handles while the other screen's tree is never committed at all.

Change

Mob.Sender is 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 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 .m or .zig change — 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/1 relied on the render's self-sent :flush being queued ahead of a later sync call. A self-send appends to the back of the mailbox — behind a sync the caller already queued — so sync/1 returned before the frame was committed. Five tests failed on it. sync/1 now performs the flush itself, which also strengthens coalescing.

Review

An adversarial pass found six more, all fixed in the second commit:

  • set_active/1 on 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.
  • A documented boot path rendered nothing silentlyliveview_notes.md skips Mob.App, and casts to a missing sender are :ok. Mob.Screen.init/1 now ensures the sender, started unlinked.
  • Coalescing swallowed nav transitions — a :push superseded by a :none re-render lost its animation.
  • sync/1's 5s default would newly kill the screen on a slow frame; Mob.Screen passes :infinity.
  • Mob.Test's documented sync point broke for the native-reading helpers. Added Mob.Test.settle/2.
  • The moduledoc still taught the rejected buggy design.

Two findings are carried into MOB-112 and recorded in the ADR: ComponentRegistry.reconcile/2 runs screen-side and destructively before the commit, and the sender has no supervisor.

Verification

  • 20 new tests, including the screen↔sender wiring the review found untested
  • Suite 1181 passed, format / credo --strict / --warnings-as-errors clean
  • Device-verified both platforms, 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

Scope 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.

GenericJamand others added 2 commits August 28, 2026 13:51
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>
@GenericJam
GenericJam merged commit e16721a 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