Skip to content

MOB-114: pin the two behaviours that arrived for free - #102

Merged
GenericJam merged 1 commit into
masterfrom
feat/mob-114-migration
Aug 29, 2026
Merged

MOB-114: pin the two behaviours that arrived for free#102
GenericJam merged 1 commit into
masterfrom
feat/mob-114-migration

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

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_reload as 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's tap/2, select/3, send_message/2 were changed to resolve the screen pid and send directly, 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 its motivation. One async send became a synchronous round-trip plus a second send. Measured at 5002 ms for one send_message while the router was blocked, against a documented fire-and-forget contract.
  • Resolve-then-send isn't 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 and webview messages go to :mob_screen on both platforms and are documented send_message/2 payloads. Router-handled shapes stopped working entirely — send_message(node, {:mob, :back}) no longer popped.

Addressing :mob_screen is 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/1 doesn'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), since hot_reload/1 is a cast per screen. The screens are drained — and because render/1 is user code that may take arbitrarily long, the assertion is a bounded wait rather than a snapshot comparison. With a 30 ms sleep in render/1 the snapshot version failed every run; the bounded version passes.

Separately, screens dump in their own terminate/2, which runs after the router exits, so GenServer.stop(router) returning doesn't mean the writes landed. The state tests monitor the screens and wait for their exits.

Verification

  • 1243 tests, 3 new. 12/12 clean full-suite runs
  • Negative controls: reverting the hot-reload broadcast, or removing the dump from terminate/2, fails exactly the tests written for them
  • Timing probe: a 30 ms render/1 passes 3/3 with the bounded wait
  • mix format, credo --strict clean

Epic 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).

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
GenericJam force-pushed the feat/mob-114-migration branch from f627eae to aa1d109 Compare August 29, 2026 11:41
@GenericJam
GenericJam merged commit c637776 into master Aug 29, 2026
4 checks passed
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>
Sign up for free to 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