Skip to content

Fix device_capabilities.md to remove spurious triple back-ticks - #2

Merged
GenericJam merged 1 commit into
GenericJam:masterfrom
leonardb:patch-1
May 14, 2026
Merged

Fix device_capabilities.md to remove spurious triple back-ticks#2
GenericJam merged 1 commit into
GenericJam:masterfrom
leonardb:patch-1

Conversation

@leonardb

Copy link
Copy Markdown
Contributor

Remove the extra triple back-ticks which were breaking rendering of documentation

Remove the extra triple back-ticks which were breaking rendering of documentation
@GenericJam
GenericJam merged commit 5892f7b into GenericJam:masterMay 14, 2026
1 check passed
GenericJam added a commit that referenced this pull request May 14, 2026
Four small lost-in-the-shuffle items closed in this batch. All four
were held up by Phase 2 work touching the same files (#1/#2/#4 in
live_view_patcher.ex; #5 in native_build.ex).
#1 — Phoenix LiveReload mac_listener warnings: code_reloader/watchers/
live_reload disabled in on-device endpoint config.
#2 — esbuild/tailwind version-not-configured warnings: versions set
via Application.put_env in mob_app.ex before ensure_all_started.
#4 — port 4200 collisions across multiple Mob LV apps: per-app hash
into 4200..4999 via :erlang.phash2(:<app>, 800).
#5 — deploy auto-pick of iPhone over sim was silent: prints the
--device <short-id> alternative when both are connected.
#3 (WS→longpoll fallback in WKWebView) is investigation, not a fix —
deferred. #6-#11 are larger work (OTP rebuild, AX modifiers,
Compose semantics walker, Android 17 SELinux patch). #12, #13 already
fixed earlier. #14 is moderate — sim node naming reconciliation
between mob_dev's connect.ex and mob_beam.m, deferred.
GenericJam added a commit that referenced this pull request Aug 29, 2026
* MOB-112: one process per screen, owned and monitored
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>
* MOB-112: fix eleven defects found by adversarial review
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>
* MOB-112: per-screen render refs, restart ceiling, and review #2 fixes
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>
* MOB-112: fix the two blockers from review #3
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>
* MOB-112: make the crash-test helpers deterministic
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>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
GenericJam added a commit that referenced this pull request Sep 1, 2026
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>
GenericJam added a commit that referenced this pull request Sep 1, 2026
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>
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.

2 participants

@leonardb@GenericJam