MOB-114: pin the two behaviours that arrived for free - #102
Merged
Conversation
Scoped as three pieces of migration work. Two were already done: each live
screen has scheduled its own state sync and dumped in its own terminate/2 since
MOB-112, and hot reload has been a broadcast over every live screen since the
same change. They fell out of making screens processes rather than needing
separate work.
Neither had a test. That is the real gap — behaviour that arrived as a side
effect of another change, asserted nowhere. Reviews of MOB-112 flagged
hot_reload as having zero coverage, and nothing had ever round-tripped state
across a multi-stack app, which is this issue's acceptance criterion.
The third piece was attempted and REVERTED, which is the more useful result.
Mob.Test's tap/select/send_message were changed to resolve the screen pid and
send directly, on the reasoning that a native tap reaches a screen via
Mob.Listener without touching the router. Review showed that was wrong three
ways:
- It made the thing it was avoiding worse. get_screen_pid/1 is a GenServer.call
INTO the router — the serialisation point the change cited as motivation —
replacing one async send with a synchronous round-trip plus a second send.
Measured at 5002ms for one send_message while the router was blocked, against
a documented fire-and-forget contract.
- Resolve-then-send is not atomic. The router reads state.current.pid and
delivers in one step; resolving separately opens a full RPC round trip in
which the screen can restart and the event be delivered to a corpse.
- The premise was factually wrong. Alert actions go to :mob_screen on both
platforms and webview messages on iOS unconditionally, and both are documented
send_message/2 payloads — so router-handled shapes stopped working entirely.
send_message({:mob, :back}) no longer popped.
Addressing :mob_screen is correct, not just the status quo: the router resolves
and delivers atomically in one non-blocking RPC, and it is where native itself
sends the messages this function simulates. lib/ is unchanged by this step.
Three barriers looked like barriers and were not, each producing a passing test
that proved nothing:
- Mob.Sender.sync/1 does not order a hot reload — a background screen's tree is
dropped rather than committed. Nor does :sys.get_state(router), since
hot_reload/1 is a cast per screen. The screens are drained, and because
render/1 is user code the assertion is a bounded wait rather than a snapshot
comparison: with a 30ms sleep in render/1 the snapshot version failed every
run and the bounded one passes.
- Screens dump in their own terminate/2 after the router exits, so
GenServer.stop(router) is not enough; the state tests monitor the screens and
wait for their exits, or the dump races the Repo teardown and logs a DB error
out of a passing test (reproduced 10/15 without the fix).
- all_entries/1 has three branches — current, the ACTIVE stack's history, and
parked — and the first scenario ended on a tab switch, leaving the active
history empty. Deleting that branch outright passed the whole suite. The
scenario now switches back, so all three are populated, and dropping either
the history or the parked branch fails the test.
Ordering: audited. On any message path the only multi-screen iteration is the
hot-reload broadcast; the stop_screen reductions in pop_to_root, pop_to and
reset are order-independent.
Rationale in decisions/2026-08-29-migration-off-the-single-screen-process.md.
Tests: 3 new. Suite 1243 passed, 20/20 clean runs, format and credo --strict
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GenericJam
force-pushed
the
feat/mob-114-migration
branch
from
August 29, 2026 11:41
f627eae to
aa1d109
Compare
pshoukry
pushed a commit
to pshoukry/mob
that referenced
this pull request
Sep 9, 2026
Scoped as three pieces of migration work. Two were already done: each live
screen has scheduled its own state sync and dumped in its own terminate/2 since
MOB-112, and hot reload has been a broadcast over every live screen since the
same change. They fell out of making screens processes rather than needing
separate work.
Neither had a test. That is the real gap — behaviour that arrived as a side
effect of another change, asserted nowhere. Reviews of MOB-112 flagged
hot_reload as having zero coverage, and nothing had ever round-tripped state
across a multi-stack app, which is this issue's acceptance criterion.
The third piece was attempted and REVERTED, which is the more useful result.
Mob.Test's tap/select/send_message were changed to resolve the screen pid and
send directly, on the reasoning that a native tap reaches a screen via
Mob.Listener without touching the router. Review showed that was wrong three
ways:
- It made the thing it was avoiding worse. get_screen_pid/1 is a GenServer.call
INTO the router — the serialisation point the change cited as motivation —
replacing one async send with a synchronous round-trip plus a second send.
Measured at 5002ms for one send_message while the router was blocked, against
a documented fire-and-forget contract.
- Resolve-then-send is not atomic. The router reads state.current.pid and
delivers in one step; resolving separately opens a full RPC round trip in
which the screen can restart and the event be delivered to a corpse.
- The premise was factually wrong. Alert actions go to :mob_screen on both
platforms and webview messages on iOS unconditionally, and both are documented
send_message/2 payloads — so router-handled shapes stopped working entirely.
send_message({:mob, :back}) no longer popped.
Addressing :mob_screen is correct, not just the status quo: the router resolves
and delivers atomically in one non-blocking RPC, and it is where native itself
sends the messages this function simulates. lib/ is unchanged by this step.
Three barriers looked like barriers and were not, each producing a passing test
that proved nothing:
- Mob.Sender.sync/1 does not order a hot reload — a background screen's tree is
dropped rather than committed. Nor does :sys.get_state(router), since
hot_reload/1 is a cast per screen. The screens are drained, and because
render/1 is user code the assertion is a bounded wait rather than a snapshot
comparison: with a 30ms sleep in render/1 the snapshot version failed every
run and the bounded one passes.
- Screens dump in their own terminate/2 after the router exits, so
GenServer.stop(router) is not enough; the state tests monitor the screens and
wait for their exits, or the dump races the Repo teardown and logs a DB error
out of a passing test (reproduced 10/15 without the fix).
- all_entries/1 has three branches — current, the ACTIVE stack's history, and
parked — and the first scenario ended on a tab switch, leaving the active
history empty. Deleting that branch outright passed the whole suite. The
scenario now switches back, so all three are populated, and dropping either
the history or the parked branch fails the test.
Ordering: audited. On any message path the only multi-screen iteration is the
hot-reload broadcast; the stop_screen reductions in pop_to_root, pop_to and
reset are order-independent.
Rationale in decisions/2026-08-29-migration-off-the-single-screen-process.md.
Tests: 3 new. Suite 1243 passed, 20/20 clean runs, format and credo --strict
clean.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 free
to 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.
Final step of epic MOB-108. Follows #95, #96, #97, #99, #101.
What this step turned out to be
MOB-114 was scoped as three pieces of migration work. Two were already done — they fell out of MOB-112 rather than needing separate work. Each live screen schedules its own state sync and dumps in its own
terminate/2, and hot reload has been a broadcast over every live screen since screens became processes.Neither had a test. That's the real gap: behaviour that arrived as a side effect of another change, asserted nowhere. Reviews of MOB-112 flagged
hot_reloadas having zero coverage, and nothing had ever round-tripped state across a multi-stack app — this issue's stated acceptance criterion.Both are pinned now, verified as negative controls. Before MOB-112 the second wasn't merely untested but impossible: only the active screen held a socket, so a screen you'd navigated away from couldn't be dumped at all.
The third piece was attempted and reverted
That's the more useful result, so it's recorded in the ADR rather than quietly dropped.
Mob.Test'stap/2,select/3,send_message/2were changed to resolve the screen pid and send directly, reasoning that a native tap reaches a screen viaMob.Listenerwithout touching the router. Review showed that was wrong three ways:get_screen_pid/1is aGenServer.callinto the router — the serialisation point the change cited as its motivation. One async send became a synchronous round-trip plus a second send. Measured at 5002 ms for onesend_messagewhile the router was blocked, against a documented fire-and-forget contract.state.current.pidand delivers in one step. Resolving separately opens a full RPC round trip in which the screen can restart and the event be delivered to a corpse.:mob_screenon both platforms and are documentedsend_message/2payloads. Router-handled shapes stopped working entirely —send_message(node, {:mob, :back})no longer popped.Addressing
:mob_screenis correct, not just incumbent: the router resolves and delivers atomically in one non-blocking RPC, and it's where native itself sends the messages this function simulates.lib/is unchanged by this step.Two barriers that looked like barriers
Worth recording — each produced a passing test that proved nothing.
Mob.Sender.sync/1doesn't order a hot reload: a background screen's tree is dropped rather than committed, so the sender catching up says nothing about whether that screen processed its cast. Nor does:sys.get_state(router), sincehot_reload/1is a cast per screen. The screens are drained — and becauserender/1is user code that may take arbitrarily long, the assertion is a bounded wait rather than a snapshot comparison. With a 30 ms sleep inrender/1the snapshot version failed every run; the bounded version passes.Separately, screens dump in their own
terminate/2, which runs after the router exits, soGenServer.stop(router)returning doesn't mean the writes landed. The state tests monitor the screens and wait for their exits.Verification
terminate/2, fails exactly the tests written for themrender/1passes 3/3 with the bounded waitmix format,credo --strictcleanEpic complete
MOB-108's six steps are done. Deferred findings are tracked as MOB-115, MOB-116 (resolved by MOB-112), MOB-117, MOB-121; unrelated bugs found along the way as MOB-118 (deploy durability), MOB-119 (suite flakes), MOB-120 (Android dark-mode text).