Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,18 @@ Real fix: `sim_runtime_dir/0` should detect `ios/build.zig` and use
`run-as` ERTS probe, exists because after an APK reinstall the root-pushed
tree can carry the previous install's categories, which `run-as` can't read
(MOB-478).
- **Physical iOS BEAM overrides must be exact and self-verifying.** The app
prefers `Documents/otp/<app>` over its complete signed bundle. Replace that
directory rather than incrementally merging it, require `<app>.beam` before
transfer, and verify the received bootstrap bytes before restarting.
- **Physical iOS BEAM uploads never remove anything and never land in place.**
`devicectl --remove-existing-content` empties the app's whole data
container (Documents, Library, tmp), whatever `--destination` says
(MOB-408); never pass it. For mob ≥ 0.9.20 upload to a fresh
`Documents/otp/<app>.versions/<N>`, verify the received bootstrap bytes,
then copy `<N>/.mob_complete`; the app loads only committed versions and
deletes the old ones itself (devicectl has no delete verb). Older runtimes
get a merge into `Documents/otp/<app>`. Require `<app>.beam` before
transfer and verify it before restarting, either way, and after the
relaunch confirm `Documents/mob_diag_beams_dir.txt` names the directory
written: which one the app loads is the runtime's choice, not mob_dev's. See
`decisions/2026-10-15-ios-deploy-never-empties-the-container.md`.
- **iOS bundle ids resolve through one function per side, never `bundle_id/0`.**
Consumers (deploy, connect, provision, battery bench) use
`MobDev.Config.ios_bundle_id/0`; the build (sim bundle, device bundle,
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
## [Unreleased]

### Fixed

- **A BEAM deploy to a physical iPhone no longer erases the app's data
(MOB-408).** The deploy copied the BEAMs with `xcrun devicectl device copy
to ... --remove-existing-content true`, meant to replace
`Documents/otp/<app>`. devicectl instead empties the app's whole data
container apart from the destination: every deploy deleted the app's SQLite
database, sessions, pairing state, the rest of `Documents/`, and `Library/`
and `tmp/`. Copies now only add and overwrite. For an app on mob ≥ 0.9.20
each deploy goes to a new `Documents/otp/<app>.versions/<N>/`, committed by
a `.mob_complete` marker once the bootstrap BEAM reads back correctly; the
app loads the newest committed version and deletes the older ones, so an
interrupted deploy leaves the previous code running. For an older runtime
the BEAMs are merged into `Documents/otp/<app>`; there a module removed
from the project stays loadable until the app is reinstalled or moves to
mob ≥ 0.9.20, and an interrupted copy can still leave old and new BEAMs
mixed (the error says so). The new layout is used when the project's mob
ships `ios/mob_code_versions.h` and the phone has
`Documents/otp/<app>.versions/`, which that runtime creates at each boot.
After relaunching the app, the deploy now waits (up to 20 s) for its
`Documents/mob_diag_beams_dir.txt` to name the directory just written, and
fails, naming the one it booted from, if it doesn't: that is an app whose
native runtime and `:mob` dependency disagree. See
`decisions/2026-10-15-ios-deploy-never-empties-the-container.md`.
**Upgrading:** updating mob_dev stops the wipe, with any mob. For crash-safe
deploys also update mob to 0.9.20 and run `mix mob.deploy --native` once;
that deploy still merges into `Documents/otp/<app>`, and the ones after it
use the numbered directories. Moving an app's mob across 0.9.20 later, in
either direction, needs `--native` too, or fast deploys fail the boot check.

## [0.7.27] - 2026-10-15

### Fixed
Expand Down
8 changes: 7 additions & 1 deletion decisions/2026-09-05-dist-deploy-writes-disk-too.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ succeeded. And even over USB the write is an
`xcrun devicectl ... --remove-existing-content` replace with no undo: a cable
knock mid-copy leaves the app unbootable, with no way back but another
successful deploy. A hot load could never damage a device. Making it able to,
silently, on the most-used command in the repo, is not a fix.
silently, on the most-used command in the repo, is not a fix. (Amended by
MOB-408: that replace was worse than stated, since the flag empties the app's
whole data container. From mob_dev 0.7.28 the write never removes anything:
an app on mob ≥ 0.9.20 gets a new committed directory that leaves the
previous code running if interrupted, an older one a merge into
`Documents/otp/<app>`, which can still leave it unbootable. The first reason
stands either way; see `2026-10-15-ios-deploy-never-empties-the-container.md`.)

The documented physical-iOS dist workflow is USB *unplugged* anyway, which is
precisely the state in which the write cannot run.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ the bundle id. iOS, Android and the Mac-side node all use it.
file, so it stays attachable. (Amended by MOB-348: iOS filesystem deploys
now also write `mob_dist_cookie` into the beams dir, the simulator runtime
dir or the physical iPhone's `Documents/otp/<app>/`, and mob ≥ the matching
release reads it, so iOS relaunches stay attachable too.)
release reads it, so iOS relaunches stay attachable too. Amended again by
MOB-408: on a physical iPhone with mob ≥ 0.9.20 that is the deploy's new
`Documents/otp/<app>.versions/<N>/`.)
- While a legacy app is connected, its node name carries `mob_secret` as a
per-node cookie on the Mac. That is the old exposure, limited to that name,
and the warning tells the developer how to end it.
90 changes: 90 additions & 0 deletions decisions/2026-10-15-ios-deploy-never-empties-the-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# A physical-iPhone deploy never removes anything: numbered uploads, or a merge

- Date: 2026-10-15
- Status: accepted
- Linear: MOB-408

## Context

`mix mob.deploy` copied a physical iPhone's BEAMs with
`xcrun devicectl device copy to ... --destination Documents/otp/<app>
--remove-existing-content true`, replacing the override rather than merging
into it (so removed modules didn't linger), then read back `<app>.beam` to
check it.

Measured on Kevin's iPhone SE (devicectl 642.16): with that flag, devicectl
empties the app's whole data container except the destination, not the
destination directory its help describes. A merge copy (no flag) to
`Documents/otp/zz` left everything in place; the same copy with the flag left
only `Documents/otp/zz`, deleting `Documents/app.db`, the other Documents files
and directories, `Documents/otp`'s other entries, and the files in `Library/`
and `tmp/`. Reproduced end to end with a blank host on mob 0.9.19 and mob_dev
0.7.26: a sentinel file, a SQLite row, a nested directory, a `Library/` file and
a sibling in `Documents/otp` were all gone after one BEAM-only deploy. Every
deploy erased the app's data, and an interrupted one left the only directory
`mob_beam.m` loads incomplete.

devicectl has no delete or rename verb, so the Mac can neither clean up nor
swap a directory atomically. `devicectl device info files` lists a directory;
for one that doesn't exist (or an app that isn't installed) it exits 1 with
`CoreDevice.ActionError` code 3 in its JSON.

## Decision

Never pass `--remove-existing-content`. Every copy is a merge. Where it goes
depends on the app's runtime:

- **mob ≥ 0.9.20** (`ios/mob_code_versions.h` in mob; mob's
`decisions/2026-10-15-ios-versioned-code-dirs.md`): list
`Documents/otp/<app>.versions/`, upload to `<N>/` with `N` one above the
highest numeric entry (committed or not, so an upload never lands on an
interrupted one's leftovers), read back `<N>/<app>.beam` and compare, then
copy `<N>/.mob_complete`. The app loads the highest committed `N` and
deletes older ones at boot, so an interrupted deploy leaves the previous
code running.
- **Older runtimes**: merge into `Documents/otp/<app>`, the one directory they
read. A module removed from the project lingers there, and an interrupted
copy can still leave a mix of old and new BEAMs; the error says so.

The versioned layout is used only when both are true (`ios_code_plan/2`):

- the project's `:mob` dependency has `ios/mob_code_versions.h`, so the
runtime built from it reads the layout. A file, not a version number, so a
path or git dependency of an unreleased mob counts;
- `Documents/otp/<app>.versions/` exists on the phone, which the runtime
creates at every boot.

The first alone misses mob updated without `--native` (an old runtime still
on the phone, which would ignore the upload); the second alone misses a phone
that ran a newer mob before a `--native` downgrade. A listing that fails any
other way fails the deploy.

Both together still guess wrong when the project's mob moves across 0.9.20
without `--native` on a phone that has `<app>.versions/`: a versioned upload
an old runtime ignores, or a legacy merge that a new runtime's committed
version shadows (and that runtime then deletes). So the deploy checks the
result instead of trusting the guess. Every mob runtime since 0.6.2 writes
the directory it loaded to `Documents/mob_diag_beams_dir.txt` at boot. The
deploy overwrites that file with a placeholder (a legacy deploy expects the
same path the previous boot may have written), relaunches, reads it until it
names the directory just written (up to 20 s), and otherwise fails, naming
where the app booted from and saying to run `mix mob.deploy --native`. No
relaunch (a launch that failed, or `restart: false`) means nothing to check.

## Consequences

- No BEAM deploy deletes app data again, whatever mob the app runs.
- A physical-iPhone deploy that relaunched the app and reports success has
had the app boot from the code it wrote.
- The `mix mob.deploy --native` run that first puts mob ≥ 0.9.20 on a phone
still merges into `Documents/otp/<app>` (the new runtime hasn't booted to
create `<app>.versions/`); its restart boots that runtime, later deploys
are versioned, and the first versioned boot deletes the legacy directory.
- Each deploy costs one more devicectl call (the listing, for a project on
mob ≥ 0.9.20), one more for a versioned one (the marker), and the boot
check: about a second over USB in total, more if the app is slow to start.
- Two deploys to one phone at the same moment can both pick the same `N`.
Concurrent deploys could always clobber each other; this doesn't fix that.
- A physical iPhone connected over dist is still only hot-loaded
(`persistable?/1`): a LAN-only device has no devicectl route, and an old
runtime's merge can still break the app mid-copy.
14 changes: 12 additions & 2 deletions lib/mix/tasks/mob.deploy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,18 @@ defmodule Mix.Tasks.Mob.Deploy do
rsync -a <compile path>/ ~/.mob/runtime/ios-sim/<app>/
xcrun simctl launch <udid> <ios bundle id>

# Any other physical iPhone: replace Documents/otp/<app>, relaunch
xcrun devicectl device copy to --device <udid> ... Documents/otp/<app>
# Any other physical iPhone: upload into a new numbered directory,
# check it, commit it, relaunch, and confirm the app booted from it. The
# app loads the newest committed one and deletes the older ones; nothing
# outside it is touched (MOB-408).
xcrun devicectl device info files ... --subdirectory Documents/otp/<app>.versions
xcrun devicectl device copy to --device <udid> ... Documents/otp/<app>.versions/<N>
xcrun devicectl device copy to --device <udid> ... Documents/otp/<app>.versions/<N>/.mob_complete
xcrun devicectl device copy to --device <udid> ... Documents/mob_diag_beams_dir.txt
xcrun devicectl device process launch --device <udid> --terminate-existing <ios bundle id>
xcrun devicectl device copy from ... --source Documents/mob_diag_beams_dir.txt
# (an app on mob < 0.9.20, or not yet launched since its --native
# install: merge into Documents/otp/<app> instead)

The BEAMs come from Mix's active build path (`Mix.Project.compile_path/0`
and `Mix.Project.build_path/0`): the app and its runtime dependencies, never
Expand Down
Loading
Loading