Stop mob_dev force-quitting every app on an attached iPhone (MOB-70) - #72
Conversation
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
commented
Sep 6, 2026
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 Three more from the review, all fixed:
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 Remaining known-lossy behaviour, all failing safe and documented: concurrent Suite 2400 passing, credo clean. |
Uh oh!
There was an error while loading. Please reload this page.
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 getsEADDRINUSEand never boots.What it cleared was decided by matching every process under
Bundle/Application/and terminating it withdevicectl --kill. All third-party iOS apps run from that path. Plugging in a personal iPhone and runningmix mob.connectforce-quit every app its owner had open, and theexcept_bundleargument 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 processesoutput, 586 lines, replayed through both implementations: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:
Bundle/Application/, so it also matched 24 system processes on the attached device — SpringBoard, Preferences, Spotlight, News. Matching is by app name, andios_display_name/0isMacro.camelizeof the project name, so a project called:newsproduces exactly the bundle name Apple ships. The device already runs a Mob app namedNewsalongside Apple's. Now anchored, with a test that fails without it.devicectl launchreport success while the BEAM inside dies, so the user sees a connect timeout and no reason. An empty record now says so.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_idforios_bundle_idfails the record test; dropping theis_listguard 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