Skip to content

Stop mob_dev force-quitting every app on an attached iPhone (MOB-70) - #72

Merged
GenericJam merged 2 commits into
masterfrom
fix/mob-70-targeted-app-kill
Sep 6, 2026
Merged

Stop mob_dev force-quitting every app on an attached iPhone (MOB-70)#72
GenericJam merged 2 commits into
masterfrom
fix/mob-70-targeted-app-kill

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Launching a Mob app on a physical device first cleared other apps off it. The reason is real: each physical-device Mob app starts an in-process EPMD bound to 0.0.0.0:4369, so only one can run at a time — a second gets EADDRINUSE and never boots.

What it cleared was decided by matching every process under Bundle/Application/ and terminating it with devicectl --kill. All third-party iOS apps run from that path. Plugging in a personal iPhone and running mix mob.connect force-quit every app its owner had open, and the except_bundle argument meant to spare the target app was discarded outright (_ = except_bundle) while the doc claimed it was honoured.

Measured against the attached iPhone SE

Real devicectl device info processes output, 586 lines, replayed through both implementations:

user apps killed
before15 (TestFlight among them)
after, no registry0
after, one app recorded as ours1

The fix

mob_dev records what it installs (MobDev.IOSInstalls, ~/.mob/ios_installs.json) and kills only that, excluding the app about to be launched. The decision is a pure function, IOS.mob_pids_to_kill/3, so the part that was untestable is now the tested part.

Not knowing is a reason to do nothing. An absent, empty, unparseable or shape-wrong registry yields [], and [] means kill nothing.

Review found the first attempt still unsafe

It would have been one unlucky project name from being as bad as the bug:

  • The pattern was not anchored to Bundle/Application/, so it also matched 24 system processes on the attached device — SpringBoard, Preferences, Spotlight, News. Matching is by app name, and ios_display_name/0 is Macro.camelize of the project name, so a project called :news produces exactly the bundle name Apple ships. The device already runs a Mob app named News alongside Apple's. Now anchored, with a test that fails without it.
  • The registry was never pruned. Since matching is by name, a stale entry stays killable for ever and a third-party app that later takes that name inherits it — the original bug in miniature. Uninstall now forgets.
  • The decision record promised a diagnostic that did not exist. A foreign Mob app holding 4369 makes devicectl launch report success while the BEAM inside dies, so the user sees a connect timeout and no reason. An empty record now says so.
  • Deleting the one line that records an install left all 2385 tests green, silently disabling the feature. The invariant is now pinned — and the bundle-id test asserts against a config where the iOS and Android ids actually differ, because in a project where they coincide it could not fail.
  • Registry writes are write-then-rename; two concurrent deploys could otherwise leave a reader parsing a half-written file.

Behaviour change

A Mob app installed by another route — Xcode, TestFlight, a colleague's build — is no longer cleared, so it keeps EPMD 4369 and the launch fails as it did before mob_dev cleared anything. That is the correct trade against force-quitting a stranger's banking app, and it now prints why instead of timing out silently.

Tests

26 new, all mutation-checked: restoring the old regex fails 5 of 7 in the scope suite; un-anchoring fails the collision test; swapping bundle_id for ios_bundle_id fails the record test; dropping the is_list guard fails the registry suite.

Suite 2397 passing, credo clean. Decision record at decisions/2026-09-06-mob-dev-kills-only-what-it-installed.md.

🤖 Generated with Claude Code

GenericJamand others added 2 commits September 6, 2026 00:34
Launching a Mob app on a physical device first cleared other apps off it. The
reason is real: each physical-device Mob app starts an in-process EPMD bound to
0.0.0.0:4369, so only one can run at a time — a second gets EADDRINUSE and
never boots.
What it cleared was decided by matching every process under
Bundle/Application/ and terminating it with `devicectl --kill`. Every
third-party iOS app runs from that path. Plugging in a personal iPhone and
running `mix mob.connect` force-quit every app its owner had open, losing
whatever state they held, and the `except_bundle` argument meant to spare the
target app was discarded outright while the doc claimed it was honoured.
Measured against the iPhone attached while writing this: the old code would
have killed 15 user apps, TestFlight among them. It now kills 0.
mob_dev records what it installs (MobDev.IOSInstalls, ~/.mob/ios_installs.json)
and kills only that, excluding the app about to be launched. The decision is a
pure function, IOS.mob_pids_to_kill/3, so the part that was untestable is now
the tested part. Not knowing is a reason to do nothing: an absent, empty,
unparseable or shape-wrong registry yields [], and [] means kill nothing.
Review found the first attempt still unsafe, and the fix would have been one
unlucky project name from being as bad as the bug:
- The pattern was not anchored to Bundle/Application/, so it also matched 24
system processes on the attached device — SpringBoard, Preferences,
Spotlight, News. Matching is by app name, and `ios_display_name/0` is
Macro.camelize of the project name, so a project called :news produces
exactly the bundle name Apple ships. The device already runs a Mob app named
News alongside Apple's. Now anchored, with a test that fails without it.
- The registry was never pruned. Since matching is by name, a stale entry
stays killable for ever and a third-party app that later takes that name
inherits it — the original bug in miniature. Uninstall now forgets.
- The decision record promised a diagnostic that did not exist. A foreign Mob
app holding 4369 makes `devicectl launch` report success while the BEAM
inside dies, so the user sees a connect timeout and no reason. An empty
record now says so.
- Deleting the one line that records an install left all 2385 tests green,
silently disabling the feature. The invariant it rests on is now pinned:
bundle basename == executable name == recorded name, and the recorded id is
the iOS one, asserted against a config where the iOS and Android ids differ
so the test can actually fail.
- Registry writes are now write-then-rename; two concurrent deploys could
otherwise leave a reader parsing a half-written file.
The rule this is an instance of, recorded in decisions/: a tool operating on
someone's device kills what it created and nothing else. mob_dev was doing to
users' phones exactly what this project's own agent rules forbid — never
pkill -f, only PIDs you spawned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-merge review found the previous commit's central claim was false. It said
the install-record invariant was pinned; it was not. The helpers were made
public and tested, which looks like coverage, but deleting the line that
*calls* them still left all 2397 tests green — the exact gap that commit
claimed to close. Both call sites are now pinned by source-contract tests,
following the convention this repo already uses for behaviour that cannot be
executed: they shell out to devicectl against a physical device.
Mutation-checked properly this time: deleting either the record or the forget
call now fails.
Three more from the same review:
- The temp file name for the registry write was shared, so write-then-rename
did not fix the torn read it claimed to — two concurrent deploys write the
same path, and one can rename while the other is mid-write, publishing a
torn file atomically. Now unique per write.
- A comment and the decision record both said the attached device runs a Mob
app called News *and Apple's*. Only the Mob one is running; I overstated
what I had observed. Corrected, and the more useful fact recorded in its
place: Apple's MobileCal.app genuinely does run from Bundle/Application/, so
a project named mobile_cal would collide exactly. The anchor is tidiness;
the registry is the safety property.
- restart_app_physical/2's docstring still promised it "kills any other
user-installed app". That was the sentence that made MOB-70 possible in the
first place — a doc describing behaviour the code did not have. Leaving a
second one in the same function would have been careless.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
OwnerAuthor

Pre-merge review returned MERGE, but caught that this PR's own description was wrong. Fixed before merging.

The claim "the invariant is now pinned" was false. Deleting the record/3 call site still left all 2397 tests green — the exact gap the previous commit said it closed. Making the helpers public and testing them looks like coverage without being it; nothing pinned the call. Same for forget/2. Both are now pinned by source-contract tests, per the convention this repo uses for behaviour that shells out to a physical device, and mutation-checked properly: deleting either call now fails.

Three more from the review, all fixed:

  • The temp filename was shared, so write-then-rename didn't fix the torn read it claimed to — two concurrent deploys write the same path, and one can rename while the other is mid-write, publishing a torn file atomically. Now unique per write.
  • I overstated an observation. A comment and the decision record said the attached device runs a Mob app called Newsand Apple's. Only the Mob one is running. Replaced with the fact that actually matters: Apple's MobileCal.app genuinely runs from Bundle/Application/, so a project named mobile_cal collides exactly — the anchor is tidiness, the registry is the safety property.
  • restart_app_physical/2's docstring still promised it "kills any other user-installed app". That kind of sentence — a doc describing behaviour the code doesn't have — is what made MOB-70 possible; leaving a second one in the same function would have been careless.

Review verified against the attached iPhone independently: anchored and unanchored regexes match the same 17 real Mob apps (so the anchor loses nothing), empty registry kills 0, and .appex bundles correctly attribute to their container app. It also confirmed no remaining path on either platform where mob_dev terminates something it didn't install.

Remaining known-lossy behaviour, all failing safe and documented: concurrent record/forget is read-modify-write, so a race can drop another device's entry — the result is an empty registry, which means kill nothing, warn, and self-heal on the next install.

Suite 2400 passing, credo clean.

@GenericJam
GenericJam merged commit 46d9d01 into masterSep 6, 2026
3 checks passed
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