From 1d0c40832a47441c4899875c35887976a391683c Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 12:14:20 +0200 Subject: [PATCH 01/17] =?UTF-8?q?[agent-cli]=20the=20device=20registry:=20?= =?UTF-8?q?one=20iOS=20simulator=20per=20worktree,=20bound=20under=20a=20l?= =?UTF-8?q?ock=20and=20read=20by=20every=20verb=20(llp/0031=20=C2=A7The=20?= =?UTF-8?q?module)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/deviceBinding/ holds the records, the lock, the lease, the choice, the inspect table and the rung loop of llp/0030, with the iOS boot through simctl bootstatus -b. No caller yet. The reap and the Android half arrive with llp/0032 and llp/0033. The five design files land with it. Co-Authored-By: Claude Fable 5.1 --- .../llp/0035-one-device-per-worktree.rfc.md | 562 ++++++++++++++++++ .../deviceBinding/__tests__/acquire-test.ts | 197 ++++++ .../deviceBinding/__tests__/boundary-test.ts | 58 ++ .../deviceBinding/__tests__/errors-test.ts | 70 +++ .../src/deviceBinding/__tests__/fakeTools.ts | 159 +++++ .../deviceBinding/__tests__/inspect-test.ts | 112 ++++ .../deviceBinding/__tests__/registry-test.ts | 158 +++++ .../src/deviceBinding/__tests__/rungs-test.ts | 156 +++++ .../agent-cli/src/deviceBinding/choose.ts | 58 ++ .../agent-cli/src/deviceBinding/errors.ts | 120 ++++ .../agent-cli/src/deviceBinding/events.ts | 14 + .../agent-cli/src/deviceBinding/index.ts | 180 ++++++ .../agent-cli/src/deviceBinding/inspect.ts | 116 ++++ .../@expo/agent-cli/src/deviceBinding/ios.ts | 201 +++++++ .../agent-cli/src/deviceBinding/lease.ts | 69 +++ .../agent-cli/src/deviceBinding/registry.ts | 225 +++++++ .../agent-cli/src/deviceBinding/rungs.ts | 139 +++++ .../agent-cli/src/deviceBinding/tools.ts | 23 + .../agent-cli/src/deviceBinding/types.ts | 158 +++++ 19 files changed, 2775 insertions(+) create mode 100644 packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/registry-test.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/rungs-test.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/choose.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/errors.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/events.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/index.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/inspect.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/ios.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/lease.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/registry.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/rungs.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/tools.ts create mode 100644 packages/@expo/agent-cli/src/deviceBinding/types.ts diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md new file mode 100644 index 00000000..e9746c04 --- /dev/null +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -0,0 +1,562 @@ +# 0035: One device per worktree + +**Type:** RFC +**Status:** Draft +**Systems:** device binding (new `src/deviceBinding/`); local device selection and boot (`src/device/bootDevice.ts`, `src/navigate/device.ts`, `src/installedApp/`); the `dev` resolver and run (`src/dev/`); `dev:stop` (`src/dev/stopAsync.ts`); `smoke` (`src/smoke/smokeAsync.ts`); EAS session selection (`src/device/cloudSimulator.ts`); the dev-server lock (`src/devLock/`); `src/deviceBinding/` (new); the `dev` resolver and run (`src/dev/resolveOptions.ts`, `src/dev/devAsync.ts`, `src/dev/detachAsync.ts`, `src/dev/index.ts`); the app-presence probe (`src/device/appPresence.ts`); the local open (`src/dev/openApp.ts`); `navigate`'s device resolution (`src/navigate/device.ts`); the installed readers (`src/installedApp/`); `smoke`'s device phases (`src/smoke/smokeAsync.ts`); `src/deviceBinding/android.ts` (new); the Android boot (`src/device/bootDevice.ts`); the Android open (`src/dev/openApp.ts`); `installDevBuild`, `askDeviceAsync`, `src/installedApp/android`; `smoke`'s Android phases; the e2e stub `adb` and a new stub `emulator`; `src/deviceBinding/reap.ts` (new); `releaseWorktreeDevicesAsync`; `dev:stop` and its `--release` flag (`src/dev/stopAsync.ts`, `src/dev/resolveStopOptions.ts`); the Metro runner (`src/start/startAsync.ts`); `status --json`; `smoke`'s cleanup; the live tier (`e2e-live/`); `src/deviceBinding/cloud.ts` (new); the EAS open (`src/dev/openAppEas.ts`); dev-wait; `smoke`'s EAS branches; `dev:stop --eas`; the e2e stub `eas`; the live suite `live-cloud` +**Author:** Vojtech Novak (drafted with Claude) +**Date:** 2026-10-08 +**Related:** [[0004-smart-start-and-project-state]], [[0005-runtime-loop-tools]], [[0010-agent-conventions]] (exit codes), [[0021-honest-reports]], [[0026-dev-owns-the-open]], [[0027-everything-on-eas]] §Testing, [[0025-dev-requires-platform]], [[0022-live-tier]] + +## Summary + +Developers run several agents on one Expo app in parallel, one agent per git worktree. Today the agents collide on devices: + +- Every verb uses the first booted simulator or the first `adb` device. All agents drive one device, and each agent replaces the app of the others. +- When no device is booted, each agent boots one. Nothing records which device belongs to which agent. +- The emulator always boots on ports 5554 and 5555. A second concurrent boot collides. +- `dev --eas` reuses the newest in-progress session of the platform, which another worktree or a CI job may own. [[0027-everything-on-eas]] §Testing observed this live. + +Decision [confirmed — Vojtech, 2026-09-30]: **each worktree gets its own device on each platform.** The device is a simulator this CLI created, an emulator instance this CLI spawned, a device the user named, or an EAS session this worktree started. A **binding** records it. Every verb of that worktree uses the bound device and never "the first booted" one. + +A first implementation on the branch `feat/one-device-per-platform` adopted any free booted device. It worked, and it was rejected for size: a 942-line module, a full device inventory on every verb, and liveness from two sources. This RFC owns its devices instead, which removes device classification, adoption and takeover. The model follows `appandflow/stim`, which solves the same problem for agents in worktrees. + +This RFC holds the rules, once each. The four plans hold the wiring, the acceptance lists and the tests of one PR each. §Delivery lists the PRs. + +## Prior art + +- **Detox** keeps `device.registry.json` under an exclusive file lock. Entries with dead pids are removed. [observed — wix/Detox master] +- **Callstack simlock** is a daemon that gives out TTL leases, creates devices in its own `simctl --set` directory, and deletes only devices it created. [observed — callstackincubator/simlock README] +- **appandflow/stim** gives each agent worktree a device the tool created, writes every lease under a lock, and removes a lock whose pid is dead. Its liveness is process identity, which does not fit this CLI: our verbs are short processes and nothing runs between two `navigate` calls. [read — local clone, 2026-10-07] +- **Expo CLI** `run:ios` uses the first booted simulator. `adb reverse` runs on every attached device. [observed — expo/expo `packages/@expo/cli`] + +## Where exclusivity comes from + +The claimants of a device decide where its ownership record must live. A local device is claimed only by processes on the same machine, so a machine-wide file registry sees all of them. An EAS session is claimed by agents on any machine, in CI, and by other clients. Only the EAS service sees all of them, so the service carries an owner mark (§EAS) and the local registry is a cache of it. No daemon. + +## Glossary + +- **Root**: the canonical project root of the Expo app, the same path the dev lock keys on; in a monorepo it is the app directory inside the git worktree. "Worktree" in this RFC means the root. **Digest**: `digestOf(canonicalizeExistingPath(root))`, shared with the dev lock. +- **Registry**: the directory `/agent-cli/bindings` and its lock. **Binding**: one JSON file in it, one per worktree, platform and backend. **Backend**: `local-ios`, `local-android` or `cloud`. +- **Lease**: the binding's `expiresAt`. **Live**: not past. **Expired**: past. **Stale**: expired, or the root is deleted (Decision 6). +- **Parked**: a `created` binding whose lease is expired. **Park**: set `expiresAt` to now; the file stays. Only simulators park. +- **Inventory**: what the platform tool lists: `simctl list devices -j` for iOS, `adb devices -l` and `emulator -list-avds` for Android. +- **Section**: one hold of the registry lock, from taking it to giving it up. The lock is **given up**, never released. +- **Let go**: what happens to a binding's device when its worktree, or a reap, is done with it; one rule per origin, in §Records. +- **Reap**: let go of other worktrees' stale bindings. **Release**: let go of the own bindings; done by `dev:stop --release` and smoke's cleanup. A plain `dev:stop` keeps the devices, because the agent's common next step is `dev` again (Decision 9). +- **Session cap**: an EAS session lives at most 115 min on the service (§Answers from the EAS code, item 2). +- **Dev lock**: main's per-project socket lock that the process running Metro holds (`src/devLock/`). "The lock" alone means the registry lock. +- **Metro runner**: `runDevServerAsync` in `start/startAsync.ts`. **Step runner**: `runStepAsync` in `dev/devAsync.ts`, which runs prebuild, the `--no-bundler` install step and `eas build` with no dev lock. +- **Draft**: the first start plan the resolver decides, before it knows about devices; **building** when it has a build location. Named by `ProjectTarget`: `expo-go`, `dev-client`, `bare`, `web`, `none`. +- **Rung**: one source a verb tries for a device (§Readers). **Probe**: a read that writes no binding. +- **How line**: the last line of every CLI error, one next action. +- A simulator state is capitalised (`Booted`, `Shutdown`); an origin is lower case (`created`, `spawned`, `explicit`). + +## Decisions + +1. **Hybrid ownership.** iOS: each worktree binds a simulator this CLI created. Android: one AVD; every worktree boots its own instance of it with `-read-only` on a free even console port. The CLI never chooses a device a human booted. `--device` on `dev` binds the device the human names. + **Why.** Owned devices need no classification, no takeover and no capacity rule, and a human's device is never hijacked. The split follows disk cost: simulators share one runtime per iOS version and own a small data container; each AVD owns a userdata image of several GB, and `-read-only` runs many instances off one image. +2. **Every Android instance is `-read-only`, the first one too.** + **Why.** The human's AVD image is never written by an agent, and the first and the fifth worktree behave the same. The cost is a cold boot and a reinstall per instance start. +3. **Liveness is a 60 min lease, renewed by use. Nothing else.** No agent pid in the binding, no socket probe, no mtime. The `emulatorPid` of a spawned instance identifies the emulator, not the agent. + **Why.** One source of truth. Metro renews the lease on a timer, so a Fast Refresh session never expires while Metro runs, and a crashed agent's binding is stale within 60 min. A pid in the binding would add a second source and a pid-reuse risk. The one case a pid would catch, `dev:stop --release` during a build step of the same worktree, costs one rerun of `dev` (Risk 9). +4. **Only `dev` and `smoke` allocate. Every other verb reads the binding**, with one subprocess per binding and no network call before the EAS rung. + **Why.** The agent's hottest verb must not pay for the rarest decision. `dev` is where the agent's loop starts ([[0026-dev-owns-the-open]]); `smoke` brings its own device ([[0005-runtime-loop-tools]]). +5. **A parked simulator is an expired `created` binding, not a second record.** A living worktree keeps its parked simulator and gets it back on the next `dev`. A deleted worktree's simulator is deleted by the next reap. + **Why.** One record type cannot disagree with itself. The worktree that paid for the native build keeps it. Disk is reclaimed when the worktree goes, with no count and no cap. Nothing takes another worktree's simulator: a `simctl create` is cheap, and the app on a foreign simulator is a foreign build anyway. +6. **A deleted worktree's bindings are stale at once.** Deleted means `lstat` of the root fails with `ENOENT`. + **Why.** A deleted worktree's simulator should not hold RAM for 60 min. A parent check cannot tell a removed monorepo worktree from an unmounted volume, so there is none; an unmounted volume loses its devices (Risk 14). +7. **Every registry write runs under one machine-wide lock, with tmp+rename. The lock also covers `simctl create` and the emulator spawn, and nothing else that touches a device.** + **Why.** A create or spawn under the lock ties the new device to the binding that names it in one section, so two `dev` runs in one worktree never make two devices. Every other device call runs after the section, so a read verb never waits on a boot or a shutdown. The races that leaves are in §Risks and each costs one rerun of `dev`. +8. **Seven PRs, one of them behavior-free. PRs 3a, 3b and 4 are reviewed one at a time and merged as one unit. 3a is committed module-first.** + **Why.** Between 3a and 4 main would have a window where `dev` creates simulators and nothing releases them. The readers ship with the iOS binding, because they are what prove the binding works. +9. **`dev:stop` keeps the worktree's devices; `dev:stop --release` lets go of them now.** + **Why.** The CLI's own restart follow-up is `dev:stop && dev --detach`, and a read-only emulator instance that is killed costs a cold boot and a reinstall on the next `dev`. The lease reclaims a stopped worktree's device within 60 min anyway (Decision 3), so a release on every stop buys little. The cost is RAM held for up to an hour after a stop; `--release` is for the agent that is done. +10. **Decided, not open.** `start --ios` keeps Expo CLI's device choice, because `dev` is the verb that binds. `dev --no-open` still binds, and may create, a simulator the user never sees, because the build step must be pinned. Local devices come before an EAS session, because a session bills by the minute. `status` never calls the network. + +## Invariant + +A created simulator boots, shuts down or is deleted, and a spawned emulator is killed, only after a section that wrote or removed its binding for that action. A child whose binding was never written is killed through its own process handle. The inventory is read before the lock and may be seconds stale; that is safe because every state change of a CLI device goes through a binding, and bindings are read under the lock. + +## Contracts every verb honors + +1. Only `acquireDeviceAsync` and `acquireCloudBindingAsync` write a device into a binding, and only `dev` (including `openAppEas` and dev-wait) and `smoke` call them. A read verb changes only `expiresAt`. +2. Read verbs call `inspectBindingAsync`, then `useBoundDeviceAsync` when they drive the device, and never list devices to choose one. +3. No code outside `src/deviceBinding/` runs `simctl list devices` or `adb devices` to select a device. The one exception is `status --device`, which lists to filter and binds nothing. +4. Every binding write runs under the lock. +5. A `created` simulator is never deleted while a binding names it. A simulator no binding names is never touched. +6. A recorded emulator pid is killed only after its file is removed. Never `emu kill` by serial. +7. `explicit` devices are booted at most, never shut down or deleted. +8. Every process that runs Metro or a build step on a bound device extends the lease, and on `lost` it writes nothing for that binding. +9. The EAS probe considers only sessions this worktree bound, its dotenv id (from PR 5; until then the fallback without `--eas` accepts the dotenv id only and `--eas` keeps main's choice), and sessions tagged with its digest (PR 6). +10. `src/deviceBinding/` imports nothing from `navigate`, `smoke`, `dev` or `installedApp`; a unit test enforces it. + +Files and budgets: `types.ts`, `registry.ts` (read, write, lock), `lease.ts`, `choose.ts` (pure), `reap.ts`, `inspect.ts` (the inspect table, the cache, `useBoundDeviceAsync`, `ownCloudIdsAsync`), `rungs.ts` (`findBoundDeviceAsync`, the rung loop of §Readers), `android.ts`, `cloud.ts`, `index.ts` (`acquireDeviceAsync`, `releaseWorktreeDevicesAsync`, `defaultTools`, re-exports). Every file under 250 lines, no function over 60, the iOS and Android boots in their own functions. + +## Records + +One file per worktree, platform and backend: `--.json`, so a worktree has at most four. Anything that acts on an existing file uses the `projectRoot` stored in it, never a recomputed digest, because a deleted worktree's canonical path can change. `dev` at a moved path gets a new device. + +```ts +export type DevicePlatform = 'ios' | 'android'; +export type BoundDevice = + | { + backend: 'local-ios'; + platform: 'ios'; + udid: string; + name: string; + origin: 'created' | 'explicit'; + } + | { + backend: 'local-android'; + platform: 'android'; + serial: string; + origin: + { kind: 'spawned'; avd: string; port: number; emulatorPid: number } | { kind: 'explicit' }; + } + | { + backend: 'cloud'; + platform: DevicePlatform; + id: string; + origin: 'started' | 'dotenv'; + tag?: string; + }; +export interface Binding { + version: 1; + device: BoundDevice; + projectRoot: string; + boundAt: string; // ISO; the session-cap clock, so a cloud reuse keeps it + expiresAt: string; // ISO, the lease +} +export type Runner = ( + args: string[], + options?: { timeoutMs?: number } +) => Promise; +export interface DeviceTools { + simctl: Runner; + adb: Runner; + emulatorList: Runner; + spawnEmulator(args: string[]): { pid?: number; exited: Promise; kill(): void }; + now: () => Date; + isPidAlive: (pid: number) => boolean; +} +export type AcquireResult = { + device: BoundDevice; + justBooted: boolean; + action: 'reused' | 'created' | 'spawned' | 'explicit'; +}; +export type InspectState = + 'none' | 'unreadable' | 'unknown' | 'recorded' | 'up' | 'not-up' | 'gone'; +export type Inspection = { + binding: Binding | null; + path: string; + state: InspectState; + cause?: 'expired' | 'device-gone' | 'timeout' | 'tool'; + toolError?: CommandError; +}; +export type ReleasedDevice = { + backend; + platform; + id; + name; + released: boolean; + shutDown: boolean; + reason: string | null; +}; +``` + +- Origins. `created`: the CLI may shut down, park and delete. `spawned`: the CLI may kill the emulator it spawned. `explicit`: boot at most. `cloud` `started`: this CLI started the session or found it by tag, and may stop it; `dotenv`: adopted from `.env.eas-simulator`; a reap never stops it, `dev:stop --eas` does, as today. +- Let go, one rule per origin, first match. The file action runs under the lock, the device action after it. + - A local binding whose device the inventory does not list, or a `spawned` one whose `emulatorPid` is dead: remove the file. + - `created` of a deleted root: remove the file, then `simctl delete`, where "not found" counts as success. + - `created`: park, then `simctl shutdown`, where an already shut-down simulator counts as success. A reap skips the shutdown when its inventory listed `Shutdown`. + - `spawned`: remove the file, then kill `emulatorPid` while it is alive and, where `ps` exists, its arguments name `-avd ` and `-ports ,`. Never the binary name: the `emulator` launcher execs `qemu-system-`, so the pid stays and the name does not. + - `explicit`: expire the lease and keep the file, so the next `dev` reuses the named device; the device holds nothing of ours, and a reap finds the binding expired and leaves it. A deleted root removes the file. + - `dotenv`: remove the file. + - `started`: remove the file, then `stopEasSessionAsync(, id)` with main's EAS stop timeout, or under the calling worktree's root when that root is deleted, because eas-cli reads the project and the account from the directory it runs in. A missing eas-cli or a failed stop is reported with the session id; the idle timeout or the session cap ends that session. +- Parsing. A foreign file with an unknown version, backend or origin is skipped. An own file that does not parse makes `acquire` refuse `unreadable` and read verbs report `unreadable`. +- Names. A created simulator is named `agent-cli `, so a leaked one shows whose it was. Names are never identity. +- `tools` is optional on every entry point and defaults to `defaultTools()`. + +## Lease + +- `LEASE_MS = 3_600_000` (60 min), `EXTEND_EVERY_MS = 300_000` (5 min). +- `extendLease(root, platform, backend, tools, { waitMs })` → `'extended' | 'lost'`, under the lock. A live lease is renewed. An expired or missing one is `lost`, and nothing is written. +- A lock not obtained in `waitMs` throws `DEVICE_REGISTRY_LOCKED` before any write. +- Read verbs extend with a 5 s wait through `useBoundDeviceAsync`: a lock timeout warns and goes on; `lost` (the file went missing or expired since the inspect) throws `gone`. +- `isStale(b, now)`: expired, or the root is deleted (Decision 6). `git worktree move` makes the old path stale at once. +- `withLeaseExtendedAsync(root, work)`: a 5 min `unref` timer around `work`, cleared when `work` ends. Every tick tries every one of the worktree's four candidate files that exists, so a cloud file that appears after Metro starts, and a parked file that `dev --detach` revived, are both picked up. A 5 s lock wait that fails skips the tick. The timer warns once per change from `extended` to `lost`. A tick never throws. Used by the Metro runner and around the step runner. + +## Lock + +- `withRegistryLockAsync(work, { waitMs })`: a temp dir inside the registry holding one marker `pid--` is renamed to `.lock`, so a lock never exists without a pid. A rename that fails with `EEXIST`, `ENOTEMPTY`, `EPERM` or `EACCES` means wait. +- A waiter polls every 100 ms. A dead pid's marker is unlinked, then `rmdir`; `ENOENT` or `ENOTEMPTY` means someone else won, retry. A `.lock` with no marker is `rmdir`'d. The owner gives the lock up with unlink, then `rmdir`, ignoring `ENOTEMPTY` and `ENOENT`. No nesting. +- Waits: `acquire` and release 30 s; read verbs and the timer 5 s. The refusal has reason `locked` and names the pid, the lock age from the marker's mtime and, where `ps` exists, the command. +- Under the lock: record reads and writes, the detached emulator spawn, and `simctl create` with a 20 s timeout; a timeout kills the child, gives the lock up and refuses `create-timeout`. Every other device call runs after the section. + +## Choice (`choose.ts`, pure) + +Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`, the highest available iOS runtime that lists an iPhone and its first iPhone device type. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs: + +```ts +| { kind: 'reuse'; binding } // own binding, live or expired, device present (iOS: listed; Android spawned: emulatorPid alive; Android explicit: serial listed); also --device naming the own device +| { kind: 'ios-create'; runtime; deviceType; name } +| { kind: 'android-spawn'; port; avd } +| { kind: 'explicit'; device } +| { kind: 'refuse'; reason: 'no-ios-runtime' | 'no-avd' | 'no-free-port' | 'explicit-not-found' | 'explicit-bound' | 'own-explicit-over-owned' | 'not-reusable'; boundBy?: { id: string; root: string }[]; matches?: string[] } +``` + +- `--device` matches a simulator by name or udid, case-insensitive, and an emulator by serial only, because read-only instances share one AVD name. Two or more matches are `explicit-not-found` with `matches`. A device that another worktree's `created` or `spawned` binding names, live or stale, is `explicit-bound`, and so is one its live `explicit` binding names; an expired `explicit` binding of another worktree is removed in the section and the device is bound here, because a named device belongs to whoever names it now. A simulator named `agent-cli …` that no binding names is `explicit-not-found`, so a leaked create is never bound. `--device` naming another device while the own binding is `spawned` is `own-explicit-over-owned`, because one file per worktree and platform cannot hold both and a replaced instance would be orphaned. While the own binding is `created`, the own simulator's file is removed in the same section and the simulator is deleted with `simctl delete` after it, the `explicit` binding is written, and the delete is reported on stderr: the user named the device, so the replacement is what they asked for, and a parked binding would refuse forever. An own `explicit` binding is replaced. +- With `reuseOnly`, any choice but `reuse` is `not-reusable`. +- An own binding whose device is gone is removed under the lock and allocation goes on. `no-free-port` carries the serials and the worktrees they are bound to in `boundBy`. There is no cloud variant: the cloud path writes a binding and chooses nothing. + +## Entry points + +**`acquireDeviceAsync(root, platform, { explicit?, reuseOnly?, tools? }) → AcquireResult`**, throws `DEVICE_UNAVAILABLE` with `data.reason`. For `dev` and `smoke` in bootstrap mode; both skip it under `AGENT_CLI_NO_DEVICE`. Read the platform's inventory; a device tool that cannot run is main's `toolError`. Under the lock, in one section: read the bindings, choose, write, reap; a refusal is thrown after the reap, so a deleted worktree's device does not block a rerun, and the device actions of the section's let-go rules run in a `finally` before the throw, so a reaped emulator is never left running without a file. After the section: boot and verify on every choice, whatever the inventory said (iOS: `simctl bootstatus -b`, which boots only when the simulator is not `Booted` and then waits, because `simctl boot` on a `Booted` simulator exits non-zero; Android: `sys.boot_completed`), with `BOOT_DEVICE_TIMEOUT_MS` per platform. A failed boot lets go of the binding; a spawn whose binding was never written kills its child through its handle. The caller prints `action` as one line on stderr. + +| Choice | Write under the lock | +| --------------- | ---------------------------------------------------------------------------------------- | +| `reuse` | rewrite `boundAt` and `expiresAt` | +| `ios-create` | `simctl create`, then write the real udid | +| `android-spawn` | spawn detached, then write serial and pid, in that order (no pid: refuse `spawn-failed`) | +| `explicit` | write the binding | + +**`acquireCloudBindingAsync(root, { platform, id, origin, tools? })`**: under the lock, writes or rewrites the own `cloud` binding (a reuse of the same id keeps `boundAt` and `origin`), then reaps the rows that need no inventory. The `origin` comes from the probe's reported source. Every session id a verb obtains is bound, a reuse of the bound id and a start that failed after it created the session included, so a later reap can stop that session. + +**`inspectBindingAsync(root, platform, backend, tools?) → Inspection`**, lock-free, no extension, no network, one subprocess at most. iOS: one `simctl list devices -j` filtered by udid. Android: `get-state`, plus `isPidAlive` for `spawned`; a `get-state` whose stderr says the device is not found counts as not listed, and any other non-zero exit is `cause: tool`. Cloud: the binding alone. The device check uses the 20 s timeout; `status` uses its own probe deadline. The inspect cache is main's probe cache with the root added to its key; `acquire` and release clear it; the rung loop never uses it. `state` is the first row that matches: + +| Condition | `state` | Rung loop | +| -------------------------------------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| no own file | `none` | next rung | +| own file does not parse | `unreadable` | refuse, exit 7 | +| the device tool cannot run (`cause: tool`) | `unknown` | with a platform flag: throw the tool error at once; without one: next rung, and after the EAS rung throw the first tool error seen | +| the device check timed out (`cause: timeout`) | `unknown` | refuse, exit 22 | +| cloud: lease live | `recorded` | the EAS rung decides | +| lease expired | `gone`, cause `expired` | next rung | +| local, device up: iOS listed `Booted`; Android `get-state` is `device` and, for `spawned`, `emulatorPid` alive | `up` | wins | +| local, Android `spawned` with `emulatorPid` dead | `gone`, cause `device-gone` | next rung | +| local, device listed | `not-up` | refuse, exit 20 | +| local, device not listed | `gone`, cause `device-gone` | next rung | + +**`useBoundDeviceAsync(inspection, tools?) → BoundDevice`**: takes an `up` inspection, extends the lease per §Lease, and returns the device; `lost` throws `gone` with cause `expired` (exit 20). It never inspects again and never boots. + +**`releaseWorktreeDevicesAsync(root, { platform?, sessionId? }) → ReleasedDevice[]`**, never reaps. Without `sessionId`: lets go of the local bindings, or only `platform`'s when given. With `sessionId`: removes the cloud binding that names that id, live or expired. Callers of the local release: `dev:stop --release`, after the server stop or when no server ran; smoke's cleanup. Callers of the `sessionId` release: every stop of a session this worktree bound, that is the foreground `dev --eas` exit, `dev:stop --eas` and `smoke --eas`, with no `--release` needed, because a stopped session leaves nothing to keep. The foreground `dev` exit and the detached child touch no local binding; the lease expires on its own. + +## Readers + +- Rung order. With a platform flag and no `--eas`: that platform's local binding. With no platform flag: both local platforms are inspected, iOS on macOS only, and any `up` wins; when both are `up`, the binding with the newer `boundAt` wins, so after `dev --android` on a worktree whose simulator is still up the agent drives the instance it just asked for. When no platform is `up`, the first state that is not `none`, in iOS, Android order, decides per the inspect table's last column: iOS `gone` and Android `not-up` is a refusal naming Android; iOS `gone` and Android `none` goes to the EAS rung. When every local rung passed, verbs that fall back to EAS (`navigate` and `smoke` in `fallback` mode) run the EAS rung; `runtime:stop` and `runtime:reload` do not. With `--eas` the local rungs are skipped. A tool error seen on any rung is thrown after the EAS rung; else the refusal reports the first non-`none` state seen, with its How line; with only `none` states it names the platform of the flag, else iOS on macOS and Android elsewhere. The loop is `findBoundDeviceAsync(root, { platform?, eas?, extend })` in `rungs.ts`. It calls `inspectBindingAsync` per platform, fresh, and with `extend` calls `useBoundDeviceAsync` once, on the winner. `resolveDeviceAsync` calls it with `extend: true` and sets `adb: resolveAdb()` on a `local-android` winner, so `openRoute`'s `adb reverse` still runs. +- The installed readers (`src/installedApp/`) call `findBoundDeviceAsync` once per platform with `extend: false`, because `status` never writes. +- The EAS rung is one probe call: `probeCloudSessionAsync({ boundIds })`. `ownCloudIdsAsync(root)` returns the ids of the own `cloud` bindings whose state is `recorded`; every probe caller uses it. When the selected id is an own binding's, the verb extends it per §Lease. When the listing shows a bound id as queued, the rung refuses `not-up` ("the bound session is still queued", How line: run the command again in a minute). When the listing shows it in no live state, the rung refuses `gone` with cause `device-gone` and writes nothing (Contract 1); its How line is the `dev` command with `--eas`, which on a running server opens a session through `openAppEas` and rewrites the binding (§EAS session binding). Until PR 5 the fallback without `--eas` accepts the dotenv id only, never the newest session, so a worktree with no binding does not drive another worktree's session; with `--eas` main's choice stays until PR 5. +- `probeLocalDeviceAsync({ projectRoot, ... })` maps the states of both platforms, with its cache keyed by root: any `up` is `present`; else any `unreadable` or `unknown` is main's `unknown`; else `absent`, with the first state seen as the reason. `status`, the start follow-ups and dev-wait fold it into their suggestion ladders. + +## Reap + +Runs inside `acquireDeviceAsync`, `acquireCloudBindingAsync` and `dev:stop`, for stale bindings of other worktrees. It reads the bindings after the section's own write. A caller acts only on the rows its inventory can decide: `acquireDeviceAsync` has its platform's inventory; `acquireCloudBindingAsync` and `dev:stop` have none and take only the rows that need nothing. First match, in the order of §Records: + +| Stale binding | Needs | Action | +| ---------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| local, device not listed (iOS) or `emulatorPid` dead | iOS inventory for `created` and `explicit` iOS, nothing for `spawned` | remove the file | +| `created`, root deleted | nothing (macOS) | remove the file, then `simctl delete` | +| `created` | iOS inventory | park, then `simctl shutdown` unless listed `Shutdown` | +| `spawned` | nothing | remove the file, then kill `emulatorPid` | +| `explicit`, root deleted | nothing | remove the file | +| `explicit` | nothing | nothing; a stale `explicit` binding is already expired and keeps its file | +| `cloud` `started`, `boundAt` past the session cap | nothing | remove the file | +| `cloud` `started` | nothing | remove the file, then stop the session | +| `cloud` `dotenv` | nothing | remove the file | + +Each reaped device is reported on stderr and in JSON with reason `expired`, `deleted-worktree`, `device-gone` or `session-cap`. + +## Output and errors + +- `dev:stop --release` JSON gains an optional `devices: ReleasedDevice[]`; a plain `dev:stop` reports the kept bindings under the same key with `released: false`. `status --json` gains `binding: { platform, backend, id, name, origin, state, expiresAt }[]`, one entry per file of the worktree, cloud bindings unprobed. `status`'s next action is `dev --` when there is no binding. +- Events: `cli:device_binding_reaped { reason }` and `cli:device_registry_lock_removed { lock, pid }` from `src/deviceBinding/`; `dev:stop_session` gains `platform`; `dev:open_app_eas_flag_unsupported { flag }` from `openAppEas`. +- A `DEVICE_UNAVAILABLE`, `DEVICE_REGISTRY_LOCKED` or `NO_BOUND_DEVICE` error the detached child raises reaches the agent unchanged, code, exit and How line, through the child verdict; today every child failure that is not needs-human is `DEV_DETACH_DIED`, whose How line reruns the same command. +- Exit codes per [[0010-agent-conventions]], one How line each. The `dev` command is ` dev -- --detach --wait-ready`. Main's `NO_DEVICE` and `NO_IOS_DEVICE` become `NO_BOUND_DEVICE` in 3a, `NO_ANDROID_DEVICE` in 3b. + +| Code | Reason | Exit | What | How | +| ------------------------ | ------------------------- | ---- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `NO_BOUND_DEVICE` | `none` | 20 | "No device is bound to this worktree" | the `dev` command | +| `NO_BOUND_DEVICE` | `gone` | 20 | "the lease expired" or "the device is gone" | the `dev` command | +| `NO_BOUND_DEVICE` | `not-up` | 20 | "the bound device is not up" | the `dev` command | +| `NO_BOUND_DEVICE` | `unknown` (timeout) | 22 | "the device check timed out" | run the command again | +| `NO_BOUND_DEVICE` | `unreadable` | 7 | "the binding file does not parse; a newer CLI may have written it" | `rm -f ''` | +| `DEVICE_UNAVAILABLE` | `unreadable` | 7 | the same | `rm -f ''` | +| `DEVICE_UNAVAILABLE` | `no-ios-runtime` | 7 | "no iOS runtime with an iPhone is installed" | `xcodebuild -downloadPlatform iOS` | +| `DEVICE_UNAVAILABLE` | `no-avd` | 7 | "no Android virtual device exists" | create one in Android Studio | +| `DEVICE_UNAVAILABLE` | `no-free-port` | 20 | the serials and the worktrees they are bound to; a serial no binding names is listed as "not ours" | ` dev:stop --release` in a listed worktree, or stop the unlisted serial by hand | +| `DEVICE_UNAVAILABLE` | `explicit-bound` | 20 | " is bound to the worktree " | ` dev` without `--device` | +| `DEVICE_UNAVAILABLE` | `explicit-not-found` | 1 | "no device matches , or more than one does" with `matches` | ` status --json` | +| `DEVICE_UNAVAILABLE` | `own-explicit-over-owned` | 1 | "this worktree runs the emulator instance " | ` dev:stop --release` | +| `DEVICE_UNAVAILABLE` | `not-reusable` | 20 | "a dev server is running and this worktree has no device to reuse" | ` dev:stop` | +| `DEVICE_UNAVAILABLE` | `create-timeout` | 22 | "the device tool did not answer in time" | run the command again | +| `DEVICE_UNAVAILABLE` | `spawn-failed` | 22 | the spawn error | run the command again | +| `DEVICE_REGISTRY_LOCKED` | `locked` | 22 | "pid N () has held the device registry for " | run the command again, or `kill ` when it repeats | + +- The agent guide (`src/agents/content.ts`) gains a `## Devices` heading with two sentences. One: run `dev` before `navigate`, `runtime:reload` or `runtime:stop`, or they refuse with `NO_BOUND_DEVICE` and name the command. Two: never boot, shut down or delete a simulator named `agent-cli …` yourself; it may be bound to another worktree. + +## EAS + +- `probeCloudSessionAsync` calls the pure `selectCloudSession`, which takes `boundIds`. A session is a candidate only when the service lists it as queued or in progress and its id is in `boundIds`, is the dotenv id, or carries the tag `agent-cli:`; a queued bound session is reported as such, never replaced by a new start. Unbound in-progress sessions come back as `unbound` for the report. The probe reports which source the selected id came from. +- The session start passes `--max-idle-time-minutes 30` (PR 5) and `--tag agent-cli:` (PR 6). When the start exits non-zero with oclif's "Nonexistent flag" on stderr and no session id was read, it retries once without the flag that was refused. A session found by tag is recorded as `started`. The idle timeout ships with the bound-only selection, because a start that an agent's tool timeout kills before it returns an id leaves a session with no binding, which the bound-only probe never reuses. +- `dev:stop --eas` stops the probe's session, bound or dotenv, and then each other `cloud` binding's session of this worktree, and removes each stopped session's binding with the `sessionId` release; it needs no `--release`. A plain `dev:stop` keeps `cloud` bindings and reports each as recorded, not checked. + +## Not built + +The agent's pid in the binding; a take of another worktree's simulator; a cap on parked simulators; allocation from "any free booted device"; `peek` mode; capacity from cpus and RAM; mtime touch, lock token and heartbeat; a `pool.json`, a create marker, adopt-by-name; a lock-free write path; a `busy` lease result; a `StartPlan.device` or `dev --json` device field; dev-lock socket liveness; a writable first emulator; `emu kill` by serial; `--device` on `navigate`; a "still booting" inspect state; a stop of the old session on a cloud id change. + +## Answers from the EAS code + +Read on 2026-10-01 in the local clones of `eas-cli` (24.7.0) and `universe` (`server/www/src/data/entities/device-run-session/`). The service calls a session a `DeviceRunSession`. + +1. **Owner mark: the service has one.** `eas simulator` accepts `--name` and `--tag` (repeatable, stored lowercased). Both come back in `simulator:list --json`, and the list filters by `--tag` (a session must carry every listed tag). No field carries a client identity. So the mark is a convention this CLI sets. `--tag` is in eas-cli since v23.2.0 (PR #4318). [read — eas-cli history, 2026-10-05] +2. **Expiry: opt-in.** `maxIdleTimeMinutes` stops a session after that many idle minutes; omitted, there is no idle timeout. `maxRunTimeMinutes` is capped at 40 minutes for normal-priority accounts and 115 for high-priority ones. `--max-idle-time-minutes` is in eas-cli since v22.5.0. +3. **Concurrency: no hard limit.** Sessions queue behind the plan's job concurrency; parallel agents wait, they do not fail. + +At eas-cli 24.7.0 a `--json` start still writes the session id to `.env.eas-simulator` once the session is ready. + +## Risks + +Each accepted race costs one rerun of `dev`, never a lost build or a touched human device. + +1. `-read-only` with `adb reverse` and install, "an emulator on a taken port exits non-zero", and the cold boot time are unverified here. The §Live gate runs before 3a to 4 merge. +2. A pid reused by an unrelated long-lived process keeps the registry lock until a human reads the message; a reused emulator pid is never killed where `ps` exists, and on a platform without `ps` the kill can hit the wrong process. Needs pid wrap-around during days of uptime. +3. eas-cli skew: a project pinned under 23.2 refuses `--tag` on the start; §EAS session binding tests the fallback. +4. The idle-timeout semantics on the service are unread; 30 min may stop a session during a long pause. +5. A crash or a 20 s timeout during `simctl create` leaks one `Shutdown` simulator named `agent-cli `, and a retry creates a second; nothing adopts or deletes them. Main measured a cold `simctl list` at 2.6 s, so `status`'s 2.5 s probe deadline can report `unknown` on a cold machine. +6. In bootstrap mode, `smoke` on a development-build project compiles onto the worktree's simulator instead of a simulator that already has the app. +7. A cloud binding can outlive its session while this worktree's Metro runs. After a plain `dev:stop`, the binding expires in 60 min unless `navigate --eas` renews it; the next reap of another worktree then stops a `started` session. A `started` session that a rewrite to a new id left behind has no binding and ends by the idle timeout or the session cap. A session of another EAS account cannot be stopped from this machine. +8. An `explicit` Android serial that a different emulator takes over after ours exits reads as `up`; Contract 7 keeps it unharmed. An instance answers `get-state` `device` before `sys.boot_completed`, so a read verb can reach it a few seconds early. +9. `dev:stop --release` during a build step of the same worktree finds no server and releases the device the step uses, so the simulator shuts down under the build and the build fails. The next verb reports `gone` and the agent reruns `dev`, which reuses the device. A plain `dev:stop` keeps the device, so an emulator instance holds its RAM for up to 60 min after a stop. +10. A deleted root's booted simulator stays up until the next reap anywhere, which deletes it. The simulator a `dev` exit or a plain `dev:stop` leaves `Booted` stays up until its lease expires and a reap with an iOS inventory parks it, or `dev:stop --release` runs. After `git worktree move`, the next reap deletes the old path's simulator while its Metro may still run; `dev` at the new path creates a new one. +11. `not-reusable` after `dev --android --detach` then `dev --ios --detach`, or after a server that `start` runs, tells the agent to stop its own server; both belong to the same worktree. +12. Shutdowns run after the section, so a release or a reap can shut down a simulator that a later section reused in the same seconds; that `dev` fails its boot or install and is rerun. A sleep over 60 min with a server that `start` runs loses the device, and the next `dev --detach` rebinds it. +13. Without a cap, one parked simulator per living worktree stays on disk, each with its installed build. `git worktree remove` reclaims it on the next reap. +14. A worktree created at a deleted worktree's path has the same digest and inherits its bindings, devices and cloud session, with the old build installed; the fingerprint decides whether that build is current, as after a branch switch. The bindings of an unmounted volume are stale, so its simulator is deleted and its emulator killed; a rebuild on the next `dev` is the cost. + +## Open questions + +1. Where does the idle check run on the service, and what counts as activity? +2. Should `--device ` with `--eas`, refused as `BAD_ARGS` today, bind an in-progress session that another tool started outside this worktree? + +## Delivery + +Seven PRs, stacked with `gh stack`, rebased never merged, one question per PR; 3a, 3b and 4 merge as one unit. Each later PR updates the section of this RFC it implements and carries its CHANGELOG entry. Prerequisite outside the stack: `apps/eas-example` moves to SDK 58 in its own PR, because `@expo/cli` 57 matches `run:android --device` by name only and the 3b live gate needs the serial. + +| # | Question | Plan | +| --- | ---------------------------------------------------------------------------------------------- | -------------------- | +| 1 | Which Metro is mine? Discovery verifies the project root. | below | +| 2 | Prep, no behavior change. | below | +| 3a | How does a worktree get an iOS simulator nobody else has, and how do the other verbs find it? | §iOS binding | +| 3b | The same question, on Android. | §Android instance | +| 4 | What happens when a worktree stops, pauses or disappears, and how does a person pick a device? | §Device lifecycle | +| 5 | An EAS session belongs to the worktree that started it. | §EAS session binding | +| 6 | How does a session survive a lost registry? | §EAS session binding | + +**PR 1.** Cherry-pick 94a6890, f6262e1, 80ccfb1 and 7e88e97 from the rejected branch. One conflict: the branch's `llp/0028-one-device-per-agent` file, which main lacks; delete it. One project-root predicate over the `/status` header's reported root serves the port scan, the dev lock's step 0 and the dev-lock port check; `/status` is retried only for the logged and default ports; the Discovery section goes into [[0004-smart-start-and-project-state]]. About 350 source lines. Leaves: a foreign Metro on 8081 is skipped and named in `status`. Independently mergeable. + +**PR 2.** Export `digestOf` from `src/devLock/address.ts`; add `Runner`; move `parseBootedIosSimulators`, `listBootedIosSimulatorsAsync`, `parseSimulators` and `SimulatorEntry` to `src/device/simulators.ts`; `parseAndroidDevices` and `AndroidDeviceLine` to `src/device/adb.ts`; `stopEasSessionAsync` with `buildSessionStopArgs` and its timeout to `src/device/eas.ts`; `openAppEas.ts`'s `firstLine` to `src/utils/text.ts`; retarget the lazy requires in `dev/devAsync.ts`, `dev/stopAsync.ts` and `smoke/smokeAsync.ts`. The e2e `installStubXcrunAsync` of `installedAppStubs.ts` replaces the private copies in `navigate-test.ts`, `stop-test.ts` and `runtime-reload-test.ts`, and gains a `simulators` option, `list runtimes -j`, `create` (udids `E2E-CREATED-` from a counter file, listed `Shutdown`), `delete`, `boot`, `bootstatus` (`-b` sets `Booted`), `openurl` and `shutdown`; `spawnAgentCli` gains a per-project `__UNSAFE_EXPO_HOME_DIRECTORY` default at `.expo-home`. About 200 source lines. Leaves: the unit and e2e suites pass as before. Independently mergeable. + +Verification shared by every PR: typecheck, lint, the module's unit tests, and the e2e files it touches (e2e after `bun run build` with `EXPO_E2E_TEMP_DIR` set; device tests with `AGENT_CLI_NO_DEVICE=0`; iOS cases `skipIf` off macOS, Android device cases skipped on win32). E2E fixtures: two fixture worktrees, one stub `xcrun`/`adb`/`emulator`/`eas`, one shared Expo home. Tests that pin a decision are named in each plan; the rest live in the PR descriptions. + +## iOS binding + +`dev` binds its own iOS simulator, and every verb uses it. PR 3a of §Delivery. Requires PR 1 and PR 2. Merges as one unit with §Android instance and §Device lifecycle. + +How does a worktree get an iOS simulator nobody else has, and how do the other verbs find it? + +### State after iOS binding + +- Two worktrees' `dev --ios` build and open on two `agent-cli` simulators. +- `expo run:ios --device ` carries the bound id, so a second worktree on a current build installs onto its fresh simulator. +- `dev --detach` on a running server boots its own `Shutdown` simulator and refuses otherwise. +- `navigate` without `dev` falls to the EAS rung, which until PR 5 accepts the dotenv id only, then refuses naming `dev`; with it, it drives the bound device only. Without a platform flag, any `up` binding wins. +- `smoke --ios` gets its simulator from the binding and leaves it bound. +- Android unchanged. + +### Commit order + +Module first (Decision 8). The first commits hold `src/deviceBinding/` and its unit tests, with no caller. The later commits hold the wiring, so a reviewer reads the module alone and then only call order. + +### The module + +Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` and `ownCloudIdsAsync`. The let-go rule and `releaseWorktreeDevicesAsync` ship here, because a failed boot needs them; their only caller in 3a is the failed boot. The reap step of `acquireDeviceAsync` is a no-op until §Device lifecycle. `createSimulatorAsync` runs `simctl create` under the lock per §Lock. Two tests guard the shape: the boundary test (Contract 10) and a subprocess-count test (one subprocess per binding a read verb inspects). + +### How `dev` uses it + +- `resolveStartPlanAsync` serves `dev`, `smoke` and `status`. It gains an optional `acquireDevice(draft) → Promise` callback that only `dev` passes, and returns `{ plan, acquired }`; `smoke` and `status` take `.plan`. +- Non-building draft for a native platform without `--eas`. On main the resolver exits early through two draft fields, `awaitsADevice` (the plan needs a device before it can decide the install step) and `opensOn` (the platform the open targets, null under `--no-open`). The resolver's exits, main against new: + +| Main | New | +| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| 1. exit when `awaitsADevice` is false, `opensOn` is null, the requested platform is not native, or `deviceBackend` is `eas` | 1. exit for `web` and `none` drafts, a non-native requested platform, or `deviceBackend` `eas` | +| | 2. the callback, for `expo-go`, `dev-client` and `bare` drafts | +| | 3. exit for an `expo-go` draft, unprobed | +| | 4. the `--no-open` exit; `opensOn` no longer drops out under `--no-open` | +| 2. exit when the build backend is `eas` | 5. the same | +| 3. the app-presence probe | 6. the probe, on the acquired device | +| 4. the toolchain exit | 7. the same | + +- `dev`'s callback first runs main's `--port` bindability check (`resolvePlannedPortAsync`, then `portDemandedError`, outside `--plan`) when the draft serves, and only refuses; main's later port block stays and still sets the port. Then it calls `acquireDeviceAsync`. Both skip under `AGENT_CLI_NO_DEVICE`, read through one helper in `src/deviceBinding/index.ts` that `smoke` uses too. +- The probe becomes `probeAppPresenceAsync(root, platform, { device, action }, injections)`: the device context is a new third parameter and main's injections move to the fourth; `ResolveStartPlanOptions.probeAppPresence` gains the parameter. With a device it answers `missing` without asking when `action` is `created`, even when no app id resolves, because a fresh device never has the app and the install step needs no id. On iOS a `Shutdown` simulator's apps are read off its disk. With null on iOS it answers main's `UNPROBED`. With null on Android it keeps main's first-device lookup until 3b; the `probeDevice` injection takes the root. +- Building draft: the resolver decides fully with no probe. `devAsync`'s order after `withForwardedExpoArgs` becomes: the refusals (`implicitEasRouteError` and the EAS project check move up from after the port block), the `--port` check, the `--plan` return, `acquireDeviceAsync`, `withDevice`, then the port block, which sets the port. So no simulator is created for a run that then refuses, and `--plan` never acquires. +- `withDevice(plan, device)` runs after `withForwardedExpoArgs` and before the `--port` block. It sets `--device ` on every `run` and `install` step and replaces any `--device` there; on a plan with neither it is a no-op. `AppPresenceProbe.installDevice` goes for iOS. `devAsync` passes `acquired.device` through `executePlanAsync` to `openAppForRunAsync`'s local branch, which hands it to `openAppOnDeviceAsync`; its iOS probe and boot go, and it passes the device to `openRouteAsync` through a new `device?: BoundDevice` option, which skips `resolveDeviceAsync`, so the open inspects nothing twice. `report.booted` is `acquired.justBooted`. +- The callback runs for iOS only. Android keeps main's probe-and-boot path, its first-adb rung, its `probeLocalDeviceAsync` branch and its `installDevice` until 3b. `acquire` is skipped for `--eas`. +- `dev --plan` passes a callback that inspects the own binding and returns it as `reused` with `justBooted: false` when the state is `up` or `not-up`; otherwise null. For a building draft `devAsync` calls that same callback itself and pins the plan, with no probe. When the callback returned null, `devAsync` leaves the step unpinned and adds the reason "will reuse or create a simulator" to the plan, building or not. +- `dev --detach`: the child acquires and never releases on exit. `devDetachAsync` gains an option `reuseBoundDevice`, default false, that `devAsync` passes as true and `smoke` leaves unset. With it, when the server is already running, `devDetachAsync` calls `acquireDeviceAsync` with `reuseOnly` before `reportDetached` prints, for `--ios` without `--eas` and without `AGENT_CLI_NO_DEVICE`. A parked own simulator is booted, the boot is waited for, and the `action` line is printed on stderr before the report. Any other choice refuses `not-reusable` (exit 20), because a device created here would carry no app. The child's `DEVICE_*` and `NO_BOUND_DEVICE` errors are written to the child verdict and the parent rethrows them unchanged, per §Output and errors; `detachFailureError` keeps `DEV_DETACH_DIED` for everything else. + +### iOS readers + +- `resolveDeviceAsync` makes `context.projectRoot` required and calls `findBoundDeviceAsync` per §Readers; its callers in `runtime/stopAsync.ts`, `runtime/reload/reloadAsync.ts`, `runtime/reload/cloudReload.ts` and `navigate/openRoute.ts` already pass it. Until 3b, main's Android rung counts as `none` when it finds nothing, and its tool error follows the `unknown` row. +- `probeLocalDeviceAsync` takes `projectRoot` in its options, keys its cache by it, and reads `inspectBindingAsync` on iOS; its callers in `status/statusAsync.ts`, `start/followUps.ts`, `deferred/dev-wait/waitAsync.ts` and `appPresence.ts` pass the root. Its `devices` lists each `up` binding as a `NavigateDevice`, then main's Android probe device until 3b, because `status`'s sections and `askDeviceAsync` read it. `noLocalDeviceAction` is unchanged: it runs only while a server is up. `status`'s next action names the `dev` command of §Output and errors where it names `dev` today, with the platform of the first binding whose state is not `up`. +- The installed readers take `projectRoot` and call `findBoundDeviceAsync` with `extend: false`; `status --device` keeps its filter through `listBootedIosSimulatorsAsync`. `probeIosSimulatorAsync`'s callers in `dev/openApp.ts` and `device/localDevice.ts` move to the binding in this PR. Their `no-device` result keeps its name; its `hint` carries the read verb's How line first and main's connected-phone hint after it. + +### iOS smoke + +In bootstrap mode `smoke` starts its own `dev --detach --no-open` child through `devDetachAsync`, which acquires. Smoke's `probeDevice` phase then finds the bound device, so smoke boots nothing itself. When that phase finds no device, after a reused dev server or a child that reported the server already running, the shared boot phase in `smoke/phases.ts` branches on the platform: iOS calls `acquireDeviceAsync` without `reuseOnly`, maps `action` to `installNeeded` (`created`) and the device's name to `choice`, registers no shutdown cleanup, and drops "and shut it down again afterwards" from its reason; Android keeps `bootDeviceAsync` and its shutdown until 3b. The device stays bound after smoke until §Device lifecycle. The `boot.refused` branch of the phase goes; `SmokeDeviceDisposition` `'absent'` stays, because it is also the start value. The smoke e2e boot cases that assert `pickSimulator`'s choice and the shutdown after the run are rewritten to assert the binding. + +### iOS deletions + +`bootSimulatorAsync`, `pickSimulator`, the iOS branch of `bootDeviceAsync` (the Android branch and `BOOT_DEVICE_TIMEOUT_MS` stay), `probeIosSimulatorAsync`, `parseBootedIosSimulator` (the plural `parseBootedIosSimulators` stays for `status --device`), the iOS `installDevice`, `NO_DEVICE` and `NO_IOS_DEVICE`. Help texts: the iOS lines that `git grep -i 'booted'` finds over `src/**/index.ts` and `runtime/stopAsync.ts`; the Android lines wait for 3b. + +### Docs + +The agent guide per §Output and errors. The RFC's §Readers and §Entry points are what this PR implements; first-booted sentences in llp/0004, 0005, 0022, 0025, 0026 change to the bound device. + +### iOS tests + +- Unit, named: `boot-always-runs`, `failed-boot-lets-go`, `only-create-and-spawn-under-lock`, `agent-cli-name-needs-binding`, `expo-go-and-bare-drafts-acquire`, `created-device-missing-without-app-id`, `withDevice-after-forwarded-args`, `already-running-reuses-only`, `smoke-never-reaches-already-running-reuse`, `smoke-acquires-without-bootDevice`, `smoke-registers-no-shutdown`, `created-device-plans-install`, `null-device-ios-unprobed`, `inspect-state-order`, `any-up-wins-across-platforms`, `bootstatus-b-not-boot`, `detached-device-error-relayed`, `fallback-eas-rung-dotenv-only`, `expired-is-gone`, `platform-flag-throws-tool-error-first`, `read-verb-one-subprocess-per-binding`, `rung-refusal-reports-first-state`, `probe-local-combines-platforms`, `deviceBinding-imports-nothing-from-verbs`, `exit-codes-per-reason`. +- E2E: `bindingFixture` (`created`, `expiresAt` 60 min ahead, canonical root, macOS only) seeded in the cases that hand a verb a booted simulator. A two-worktree case asserts each `run:ios --device` equals its registry id and the two ids are distinct `E2E-CREATED-*` udids, and set equality of `simctl bootstatus -b` calls. +- Live: `navigate` wall time before and after on a machine with ten simulators, warm and cold, with `simctl list devices -j` and the booted-only list timed side by side. A cold `simctl create` is timed to confirm the 20 s timeout (Risk 5). + +About 800 source lines; the module within its budgets. + +## Android instance + +`dev` binds its own Android emulator instance. PR 3b of §Delivery. Requires §iOS binding. Merges as one unit with it and §Device lifecycle. + +How does a worktree get an Android emulator nobody else has? The same question as 3a, on Android. + +### State after Android binding + +- Two worktrees' `dev --android` run two read-only instances of one AVD on ports 5554 and 5556. +- `expo run:android --device ` carries the bound serial (SDK 58 floor; the "a serial is refused" notes in `installDevBuild` and llp/0005 go). +- Live gate on SDK 58 passed (below). + +### Android boot + +Before the lock: `adb devices -l` and `emulator -list-avds`. Under the lock: `busyPorts` and the port choice per §Choice, else `no-free-port`. Then `spawnEmulator(['-avd', avd, '-ports', 'P,P+1', '-no-snapshot-save', '-read-only'])`, then one binding with serial and pid, in that order inside one section. After it: poll `sys.boot_completed` until `BOOT_DEVICE_TIMEOUT_MS`, accepted only while the child is alive and `adb devices` lists the serial; a non-zero exit or the deadline is a failed boot. + +### Wiring + +- `android.ts`; the `emulatorList` and `spawnEmulator` tools; the `android-spawn` choice; the boot poll with deadline and exit watcher. +- The resolver callback and the `reuseBoundDevice` reuse of §iOS binding run for Android too. `AppPresenceProbe.installDevice` goes for Android. `withDevice` sets `--device `. +- `openAppOnDeviceAsync`'s Android branch takes the bound device. `resolveDeviceAsync` sets `adb: resolveAdb()` on a `local-android` winner. `installDevBuild`, `askDeviceAsync` and `installedApp/android` pass the serial; `androidDeviceNameAsync` stays for display only. +- Smoke's Android boot phase calls `acquireDeviceAsync`. `shutdownDeviceAsync` and smoke's `shutdownDevice` phase go, because every device is bound and that branch runs `emu kill` by serial (Contract 6). + +### Android deletions + +`EMULATOR_SERIAL`, `bootDeviceAsync`, `shutdownDeviceAsync`, smoke's `shutdownDevice` phase, `probeAndroidDeviceAsync`, `parseFirstAndroidDevice`, `NO_ANDROID_DEVICE`, the Android `installDevice`, the "until 3b" branches of §iOS binding, and the Android help lines that `git grep -i 'attached Android\|adb devices'` finds over `src/**/index.ts` and `runtime/stopAsync.ts`. + +### Android tests + +- A stub `emulator` that records argv, appends its serial to a state file beside the booted-state file, removes it on `SIGTERM`, and stays alive until killed. `installStubAdbAsync` lists those serials and answers `get-state` and `sys.boot_completed`. Tests kill the pids in their binding files in `afterEach`. Android device e2e skipped on win32. The dev e2e moves from `--device AVD_NAME` to the serial. A win32 lock test for the rename errors of §Lock. +- Unit, named: `spawned-booting-is-not-up`, `get-state-not-found-is-gone`, `busy-ports-include-alive-emulators`, `kill-checks-arguments-not-binary`, `let-go-runs-before-refusal`. + +### Live gate + +Runs on a Mac with two scratch worktrees of `apps/eas-example` on SDK 58, before 3a to 4 merge (Risk 1). It verifies five facts and measures one: + +1. A second `-read-only` instance boots beside the first; `adb reverse` and install work on it. +2. A human's writable instance beside ours, and ours beside a human's normal launch. +3. An emulator started on a taken port exits non-zero. +4. The spawned process stays alive for the whole life of the instance. If a launcher hands off, the binding must hold the qemu child's pid instead. +5. A cold `-read-only` boot plus the install fits `BOOT_DEVICE_TIMEOUT_MS`; else the Android timeout is raised in this PR. +6. The wall time of `dev --android` from a cold instance to the open app, because every instance start pays it (Decision 2). + +About 350 source lines. + +## Device lifecycle + +Release, park, reap, `--device`, and the lease that outlives a pause. PR 4 of §Delivery. Requires §Android instance. Merges as one unit with 3a and 3b. + +What happens when a worktree stops, pauses or disappears, and how does a person pick a device? + +### State after lifecycle + +- `dev:stop` keeps the worktree's devices; `dev:stop --release` parks the simulator and kills the emulator instance. +- A deleted worktree's simulator is deleted by the next reap anywhere; its emulator instance is killed. +- A worktree with Metro running keeps its device for hours without a verb. +- `--device` binds a named device, keeps it across `dev:stop` and the lease, and never shuts it down; `--device` over an own created simulator deletes that simulator and says so. +- `smoke` releases its device. +- `status --json` shows the binding; `dev:stop --json` shows what it kept or released. +- The restart follow-up `dev:stop && dev --detach` reuses the running instance, so it costs no boot and no install. + +### Release + +- `dev:stop`: stop the dev server as today (main signals the dev-lock holder and waits for it to exit); with `--release`, then release the local bindings when the server stopped or none ran (main's `devServerOk`); then run the reap once, whatever `devServerOk` is. Without `--release` the bindings stay and are reported as kept. `--release` is in `resolveStopOptions` and sets `DevStopOptions.release`; `platform?` is internal, with no CLI flag, so smoke releases its platform only. +- The foreground `dev` exit and the detached child touch no binding: the lease, renewed while Metro ran, expires on its own and the next reap parks or kills the device. + +### Lease timer + +`withLeaseExtendedAsync` of §Lease wraps the Metro runner (for `dev`, the detached child and `start`) and the step runner. + +### Lifecycle reap + +`reap.ts` per §Reap, called from `acquireDeviceAsync` and `dev:stop`. The `cloud` rows wait for §EAS session binding. + +### `--device` on `dev` + +In `DEV_OWN_FLAGS`; its value is read as `resolvePort` reads `--port`, `--device=` included; the flag and value are stripped from the Expo args as `withoutPortArgs` strips the port; `detachArgv` carries it unchanged; with `--eas` it is `BAD_ARGS`, checked where `assertEasRunFits` runs. The match and the refusals are in §Choice; the origin's rule is Contract 7. `dev --plan` naming a device bound to another worktree carries "will refuse: is bound to ". + +### Lifecycle smoke + +One release per device. A device the child bound is released by `devStopAsync`, which smoke's `stopDevServer` already calls, once the child has exited, through the internal `DevStopOptions` fields `release: true` and `platform`. A device smoke's own boot phase bound, after a reused dev server, is released by smoke's device cleanup hook with its `platform`. A `smoke --no-start` run binds nothing and releases nothing. + +### Lifecycle output + +`dev:stop` JSON `devices`, `status --json` `binding`, the reap events and the `cli:device_binding_reaped` reasons, per §Output and errors. + +### Live tier + +The live tier passes `--device $AGENT_CLI_LIVE_UDID` to `dev` or lets it create. The runbook in `e2e-live/README` gains the two-worktree setup. + +### Lifecycle tests + +- Unit, named: `extend-expired-is-lost`, `timer-picks-up-late-cloud-file`, `timer-resumes-revived-file`, `timer-warns-lost-once`, `deleted-worktree-deletes-simulator`, `reap-reads-after-write`, `dev-stop-keeps-without-release`, `foreground-exit-touches-no-binding`, `explicit-expires-keeps-file`, `device-over-own-created-deletes`, `smoke-releases-own-platform-only`, `dev-stop-reaps-once`, `explicit-bound-live-or-stale`, `explicit-never-shut-down`. +- E2E, kept verbatim from the rejected branch's device-claims suite: the one machine dir, `envFor()`, the `afterEach` `dev:stop --release` loop, the `dev:stop --release` JSON plus the exact `shutdown` call, and the deleted-worktree reap case, adapted to assert the `simctl delete` call. Dropped: `touchRace.ts`, `lockRace.ts`, `claimedDevice-test.ts` and the 10-field claim literal. + +About 500 source lines. + +## EAS session binding + +An EAS session belongs to the worktree that started it. PRs 5 and 6 of §Delivery. PR 5 requires §Device lifecycle; PR 6 requires PR 5. + +PR 5: how does a worktree keep to its own EAS session? PR 6: how does a session survive a lost registry? + +### State after EAS binding + +- PR 5: two worktrees get two sessions; `navigate --eas` uses its own; `dev:stop --eas` ends this worktree's sessions; a deleted worktree's `started` session is stopped from another worktree, a `dotenv` one only removed; `dev --eas` reaps too. +- PR 6: a lost registry recovers its session by tag; the live suite is green on a Mac with two worktrees. + +### PR 5 + +- `cloud.ts`; `acquireCloudBindingAsync` and the `sessionId` release of §Entry points; the cloud rows of the inspect and reap tables. +- `selectCloudSession(boundIds)` and the `unbound` result per §EAS, without the tag rung. Readers of `candidateCount` are checked. `ensureEasSessionAsync` returns the source of the selected id with its `sessionId`. +- `ownCloudIdsAsync(root)` serves every probe caller: the rung loop, `ensureEasSessionAsync`, smoke's reuse branch and `stopProjectEasSessionAsync`. +- Binding points. `openAppEas`, dev-wait and both of smoke's EAS branches call `acquireCloudBindingAsync` whenever `ensureEasSessionAsync` or a direct probe returns a session id. That includes a reuse of the bound id and a start that failed after it created the session, so a later reap can stop that session. dev-wait binds the session it finds and chooses no device. +- `dev --eas` binds; the foreground `dev --eas` stops its session at exit as today and then releases with `sessionId`. `dev --eas --detach` on a running server runs `openAppEas`, which reuses the bound session or starts one and binds it; this is the How line of the `gone` cloud refusal (§Readers), raised when the listing shows the bound id in no live state; a queued bound session is `not-up` and is never replaced by a new start. +- `--max-idle-time-minutes 30` on the session start, with the "Nonexistent flag" retry of §EAS, for the reason given there. `smoke --eas` binds in both its start and its reuse branch and, after its own stop succeeds, releases with `sessionId`. +- `dev:stop --eas` stops the probe's session, bound or dotenv, and then each other `cloud` binding's session of this worktree. It calls the `sessionId` release for each stop that succeeded. The JSON `session` reports the probe's id and `devices` the rest. A plain `dev:stop` keeps `cloud` bindings and reports each as "recorded, not checked" ([[0021-honest-reports]]), with `dev:stop --eas` as the next step. `dev:stop_session` gains `platform`. +- Three readers switch from the dotenv file to the `recorded` state of the own cloud binding, with no network call: dev-wait's cloud fallback, the start follow-ups, and `status`. +- Tests: the rejected branch's `stubEas.ts` and `stubs/eas.js` `STUB_SIM_STORE` with repeatable `--status`, paging dropped, and its e2e cases, including "plain `dev:stop` keeps it". Unit, named: `session-id-release-skips-stale-check`, `start-failure-with-session-binds`, `dotenv-removed-not-stopped`, `select-bound-only`, `cloud-expired-is-gone`, `unlisted-bound-id-is-gone`, `queued-bound-id-is-not-up`, `cloud-rung-writes-nothing`, `reap-stops-under-binding-root`, `idle-flag-retry-on-nonexistent-flag`. + +About 350 source lines. + +### PR 6 + +- `--tag agent-cli:` on the session start, with the same "Nonexistent flag" retry. Any other failure is reported as today, because a start can create a session and still exit non-zero. The event `dev:open_app_eas_flag_unsupported { flag }` records a retry. +- The tag field in `CloudSessionInfo`, named from a recorded `simulator:list --json` fixture that carries a tag. The tag match reads that one listing, with no second `eas` call. +- The runbook and a live test trimmed to the acceptance lists. `live-cloud` starts its session with `eas` directly, so the dotenv source stays and a reap never stops a `dotenv` session (Risk 7). +- Tests, named: `tag-matches-on-one-listing`, `tag-retry-only-on-nonexistent-flag`, with a stub version switch for the eas-cli skew (Risk 3). + +About 120 source lines. diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts new file mode 100644 index 00000000..47509994 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts @@ -0,0 +1,197 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests +import { vol } from 'memfs'; + +import { acquireDeviceAsync, releaseWorktreeDevicesAsync } from '..'; +import { bindingPathFor, readBindingFile } from '../registry'; +import { bindingFor, fakeTools, writeBinding } from './fakeTools'; + +const ROOT = '/work/app'; + +beforeEach(() => { + vol.reset(); + vol.fromJSON({ [`${ROOT}/package.json`]: '{}' }); +}); + +const file = () => bindingPathFor(ROOT, 'ios', 'local-ios'); + +describe(acquireDeviceAsync, () => { + it('creates a simulator on the newest iPhone runtime and binds its real udid', async () => { + const tools = fakeTools(); + + const result = await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(result).toMatchObject({ action: 'created', justBooted: true }); + expect(result.device).toMatchObject({ udid: 'CREATED-1', origin: 'created' }); + expect(tools.calls).toContainEqual([ + 'create', + expect.stringMatching(/^agent-cli [0-9a-f]{8}$/), + expect.stringContaining('iPhone'), + expect.stringContaining('iOS-26-0'), + ]); + const read = readBindingFile(file()); + expect(read.kind === 'binding' && read.binding.device).toMatchObject({ udid: 'CREATED-1' }); + expect(read.kind === 'binding' && read.binding.projectRoot).toBe(ROOT); + }); + + // bootstatus-b-not-boot + it('boots with bootstatus -b, never simctl boot', async () => { + const tools = fakeTools(); + + await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(tools.calls).toContainEqual(['bootstatus', 'CREATED-1', '-b']); + expect(tools.calls.some((call) => call[0] === 'boot')).toBe(false); + expect(tools.simulators[0]).toMatchObject({ udid: 'CREATED-1', state: 'Booted' }); + }); + + // boot-always-runs + it('boots on a reuse too, whatever the inventory said', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + + const result = await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(result).toMatchObject({ action: 'reused', justBooted: false }); + expect(tools.calls).toContainEqual(['bootstatus', 'SIM-1', '-b']); + }); + + it('reuses a parked simulator and renews the lease', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Shutdown' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1', { expiresAt: '2026-10-08T09:30:00.000Z' })); + + const result = await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(result).toMatchObject({ action: 'reused', justBooted: true }); + const read = readBindingFile(file()); + expect(read.kind === 'binding' && read.binding.expiresAt).toBe('2026-10-08T11:00:00.000Z'); + expect(read.kind === 'binding' && read.binding.boundAt).toBe('2026-10-08T10:00:00.000Z'); + }); + + // only-create-and-spawn-under-lock + it('runs simctl create under the lock and every other device call after it', async () => { + const tools = fakeTools(); + + await acquireDeviceAsync(ROOT, 'ios', { tools }); + + const locked = tools.calls.map((call, index) => [call[0], tools.lockedAt[index]]); + expect(locked).toEqual([ + ['list', false], + ['list', false], + ['create', true], + ['bootstatus', false], + ]); + }); + + // failed-boot-lets-go + it('lets go of the binding when the boot fails', async () => { + const tools = fakeTools({ bootFails: true }); + + const error = await acquireDeviceAsync(ROOT, 'ios', { tools }).catch((e) => e); + + expect(error.code).toBe('DEVICE_UNAVAILABLE'); + expect(error.data).toEqual({ reason: 'boot-failed' }); + const read = readBindingFile(file()); + expect(read.kind === 'binding' && read.binding.expiresAt).toBe('2026-10-08T10:00:00.000Z'); + }); + + // agent-cli-name-needs-binding + it('never adopts an agent-cli simulator no binding names', async () => { + const tools = fakeTools({ + simulators: [{ udid: 'LEAKED', name: 'agent-cli deadbeef', state: 'Shutdown' }], + }); + + const result = await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(result.device).toMatchObject({ udid: 'CREATED-1' }); + }); + + it('removes an own binding whose simulator is gone and creates again', async () => { + const tools = fakeTools(); + writeBinding(file(), bindingFor(ROOT, 'GONE')); + + const result = await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(result).toMatchObject({ action: 'created' }); + expect(result.device).toMatchObject({ udid: 'CREATED-1' }); + }); + + it('refuses not-reusable with reuseOnly and nothing to reuse', async () => { + const tools = fakeTools(); + + const error = await acquireDeviceAsync(ROOT, 'ios', { tools, reuseOnly: true }).catch((e) => e); + + expect(error.code).toBe('DEVICE_UNAVAILABLE'); + expect(error.data).toEqual({ reason: 'not-reusable' }); + expect(error.exitCode).toBe(20); + expect(tools.calls.some((call) => call[0] === 'create')).toBe(false); + }); + + it('refuses no-ios-runtime before any create', async () => { + const tools = fakeTools({ runtime: false }); + + const error = await acquireDeviceAsync(ROOT, 'ios', { tools }).catch((e) => e); + + expect(error.data).toEqual({ reason: 'no-ios-runtime' }); + expect(error.exitCode).toBe(7); + }); + + it('refuses an own file that does not parse', async () => { + const tools = fakeTools(); + vol.fromJSON({ [file()]: '{not json' }); + + const error = await acquireDeviceAsync(ROOT, 'ios', { tools }).catch((e) => e); + + expect(error.data).toEqual({ reason: 'unreadable' }); + expect(error.message).toContain(`rm -f '${file()}'`); + }); + + it('throws the tool error when xcrun cannot run', async () => { + const tools = fakeTools({ spawnError: true }); + + const error = await acquireDeviceAsync(ROOT, 'ios', { tools }).catch((e) => e); + + expect(error.code).toBe('XCRUN_NOT_RUNNABLE'); + }); +}); + +describe(releaseWorktreeDevicesAsync, () => { + it('parks a created simulator and shuts it down after the section', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + + const released = await releaseWorktreeDevicesAsync(ROOT, { tools }); + + expect(released).toEqual([ + { + backend: 'local-ios', + platform: 'ios', + id: 'SIM-1', + name: 'agent-cli 0000', + released: true, + shutDown: true, + reason: null, + }, + ]); + const shutdownAt = tools.calls.findIndex((call) => call[0] === 'shutdown'); + expect(tools.lockedAt[shutdownAt]).toBe(false); + const read = readBindingFile(file()); + expect(read.kind === 'binding' && read.binding.expiresAt).toBe('2026-10-08T10:00:00.000Z'); + }); + + it('removes the file of a simulator the inventory does not list', async () => { + const tools = fakeTools(); + writeBinding(file(), bindingFor(ROOT, 'GONE')); + + await releaseWorktreeDevicesAsync(ROOT, { tools }); + + expect(readBindingFile(file())).toEqual({ kind: 'none' }); + expect(tools.calls.some((call) => call[0] === 'shutdown')).toBe(false); + }); + + it('does nothing for a worktree with no binding', async () => { + const tools = fakeTools(); + + expect(await releaseWorktreeDevicesAsync(ROOT, { tools })).toEqual([]); + expect(tools.calls).toEqual([]); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts new file mode 100644 index 00000000..e0a42029 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts @@ -0,0 +1,58 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Contracts every verb honors +import fs from 'fs'; +import path from 'path'; + +// This suite reads the repository, which the suite-wide memfs mock has none of. +vi.unmock('fs'); +vi.unmock('node:fs'); + +const MODULE_DIR = path.resolve(__dirname, '..'); +const FORBIDDEN = ['navigate', 'smoke', 'dev', 'installedApp']; +const MAX_FILE_LINES = 250; +const MAX_FUNCTION_LINES = 60; + +const sources = fs + .readdirSync(MODULE_DIR) + .filter((name) => name.endsWith('.ts')) + .map((name) => ({ name, text: fs.readFileSync(path.join(MODULE_DIR, name), 'utf8') })); + +describe('src/deviceBinding/', () => { + // deviceBinding-imports-nothing-from-verbs (Contract 10) + it('imports nothing from navigate, smoke, dev or installedApp', () => { + const offending = sources.flatMap(({ name, text }) => + [...text.matchAll(/from '([^']+)'/g)] + .map((match) => match[1]!) + .filter((spec) => + FORBIDDEN.some((verb) => spec.includes(`/${verb}/`) || spec.endsWith(`/${verb}`)) + ) + .map((spec) => `${name}: ${spec}`) + ); + expect(offending).toEqual([]); + }); + + it(`keeps every file under ${MAX_FILE_LINES} lines`, () => { + const over = sources + .map(({ name, text }) => ({ name, lines: text.split('\n').length })) + .filter(({ lines }) => lines >= MAX_FILE_LINES); + expect(over).toEqual([]); + }); + + it(`keeps every function under ${MAX_FUNCTION_LINES} lines`, () => { + const over: string[] = []; + for (const { name, text } of sources) { + const lines = text.split('\n'); + lines.forEach((line, index) => { + const match = /^(?:export )?(?:async )?function (\w+)/.exec(line); + if (!match) { + return; + } + const end = lines.findIndex((candidate, at) => at > index && candidate === '}'); + const length = (end === -1 ? lines.length : end) - index + 1; + if (length > MAX_FUNCTION_LINES) { + over.push(`${name}: ${match[1]} (${length} lines)`); + } + }); + } + expect(over).toEqual([]); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts new file mode 100644 index 00000000..8a4035c3 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts @@ -0,0 +1,70 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Output and errors +import { deviceUnavailableError, noBoundDeviceError, registryLockedError } from '../errors'; + +// exit-codes-per-reason +describe('exit codes per reason', () => { + it.each([ + ['NO_BOUND_DEVICE', 'none', 20, noBoundDeviceError('none', { platform: 'ios' })], + [ + 'NO_BOUND_DEVICE', + 'gone', + 20, + noBoundDeviceError('gone', { platform: 'ios', cause: 'expired' }), + ], + ['NO_BOUND_DEVICE', 'not-up', 20, noBoundDeviceError('not-up', { platform: 'ios' })], + ['NO_BOUND_DEVICE', 'unknown', 22, noBoundDeviceError('unknown', { platform: 'ios' })], + [ + 'NO_BOUND_DEVICE', + 'unreadable', + 7, + noBoundDeviceError('unreadable', { platform: 'ios', path: '/p' }), + ], + [ + 'DEVICE_UNAVAILABLE', + 'unreadable', + 7, + deviceUnavailableError('unreadable', { platform: 'ios', path: '/p' }), + ], + [ + 'DEVICE_UNAVAILABLE', + 'no-ios-runtime', + 7, + deviceUnavailableError('no-ios-runtime', { platform: 'ios' }), + ], + [ + 'DEVICE_UNAVAILABLE', + 'not-reusable', + 20, + deviceUnavailableError('not-reusable', { platform: 'ios' }), + ], + [ + 'DEVICE_UNAVAILABLE', + 'create-timeout', + 22, + deviceUnavailableError('create-timeout', { platform: 'ios' }), + ], + [ + 'DEVICE_UNAVAILABLE', + 'boot-failed', + 20, + deviceUnavailableError('boot-failed', { platform: 'ios' }), + ], + [ + 'DEVICE_REGISTRY_LOCKED', + 'locked', + 22, + registryLockedError({ pid: 7, ageMs: 4_000, command: 'node' }), + ], + ])('%s %s exits %i', (code, reason, exitCode, error) => { + expect(error.code).toBe(code); + expect(error.data).toEqual({ reason }); + expect(error.exitCode).toBe(exitCode); + expect(error.message.split('\n').at(-1)).toMatch(/^How: /); + }); + + it('names the dev command as the How line of a read refusal', () => { + const error = noBoundDeviceError('none', { platform: 'android' }); + expect(error.message).toContain('npx @expo/agent-cli dev --android --detach --wait-ready'); + expect(error.suggestedCommand).toBe('npx @expo/agent-cli dev --android --detach --wait-ready'); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts new file mode 100644 index 00000000..d304eee1 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts @@ -0,0 +1,159 @@ +// A `simctl` that keeps simulator state the way the real one does, so a test reads calls and state +// rather than mocking one function per call. + +import fs from 'fs'; +import path from 'path'; + +import type { SpawnCaptureResult } from '../../utils/spawnCapture'; +import { registryDirectory } from '../registry'; +import type { Binding, DeviceTools } from '../types'; + +export const RUNTIME = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0'; +export const DEVICE_TYPE = 'com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro'; + +export interface FakeSimulator { + udid: string; + name: string; + state: 'Booted' | 'Shutdown'; +} + +export interface FakeSimctlOptions { + simulators?: FakeSimulator[]; + /** Whether `list runtimes` names an iPhone runtime. */ + runtime?: boolean; + /** `bootstatus -b` leaves the simulator down and exits non-zero. */ + bootFails?: boolean; + /** Every call fails to spawn. */ + spawnError?: boolean; + /** Every `list devices` times out. */ + listTimesOut?: boolean; +} + +export interface FakeTools extends DeviceTools { + calls: string[][]; + simulators: FakeSimulator[]; + /** Whether the registry lock existed when each call ran, by call index. */ + lockedAt: boolean[]; + clock: { now: Date }; +} + +export function fakeTools({ + simulators = [], + runtime = true, + bootFails = false, + spawnError = false, + listTimesOut = false, +}: FakeSimctlOptions = {}): FakeTools { + const state = simulators.map((simulator) => ({ ...simulator })); + const calls: string[][] = []; + const lockedAt: boolean[] = []; + const clock = { now: new Date('2026-10-08T10:00:00.000Z') }; + let created = 0; + const ok = (stdout = ''): SpawnCaptureResult => ({ stdout, stderr: '', exitCode: 0 }); + + const simctl: DeviceTools['simctl'] = async (args) => { + calls.push(args); + lockedAt.push(fs.existsSync(path.join(registryDirectory(), '.lock'))); + if (spawnError) { + return { + stdout: '', + stderr: '', + exitCode: null, + spawnError: Object.assign(new Error('spawn xcrun ENOENT'), { code: 'ENOENT' }), + }; + } + const [command, ...rest] = args; + if (command === 'list' && rest[0] === 'runtimes') { + return ok( + JSON.stringify({ + runtimes: runtime + ? [ + { + identifier: RUNTIME, + platform: 'iOS', + version: '26.0', + isAvailable: true, + supportedDeviceTypes: [{ identifier: DEVICE_TYPE, productFamily: 'iPhone' }], + }, + ] + : [], + }) + ); + } + if (command === 'list') { + if (listTimesOut) { + return { stdout: '', stderr: '', exitCode: null }; + } + return ok(JSON.stringify({ devices: { [RUNTIME]: state } })); + } + if (command === 'create') { + created += 1; + const udid = `CREATED-${created}`; + state.push({ udid, name: rest[0]!, state: 'Shutdown' }); + return ok(`${udid}\n`); + } + const simulator = state.find((entry) => entry.udid === rest[0]); + if (!simulator) { + return { stdout: '', stderr: `Invalid device: ${rest[0]}\n`, exitCode: 148 }; + } + if (command === 'bootstatus') { + if (bootFails) { + return { stdout: '', stderr: 'boot failed\n', exitCode: 1 }; + } + if (rest.includes('-b')) { + simulator.state = 'Booted'; + } + return ok(); + } + if (command === 'shutdown') { + if (simulator.state === 'Shutdown') { + return { + stdout: '', + stderr: 'Unable to shutdown device in current state: Shutdown\n', + exitCode: 149, + }; + } + simulator.state = 'Shutdown'; + return ok(); + } + if (command === 'boot') { + simulator.state = 'Booted'; + return ok(); + } + return { stdout: '', stderr: `unexpected ${args.join(' ')}\n`, exitCode: 2 }; + }; + + return { + simctl, + now: () => clock.now, + isPidAlive: () => true, + calls, + simulators: state, + lockedAt, + clock, + }; +} + +/** A binding an hour from expiry, as `acquire` writes one. */ +export function bindingFor( + projectRoot: string, + udid: string, + { + expiresAt, + boundAt, + name = 'agent-cli 0000', + }: { expiresAt?: string; boundAt?: string; name?: string } = {} +): Binding { + return { + version: 1, + device: { backend: 'local-ios', platform: 'ios', udid, name, origin: 'created' }, + projectRoot, + boundAt: boundAt ?? '2026-10-08T09:00:00.000Z', + expiresAt: expiresAt ?? '2026-10-08T11:00:00.000Z', + }; +} + +export function writeBinding(file: string, binding: Binding): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(binding)); +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts new file mode 100644 index 00000000..dcd4cf35 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts @@ -0,0 +1,112 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests +import { vol } from 'memfs'; + +import { inspectBindingAsync, useBoundDeviceAsync } from '../inspect'; +import { bindingPathFor, readBindingFile } from '../registry'; +import { bindingFor, fakeTools, writeBinding } from './fakeTools'; + +const ROOT = '/work/app'; +const file = () => bindingPathFor(ROOT, 'ios', 'local-ios'); + +beforeEach(() => { + vol.reset(); + vol.fromJSON({ [`${ROOT}/package.json`]: '{}' }); +}); + +describe(inspectBindingAsync, () => { + // inspect-state-order: the first row that matches wins, in the order of the table. + it.each([ + ['none', {}, { state: 'none' }], + ['unreadable', { text: '{' }, { state: 'unreadable' }], + [ + 'unknown from the tool', + { bind: 'SIM-1', spawnError: true }, + { state: 'unknown', cause: 'tool' }, + ], + [ + 'unknown from a timeout', + { bind: 'SIM-1', listTimesOut: true }, + { state: 'unknown', cause: 'timeout' }, + ], + [ + 'gone when expired, even with the device up', + { bind: 'SIM-1', expired: true, listed: 'Booted' }, + { state: 'gone', cause: 'expired' }, + ], + ['up', { bind: 'SIM-1', listed: 'Booted' }, { state: 'up' }], + ['not-up', { bind: 'SIM-1', listed: 'Shutdown' }, { state: 'not-up' }], + ['gone when not listed', { bind: 'SIM-1' }, { state: 'gone', cause: 'device-gone' }], + ] as const)('reports %s', async (_name, setup, expected) => { + const tools = fakeTools({ + simulators: 'listed' in setup ? [{ udid: 'SIM-1', name: 'x', state: setup.listed }] : [], + spawnError: 'spawnError' in setup, + listTimesOut: 'listTimesOut' in setup, + }); + if ('text' in setup) { + vol.fromJSON({ [file()]: setup.text }); + } else if ('bind' in setup) { + writeBinding( + file(), + bindingFor(ROOT, setup.bind, { + expiresAt: 'expired' in setup ? '2026-10-08T09:59:59.000Z' : undefined, + }) + ); + } + + expect(await inspectBindingAsync(ROOT, 'ios', 'local-ios', tools)).toMatchObject(expected); + }); + + // read-verb-one-subprocess-per-binding + it('spawns one subprocess for a binding and none without one', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + + await inspectBindingAsync(ROOT, 'ios', 'local-ios', tools); + expect(tools.calls).toEqual([]); + + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + await inspectBindingAsync(ROOT, 'ios', 'local-ios', tools); + expect(tools.calls).toEqual([['list', 'devices', '-j']]); + }); + + it('reports a cloud binding as recorded without a subprocess', async () => { + const tools = fakeTools(); + writeBinding(bindingPathFor(ROOT, 'ios', 'cloud'), { + ...bindingFor(ROOT, 'unused'), + device: { backend: 'cloud', platform: 'ios', id: 'session-1', origin: 'started' }, + }); + + expect(await inspectBindingAsync(ROOT, 'ios', 'cloud', tools)).toMatchObject({ + state: 'recorded', + }); + expect(tools.calls).toEqual([]); + }); +}); + +describe(useBoundDeviceAsync, () => { + it('extends a live lease and returns the device', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1', { expiresAt: '2026-10-08T10:30:00.000Z' })); + const inspection = await inspectBindingAsync(ROOT, 'ios', 'local-ios', tools); + + const device = await useBoundDeviceAsync(inspection, tools); + + expect(device).toMatchObject({ udid: 'SIM-1' }); + const read = readBindingFile(file()); + expect(read.kind === 'binding' && read.binding.expiresAt).toBe('2026-10-08T11:00:00.000Z'); + }); + + // expired-is-gone + it('throws gone when the lease expired since the inspect', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1', { expiresAt: '2026-10-08T10:00:01.000Z' })); + const inspection = await inspectBindingAsync(ROOT, 'ios', 'local-ios', tools); + tools.clock.now = new Date('2026-10-08T10:00:02.000Z'); + + const error = await useBoundDeviceAsync(inspection, tools).catch((e) => e); + + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.data).toEqual({ reason: 'gone' }); + expect(error.message).toContain('lease'); + expect(tools.calls).toEqual([['list', 'devices', '-j']]); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/registry-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/registry-test.ts new file mode 100644 index 00000000..8718947c --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/registry-test.ts @@ -0,0 +1,158 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Lock +import fs from 'fs'; +import path from 'path'; + +import { + readBindingFile, + registryDirectory, + withRegistryLockAsync, + writeBindingFile, +} from '../registry'; +import { bindingFor } from './fakeTools'; + +// The lock is a directory rename, which memfs lets replace a non-empty directory and the kernel +// refuses, so this suite runs on the real disk. +vi.unmock('fs'); +vi.unmock('node:fs'); +const os = await vi.importActual('os'); + +const ROOT = '/work/app'; +const lock = () => path.join(registryDirectory(), '.lock'); +const alive = { isPidAlive: () => true }; +const dead = { isPidAlive: () => false }; + +let home: string; +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-registry-')); + process.env.__UNSAFE_EXPO_HOME_DIRECTORY = home; +}); +afterEach(() => { + delete process.env.__UNSAFE_EXPO_HOME_DIRECTORY; + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe(withRegistryLockAsync, () => { + it('holds a lock that names this pid while work runs, and gives it up after', async () => { + let markers: string[] = []; + + await withRegistryLockAsync( + async () => { + markers = fs.readdirSync(lock()); + }, + { waitMs: 100, tools: alive } + ); + + expect(markers).toEqual([expect.stringMatching(new RegExp(`^pid-${process.pid}-[0-9a-f]+$`))]); + expect(fs.existsSync(lock())).toBe(false); + expect(fs.readdirSync(registryDirectory())).toEqual([]); + }); + + it('waits for a live holder and refuses DEVICE_REGISTRY_LOCKED when the wait runs out', async () => { + fs.mkdirSync(lock(), { recursive: true }); + fs.writeFileSync(path.join(lock(), 'pid-424242-abcd'), ''); + + const error = await withRegistryLockAsync(async () => 'ran', { + waitMs: 250, + tools: alive, + }).catch((e) => e); + + expect(error.code).toBe('DEVICE_REGISTRY_LOCKED'); + expect(error.exitCode).toBe(22); + expect(error.message).toContain('pid 424242'); + expect(fs.existsSync(path.join(lock(), 'pid-424242-abcd'))).toBe(true); + }); + + it("removes a dead holder's lock and takes it", async () => { + fs.mkdirSync(lock(), { recursive: true }); + fs.writeFileSync(path.join(lock(), 'pid-424242-abcd'), ''); + + const result = await withRegistryLockAsync(async () => 'ran', { waitMs: 1_000, tools: dead }); + + expect(result).toBe('ran'); + expect(fs.existsSync(lock())).toBe(false); + }); + + it('removes a lock with no marker', async () => { + fs.mkdirSync(lock(), { recursive: true }); + + const result = await withRegistryLockAsync(async () => 'ran', { waitMs: 1_000, tools: alive }); + + expect(result).toBe('ran'); + }); + + it('serializes two sections of one process', async () => { + const order: string[] = []; + const first = withRegistryLockAsync( + async () => { + order.push('first-in'); + await new Promise((resolve) => setTimeout(resolve, 150)); + order.push('first-out'); + }, + { waitMs: 1_000, tools: alive } + ); + const second = withRegistryLockAsync( + async () => { + order.push('second-in'); + }, + { waitMs: 1_000, tools: alive } + ); + + await Promise.all([first, second]); + + expect(order).toEqual(['first-in', 'first-out', 'second-in']); + }); + + it('gives the lock up when work throws', async () => { + await expect( + withRegistryLockAsync( + async () => { + throw new Error('boom'); + }, + { waitMs: 100, tools: alive } + ) + ).rejects.toThrow('boom'); + + expect(fs.existsSync(lock())).toBe(false); + }); +}); + +describe(readBindingFile, () => { + const file = () => path.join(registryDirectory(), 'x-ios-local-ios.json'); + + it('round-trips a binding through tmp+rename', () => { + const binding = bindingFor(ROOT, 'SIM-1'); + + writeBindingFile(file(), binding); + + expect(readBindingFile(file())).toEqual({ kind: 'binding', binding }); + expect(fs.readdirSync(registryDirectory())).toEqual(['x-ios-local-ios.json']); + }); + + it('reports none for a missing file and unreadable for one that does not parse', () => { + expect(readBindingFile(file())).toEqual({ kind: 'none' }); + fs.mkdirSync(registryDirectory(), { recursive: true }); + + fs.writeFileSync(file(), 'nope'); + expect(readBindingFile(file())).toEqual({ kind: 'unreadable' }); + }); + + it.each([ + ['an unknown version', { ...bindingFor(ROOT, 'SIM-1'), version: 2 }], + [ + 'an unknown backend', + { ...bindingFor(ROOT, 'SIM-1'), device: { backend: 'local-tvos', platform: 'ios' } }, + ], + [ + 'an unknown origin', + { + ...bindingFor(ROOT, 'SIM-1'), + device: { ...bindingFor(ROOT, 'SIM-1').device, origin: 'adopted' }, + }, + ], + ])('reports unreadable for %s', (_name, record) => { + fs.mkdirSync(registryDirectory(), { recursive: true }); + fs.writeFileSync(file(), JSON.stringify(record)); + + expect(readBindingFile(file())).toEqual({ kind: 'unreadable' }); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/rungs-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/rungs-test.ts new file mode 100644 index 00000000..a572f6af --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/rungs-test.ts @@ -0,0 +1,156 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests +import { vol } from 'memfs'; + +import { CommandError } from '../../utils/errors'; +import { bindingPathFor } from '../registry'; +import { findBoundDeviceAsync, type AndroidRung } from '../rungs'; +import type { BoundDevice } from '../types'; +import { bindingFor, fakeTools, writeBinding } from './fakeTools'; + +const ROOT = '/work/app'; +const file = () => bindingPathFor(ROOT, 'ios', 'local-ios'); + +const EMULATOR: BoundDevice = { + backend: 'local-android', + platform: 'android', + serial: 'emulator-5554', + origin: { kind: 'explicit' }, +}; +const androidUp: AndroidRung = async () => ({ device: EMULATOR }); +const androidNone: AndroidRung = async () => ({ device: null }); +const adbBroken = new CommandError('ADB_NOT_RUNNABLE', 'adb could not be run'); +const androidBroken: AndroidRung = async () => ({ device: null, toolError: adbBroken }); + +beforeEach(() => { + vol.reset(); + vol.fromJSON({ [`${ROOT}/package.json`]: '{}' }); +}); + +describe(findBoundDeviceAsync, () => { + // any-up-wins-across-platforms + it('takes any up binding without a platform flag, the newer boundAt first', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + + const ios = await findBoundDeviceAsync(ROOT, { + extend: false, + tools, + android: androidUp, + hostPlatform: 'darwin', + }); + expect(ios.device).toMatchObject({ udid: 'SIM-1' }); + + tools.simulators[0]!.state = 'Shutdown'; + const android = await findBoundDeviceAsync(ROOT, { + extend: false, + tools, + android: androidUp, + hostPlatform: 'darwin', + }); + expect(android.device).toBe(EMULATOR); + }); + + it('inspects only the named platform with a flag', async () => { + const tools = fakeTools(); + const android = vi.fn(androidUp); + + const found = await findBoundDeviceAsync(ROOT, { + platform: 'android', + extend: true, + tools, + android, + }); + + expect(found.device).toBe(EMULATOR); + expect(tools.calls).toEqual([]); + }); + + it('extends the winner once', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Booted' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1', { expiresAt: '2026-10-08T10:30:00.000Z' })); + + await findBoundDeviceAsync(ROOT, { platform: 'ios', extend: true, tools }); + + expect(tools.calls).toEqual([['list', 'devices', '-j']]); + const written = JSON.parse(vol.readFileSync(file(), 'utf8') as string); + expect(written.expiresAt).toBe('2026-10-08T11:00:00.000Z'); + }); + + // rung-refusal-reports-first-state + it('refuses with the first state that is not none, in iOS then Android order', async () => { + const tools = fakeTools(); + writeBinding(file(), bindingFor(ROOT, 'GONE')); + + const fell = await findBoundDeviceAsync(ROOT, { + extend: false, + tools, + android: androidNone, + hostPlatform: 'darwin', + }); + + expect(fell.device).toBeNull(); + expect(fell.refusal?.data).toEqual({ reason: 'gone' }); + expect(fell.refusal?.message).toContain('iOS'.toLowerCase()); + expect(fell.toolError).toBeNull(); + }); + + it('refuses not-up at once, naming the platform', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM-1', name: 'x', state: 'Shutdown' }] }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + + const error = await findBoundDeviceAsync(ROOT, { + extend: false, + tools, + android: androidNone, + hostPlatform: 'darwin', + }).catch((e) => e); + + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.data).toEqual({ reason: 'not-up' }); + expect(error.suggestedCommand).toBe('npx @expo/agent-cli dev --ios --detach --wait-ready'); + }); + + it('names the host platform when every state is none', async () => { + const tools = fakeTools(); + + const mac = await findBoundDeviceAsync(ROOT, { extend: false, tools, hostPlatform: 'darwin' }); + const linux = await findBoundDeviceAsync(ROOT, { extend: false, tools, hostPlatform: 'linux' }); + + expect(mac.refusal?.message).toContain('No ios device is bound'); + expect(linux.refusal?.message).toContain('No android device is bound'); + }); + + // platform-flag-throws-tool-error-first + it('throws the tool error at once with a platform flag, and after the loop without one', async () => { + const tools = fakeTools({ spawnError: true }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + + const flagged = await findBoundDeviceAsync(ROOT, { + platform: 'ios', + extend: false, + tools, + }).catch((e) => e); + expect(flagged.code).toBe('XCRUN_NOT_RUNNABLE'); + + const unflagged = await findBoundDeviceAsync(ROOT, { + extend: false, + tools, + android: androidBroken, + hostPlatform: 'darwin', + }); + expect(unflagged.device).toBeNull(); + expect(unflagged.toolError?.code).toBe('XCRUN_NOT_RUNNABLE'); + }); + + it('refuses the timed-out check with exit 22', async () => { + const tools = fakeTools({ listTimesOut: true }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1')); + + const error = await findBoundDeviceAsync(ROOT, { platform: 'ios', extend: false, tools }).catch( + (e) => e + ); + + expect(error.data).toEqual({ reason: 'unknown' }); + expect(error.exitCode).toBe(22); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/choose.ts b/packages/@expo/agent-cli/src/deviceBinding/choose.ts new file mode 100644 index 00000000..d704877a --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/choose.ts @@ -0,0 +1,58 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Choice +// Which device a worktree gets. Pure: the inventory and the bindings come in, a choice comes out. + +import type { SimulatorEntry } from '../device/simulators'; +import type { Binding } from './types'; + +export interface IosInventory { + simulators: SimulatorEntry[]; + /** The highest available iOS runtime that lists an iPhone, and its first iPhone device type. */ + newestIosRuntime: { identifier: string; deviceType: string } | null; +} + +export type IosChoice = + | { kind: 'reuse'; binding: Binding; listed: SimulatorEntry } + | { kind: 'ios-create'; runtime: string; deviceType: string; name: string } + | { kind: 'refuse'; reason: 'no-ios-runtime' | 'not-reusable' }; + +/** The name of a created simulator, so a leaked one shows whose it was. Never identity. */ +export function createdSimulatorName(digest: string): string { + return `agent-cli ${digest.slice(0, 8)}`; +} + +/** + * Choose the simulator of a worktree. + * + * The own binding is reused, live or expired, when the inventory lists its simulator. An own + * binding whose simulator is gone is for the caller to remove; the choice goes on to a create. + */ +export function chooseIosDevice({ + own, + inventory, + reuseOnly, + digest, +}: { + own: Binding | null; + inventory: IosInventory; + reuseOnly: boolean; + digest: string; +}): IosChoice { + const ownUdid = own?.device.backend === 'local-ios' ? own.device.udid : null; + const listed = inventory.simulators.find((entry) => entry.udid === ownUdid); + if (own != null && listed != null) { + return { kind: 'reuse', binding: own, listed }; + } + if (reuseOnly) { + return { kind: 'refuse', reason: 'not-reusable' }; + } + const runtime = inventory.newestIosRuntime; + if (runtime == null) { + return { kind: 'refuse', reason: 'no-ios-runtime' }; + } + return { + kind: 'ios-create', + runtime: runtime.identifier, + deviceType: runtime.deviceType, + name: createdSimulatorName(digest), + }; +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/errors.ts b/packages/@expo/agent-cli/src/deviceBinding/errors.ts new file mode 100644 index 00000000..b06e79d3 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/errors.ts @@ -0,0 +1,120 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Output and errors +// Every refusal of the device registry, with its exit code and its How line. + +import { EXIT_NEEDS_HUMAN, EXIT_OUTCOME_FAILED, EXIT_OUTCOME_TIMEOUT } from '../exitCodes'; +import { PROGRAM_PREFIX } from '../programName'; +import { CommandError } from '../utils/errors'; +import type { DevicePlatform, InspectCause, InspectState } from './types'; + +/** The one command that binds a device to this worktree. */ +export function deviceCommand(platform: DevicePlatform): string { + return `${PROGRAM_PREFIX} dev --${platform} --detach --wait-ready`; +} + +export type UnavailableReason = + | 'unreadable' + | 'no-ios-runtime' + | 'not-reusable' + | 'create-timeout' + | 'boot-failed'; + +const RERUN = 'run the command again'; + +/** The refusal of a read verb: no device is bound, or the bound one cannot be driven now. */ +export function noBoundDeviceError( + state: Exclude, + { platform, cause, path }: { platform: DevicePlatform; cause?: InspectCause; path?: string } +): CommandError { + const command = deviceCommand(platform); + if (state === 'unreadable') { + return refusal('NO_BOUND_DEVICE', 'unreadable', EXIT_NEEDS_HUMAN, [ + `The binding file ${path} does not parse; a newer CLI may have written it.`, + `How: run "rm -f '${path}'", then "${command}".`, + ]); + } + if (state === 'unknown') { + return refusal('NO_BOUND_DEVICE', 'unknown', EXIT_OUTCOME_TIMEOUT, [ + `The ${platform} device check timed out, so nothing is known about the bound device.`, + `How: ${RERUN}.`, + ]); + } + const what = + state === 'none' + ? `No ${platform} device is bound to this worktree.` + : state === 'not-up' + ? `The bound ${platform} device is not up.` + : cause === 'expired' + ? `The lease on the bound ${platform} device expired.` + : `The bound ${platform} device is gone.`; + const error = refusal('NO_BOUND_DEVICE', state, EXIT_OUTCOME_FAILED, [ + what, + `Why: every verb drives the device "${PROGRAM_PREFIX} dev" bound to this worktree, and never the first booted one, so two worktrees never share a device.`, + `How: run "${command}", then this command again.`, + ]); + error.suggestedCommand = command; + return error; +} + +/** The refusal of `dev` or `smoke`: no device could be bound. */ +export function deviceUnavailableError( + reason: UnavailableReason, + { platform, path, detail }: { platform: DevicePlatform; path?: string; detail?: string } +): CommandError { + switch (reason) { + case 'unreadable': + return refusal('DEVICE_UNAVAILABLE', reason, EXIT_NEEDS_HUMAN, [ + `The binding file ${path} does not parse; a newer CLI may have written it.`, + `How: run "rm -f '${path}'", then this command again.`, + ]); + case 'no-ios-runtime': + return refusal('DEVICE_UNAVAILABLE', reason, EXIT_NEEDS_HUMAN, [ + 'No iOS runtime with an iPhone is installed, so no simulator can be created.', + 'How: run "xcodebuild -downloadPlatform iOS", then this command again.', + ]); + case 'not-reusable': { + const error = refusal('DEVICE_UNAVAILABLE', reason, EXIT_OUTCOME_FAILED, [ + `A dev server is running and this worktree has no ${platform} device to reuse.`, + 'Why: a device created now would carry no app, because the running server already installed nothing on it.', + `How: run "${PROGRAM_PREFIX} dev:stop", then this command again.`, + ]); + error.suggestedCommand = `${PROGRAM_PREFIX} dev:stop`; + return error; + } + case 'create-timeout': + return refusal('DEVICE_UNAVAILABLE', reason, EXIT_OUTCOME_TIMEOUT, [ + 'The device tool did not answer in time.', + `How: ${RERUN}.`, + ]); + case 'boot-failed': + return refusal('DEVICE_UNAVAILABLE', reason, EXIT_OUTCOME_FAILED, [ + `The bound ${platform} device did not boot${detail ? `: ${detail}` : '.'}`, + `How: ${RERUN}.`, + ]); + } +} + +/** The refusal of a write that could not take the registry lock in time. */ +export function registryLockedError({ + pid, + ageMs, + command, +}: { + pid: number | null; + ageMs: number | null; + command: string | null; +}): CommandError { + const holder = pid == null ? 'another process' : `pid ${pid}${command ? ` (${command})` : ''}`; + const age = ageMs == null ? 'a while' : `${Math.round(ageMs / 1000)} s`; + const error = refusal('DEVICE_REGISTRY_LOCKED', 'locked', EXIT_OUTCOME_TIMEOUT, [ + `${holder} has held the device registry for ${age}.`, + `How: ${RERUN}${pid == null ? '' : `, or "kill ${pid}" when it repeats`}.`, + ]); + return error; +} + +function refusal(code: string, reason: string, exitCode: number, lines: string[]): CommandError { + const error = new CommandError(code, lines.join('\n')); + error.exitCode = exitCode; + error.data = { reason }; + return error; +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/events.ts b/packages/@expo/agent-cli/src/deviceBinding/events.ts new file mode 100644 index 00000000..1b9fbb3f --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/events.ts @@ -0,0 +1,14 @@ +import { events } from '2g'; + +declare module '2g' { + interface EventRegistry { + // @ref llp/0035-one-device-per-worktree.rfc.md §Output and errors + /** A registry lock whose holder is dead was removed by a waiter. */ + 'cli:device_registry_lock_removed': { lock: string; pid: number }; + /** A stale binding of another worktree was let go of. */ + 'cli:device_binding_reaped': { reason: string }; + } +} + +export const event = events('cli'); +export const debugEvent = events.debug('cli'); diff --git a/packages/@expo/agent-cli/src/deviceBinding/index.ts b/packages/@expo/agent-cli/src/deviceBinding/index.ts new file mode 100644 index 00000000..0d63db1a --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/index.ts @@ -0,0 +1,180 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Entry points +// Each worktree gets its own device. `dev` and `smoke` acquire; every other verb reads. + +import { BOOT_DEVICE_TIMEOUT_MS } from '../device/bootDevice'; +import { canonicalizeExistingPath } from '../utils/dir'; +import { chooseIosDevice, type IosInventory } from './choose'; +import { deviceUnavailableError } from './errors'; +import { clearInspectCache } from './inspect'; +import { + bootSimulatorAsync, + createSimulatorAsync, + listSimulatorAsync, + readIosInventoryAsync, + shutdownSimulatorAsync, +} from './ios'; +import { leaseFrom } from './lease'; +import { + bindingPathFor, + digestForRoot, + readBindingFile, + removeBindingFile, + withRegistryLockAsync, + writeBindingFile, +} from './registry'; +import { defaultTools } from './tools'; +import type { AcquireAction, AcquireResult, Binding, DeviceTools, ReleasedDevice } from './types'; + +export { deviceCommand, noBoundDeviceError, deviceUnavailableError } from './errors'; +export { + clearInspectCache, + inspectBindingAsync, + inspectBindingCachedAsync, + useBoundDeviceAsync, +} from './inspect'; +export { findBoundDeviceAsync, type AndroidRung, type BoundDeviceSearch } from './rungs'; +export { defaultTools } from './tools'; +export type * from './types'; + +/** The waits of `acquire` and release; read verbs wait less (`./inspect.ts`). */ +export const WRITE_LOCK_WAIT_MS = 30_000; + +/** `AGENT_CLI_NO_DEVICE` turns every device off for a harness whose machines must not be touched. */ +export function devicesDisabled(): boolean { + return process.env.AGENT_CLI_NO_DEVICE === '1'; +} + +export interface AcquireDeviceOptions { + /** Any choice but a reuse is refused `not-reusable`. */ + reuseOnly?: boolean; + tools?: DeviceTools; +} + +/** + * Bind this worktree's simulator and boot it. + * + * The inventory is read before the lock. Under the lock, in one section: the own binding is + * read, the choice made and written, with `simctl create` inside the section. After it, the boot + * runs on every choice, whatever the inventory said. A failed boot lets go of the binding. + * + * @throws `DEVICE_UNAVAILABLE` with `data.reason`, `DEVICE_REGISTRY_LOCKED`, or the tool error. + */ +export async function acquireDeviceAsync( + projectRoot: string, + platform: 'ios', + { reuseOnly = false, tools = defaultTools() }: AcquireDeviceOptions = {} +): Promise { + const inventory = await readIosInventoryAsync(tools); + const { binding, wasBooted, action } = await withRegistryLockAsync( + () => bindIosSectionAsync(projectRoot, { inventory, reuseOnly, tools }), + { waitMs: WRITE_LOCK_WAIT_MS, tools } + ); + clearInspectCache(); + + const device = binding.device as Extract; + const boot = await bootSimulatorAsync(tools, device.udid, { + timeoutMs: BOOT_DEVICE_TIMEOUT_MS.ios, + }); + if (!boot.ok) { + await releaseWorktreeDevicesAsync(projectRoot, { platform, tools }); + throw deviceUnavailableError('boot-failed', { platform, detail: boot.reason ?? undefined }); + } + return { device, justBooted: !wasBooted, action }; +} + +/** The section: read the own binding, choose, write. `simctl create` runs inside it. */ +async function bindIosSectionAsync( + projectRoot: string, + { + inventory, + reuseOnly, + tools, + }: { inventory: IosInventory; reuseOnly: boolean; tools: DeviceTools } +): Promise<{ binding: Binding; wasBooted: boolean; action: AcquireAction }> { + const file = bindingPathFor(projectRoot, 'ios', 'local-ios'); + const read = readBindingFile(file); + if (read.kind === 'unreadable') { + throw deviceUnavailableError('unreadable', { platform: 'ios', path: file }); + } + const own = read.kind === 'binding' ? read.binding : null; + const choice = chooseIosDevice({ own, inventory, reuseOnly, digest: digestForRoot(projectRoot) }); + if (own != null && choice.kind !== 'reuse') { + removeBindingFile(file); + } + // The reap of other worktrees' stale bindings arrives with llp/0035 §Device lifecycle. + if (choice.kind === 'refuse') { + throw deviceUnavailableError(choice.reason, { platform: 'ios' }); + } + const lease = leaseFrom(tools.now()); + if (choice.kind === 'reuse') { + const reused = { ...choice.binding, ...lease }; + writeBindingFile(file, reused); + return { binding: reused, wasBooted: choice.listed.state === 'Booted', action: 'reused' }; + } + const udid = await createSimulatorAsync(tools, choice); + const created: Binding = { + version: 1, + device: { backend: 'local-ios', platform: 'ios', udid, name: choice.name, origin: 'created' }, + projectRoot: canonicalizeExistingPath(projectRoot), + ...lease, + }; + writeBindingFile(file, created); + return { binding: created, wasBooted: false, action: 'created' }; +} + +/** + * Let go of this worktree's local bindings, or only `platform`'s. Never reaps. + * + * A `created` simulator is parked and shut down; an `explicit` one keeps its file with an expired + * lease; a binding whose simulator the inventory does not list loses its file. + */ +export async function releaseWorktreeDevicesAsync( + projectRoot: string, + { platform, tools = defaultTools() }: { platform?: 'ios' | 'android'; tools?: DeviceTools } = {} +): Promise { + if (platform === 'android') { + return []; + } + const file = bindingPathFor(projectRoot, 'ios', 'local-ios'); + const before = readBindingFile(file); + if (before.kind !== 'binding' || before.binding.device.backend !== 'local-ios') { + return []; + } + const { udid, name, origin } = before.binding.device; + const listing = await listSimulatorAsync(tools, udid); + + const letGo = await withRegistryLockAsync( + async () => { + const read = readBindingFile(file); + if (read.kind !== 'binding') { + return null; + } + if (listing.kind === 'missing') { + removeBindingFile(file); + return { shutdown: false }; + } + writeBindingFile(file, { ...read.binding, expiresAt: tools.now().toISOString() }); + return { + shutdown: + origin === 'created' && !(listing.kind === 'listed' && listing.state === 'Shutdown'), + }; + }, + { waitMs: WRITE_LOCK_WAIT_MS, tools } + ); + clearInspectCache(); + if (letGo == null) { + return []; + } + const stopped = letGo.shutdown ? await shutdownSimulatorAsync(tools, udid) : null; + return [ + { + backend: 'local-ios', + platform: 'ios', + id: udid, + name, + released: true, + shutDown: stopped?.ok ?? false, + reason: stopped?.reason ?? null, + }, + ]; +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/inspect.ts b/packages/@expo/agent-cli/src/deviceBinding/inspect.ts new file mode 100644 index 00000000..dd909941 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/inspect.ts @@ -0,0 +1,116 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Entry points +// What a read verb learns about one binding: lock-free, no extension, one subprocess at most. + +import * as Log from '../log'; +import { noBoundDeviceError } from './errors'; +import { listSimulatorAsync, SIMCTL_CALL_TIMEOUT_MS } from './ios'; +import { extendLeaseAsync, isExpired } from './lease'; +import { bindingPathFor, readBindingFile } from './registry'; +import { defaultTools } from './tools'; +import type { BoundDevice, DevicePlatform, DeviceTools, Inspection } from './types'; + +/** The wait of a read verb's lease extension; a timeout warns and goes on. */ +export const READ_LOCK_WAIT_MS = 5_000; + +/** The backends this PR inspects; `local-android` arrives with llp/0035 §Android instance. */ +export type InspectableBackend = 'local-ios' | 'cloud'; + +export async function inspectBindingAsync( + projectRoot: string, + platform: DevicePlatform, + backend: InspectableBackend, + tools: DeviceTools = defaultTools(), + { timeoutMs = SIMCTL_CALL_TIMEOUT_MS }: { timeoutMs?: number } = {} +): Promise { + const path = bindingPathFor(projectRoot, platform, backend); + const read = readBindingFile(path); + if (read.kind === 'none') { + return { binding: null, path, state: 'none' }; + } + if (read.kind === 'unreadable') { + return { binding: null, path, state: 'unreadable' }; + } + const { binding } = read; + const expired = isExpired(binding, tools.now()); + if (binding.device.backend === 'cloud') { + return expired + ? { binding, path, state: 'gone', cause: 'expired' } + : { binding, path, state: 'recorded' }; + } + if (binding.device.backend !== 'local-ios') { + return { binding, path, state: 'unreadable' }; + } + const listing = await listSimulatorAsync(tools, binding.device.udid, { timeoutMs }); + if (listing.kind === 'tool') { + return { binding, path, state: 'unknown', cause: 'tool', toolError: listing.error }; + } + if (listing.kind === 'timeout') { + return { binding, path, state: 'unknown', cause: 'timeout' }; + } + if (expired) { + return { binding, path, state: 'gone', cause: 'expired' }; + } + if (listing.kind === 'missing') { + return { binding, path, state: 'gone', cause: 'device-gone' }; + } + return { binding, path, state: listing.state === 'Booted' ? 'up' : 'not-up' }; +} + +const cache = new Map>(); + +/** One inspection per process, root, platform and backend, for the suggestion ladders. */ +export function inspectBindingCachedAsync( + projectRoot: string, + platform: DevicePlatform, + backend: InspectableBackend, + options: { tools?: DeviceTools; timeoutMs?: number } = {} +): Promise { + const key = `${projectRoot}\0${platform}\0${backend}`; + let pending = cache.get(key); + if (!pending) { + pending = inspectBindingAsync(projectRoot, platform, backend, options.tools, options); + cache.set(key, pending); + } + return pending; +} + +export function clearInspectCache(): void { + cache.clear(); +} + +/** + * Take an `up` inspection, extend its lease, and hand back the device. Never inspects again and + * never boots. `lost` throws `gone` with cause `expired`. + */ +export async function useBoundDeviceAsync( + inspection: Inspection, + tools: DeviceTools = defaultTools() +): Promise { + const binding = inspection.binding; + if (inspection.state !== 'up' || binding == null) { + throw new Error(`useBoundDeviceAsync needs an up inspection, not ${inspection.state}`); + } + const { platform, backend } = binding.device; + let outcome: 'extended' | 'lost'; + try { + outcome = await extendLeaseAsync(binding.projectRoot, platform, backend, tools, { + waitMs: READ_LOCK_WAIT_MS, + }); + } catch (error: unknown) { + if ((error as { code?: string }).code !== 'DEVICE_REGISTRY_LOCKED') { + throw error; + } + Log.warn( + `The device registry is locked, so the lease on ${deviceLabel(binding.device)} was not extended.` + ); + return binding.device; + } + if (outcome === 'lost') { + throw noBoundDeviceError('gone', { platform, cause: 'expired', path: inspection.path }); + } + return binding.device; +} + +function deviceLabel(device: BoundDevice): string { + return device.backend === 'local-ios' ? device.name : device.backend; +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/ios.ts b/packages/@expo/agent-cli/src/deviceBinding/ios.ts new file mode 100644 index 00000000..3d73e8e1 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/ios.ts @@ -0,0 +1,201 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Entry points +// The iOS half of the registry's device calls: the inventory, `simctl create`, the boot and the +// shutdown, each one `simctl` subprocess. + +import { parseSimulators } from '../device/simulators'; +import { CommandError } from '../utils/errors'; +import type { SpawnCaptureResult } from '../utils/spawnCapture'; +import { firstLine } from '../utils/text'; +import type { IosInventory } from './choose'; +import { deviceUnavailableError } from './errors'; +import type { DeviceTools } from './types'; + +/** The budget of every `simctl` call that is not a boot. */ +export const SIMCTL_CALL_TIMEOUT_MS = 20_000; + +export function xcrunNotRunnableError(reason: string): CommandError { + return new CommandError( + 'XCRUN_NOT_RUNNABLE', + [ + `Could not run "xcrun simctl", so no iOS simulator was looked at.`, + `Why: ${reason}`, + `How: install Xcode and its command line tools, which provide "xcrun simctl", then run this command again.`, + ].join('\n') + ); +} + +/** Pure: the newest available iOS runtime with an iPhone, out of `simctl list runtimes -j`. */ +export function parseNewestIosRuntime(stdout: string): IosInventory['newestIosRuntime'] { + let parsed: { + runtimes?: { + identifier?: string; + platform?: string; + version?: string; + isAvailable?: boolean; + supportedDeviceTypes?: { identifier?: string; productFamily?: string }[]; + }[]; + }; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const candidates = (parsed.runtimes ?? []) + .filter((runtime) => runtime.platform === 'iOS' && runtime.isAvailable !== false) + .map((runtime) => ({ + identifier: runtime.identifier ?? '', + version: (runtime.version ?? '').split('.').map(Number), + deviceType: + runtime.supportedDeviceTypes?.find((type) => type.productFamily === 'iPhone')?.identifier ?? + null, + })) + .filter((runtime) => runtime.identifier && runtime.deviceType != null) + .sort((left, right) => compareVersions(right.version, left.version)); + const newest = candidates[0]; + return newest ? { identifier: newest.identifier, deviceType: newest.deviceType! } : null; +} + +function compareVersions(left: number[], right: number[]): number { + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +/** + * The iOS inventory, read before the lock. + * + * @throws `XCRUN_NOT_RUNNABLE` when `xcrun` cannot run; `DEVICE_UNAVAILABLE` when it does not + * answer in time. + */ +export async function readIosInventoryAsync(tools: DeviceTools): Promise { + const devices = await tools.simctl(['list', 'devices', '-j'], { + timeoutMs: SIMCTL_CALL_TIMEOUT_MS, + }); + assertAnswered(devices, 'list devices'); + const runtimes = await tools.simctl(['list', 'runtimes', '-j'], { + timeoutMs: SIMCTL_CALL_TIMEOUT_MS, + }); + assertAnswered(runtimes, 'list runtimes'); + return { + simulators: parseSimulators(devices.stdout), + newestIosRuntime: parseNewestIosRuntime(runtimes.stdout), + }; +} + +function assertAnswered(result: SpawnCaptureResult, call: string): void { + if (result.spawnError) { + throw xcrunNotRunnableError(result.spawnError.message); + } + if (result.exitCode == null) { + throw deviceUnavailableError('create-timeout', { platform: 'ios' }); + } + if (result.exitCode !== 0) { + throw xcrunNotRunnableError( + `"xcrun simctl ${call}" exited ${result.exitCode}: ${firstLine(result.stderr) || 'no output'}` + ); + } +} + +/** `simctl create`, under the lock. A timeout kills the child and refuses `create-timeout`. */ +export async function createSimulatorAsync( + tools: DeviceTools, + { name, deviceType, runtime }: { name: string; deviceType: string; runtime: string } +): Promise { + const created = await tools.simctl(['create', name, deviceType, runtime], { + timeoutMs: SIMCTL_CALL_TIMEOUT_MS, + }); + if (created.spawnError) { + throw xcrunNotRunnableError(created.spawnError.message); + } + if (created.exitCode == null) { + throw deviceUnavailableError('create-timeout', { platform: 'ios' }); + } + const udid = firstLine(created.stdout); + if (created.exitCode !== 0 || !udid) { + throw xcrunNotRunnableError( + `"xcrun simctl create" exited ${created.exitCode}: ${firstLine(created.stderr) || 'no output'}` + ); + } + return udid; +} + +/** What one `simctl list devices -j` says about one simulator. */ +export type SimulatorListing = + | { kind: 'listed'; state: string; name: string } + | { kind: 'missing' } + | { kind: 'tool'; error: CommandError } + | { kind: 'timeout' }; + +export async function listSimulatorAsync( + tools: DeviceTools, + udid: string, + { timeoutMs = SIMCTL_CALL_TIMEOUT_MS }: { timeoutMs?: number } = {} +): Promise { + const listed = await tools.simctl(['list', 'devices', '-j'], { timeoutMs }); + if (listed.spawnError) { + return { kind: 'tool', error: xcrunNotRunnableError(listed.spawnError.message) }; + } + if (listed.exitCode == null) { + return { kind: 'timeout' }; + } + if (listed.exitCode !== 0) { + return { + kind: 'tool', + error: xcrunNotRunnableError( + `"xcrun simctl list devices" exited ${listed.exitCode}: ${firstLine(listed.stderr) || 'no output'}` + ), + }; + } + const entry = parseSimulators(listed.stdout).find((simulator) => simulator.udid === udid); + return entry ? { kind: 'listed', state: entry.state, name: entry.name } : { kind: 'missing' }; +} + +/** + * Boot and wait. `bootstatus -b` boots only when the simulator is not `Booted` and then waits, + * because `simctl boot` on a `Booted` simulator exits non-zero. + */ +export async function bootSimulatorAsync( + tools: DeviceTools, + udid: string, + { timeoutMs }: { timeoutMs: number } +): Promise<{ ok: boolean; reason: string | null }> { + const status = await tools.simctl(['bootstatus', udid, '-b'], { timeoutMs }); + if (status.spawnError) { + return { ok: false, reason: `could not run "xcrun simctl": ${status.spawnError.message}` }; + } + if (status.exitCode == null) { + return { + ok: false, + reason: `"xcrun simctl bootstatus ${udid} -b" did not report it up within ${timeoutMs}ms`, + }; + } + if (status.exitCode !== 0) { + return { + ok: false, + reason: `"xcrun simctl bootstatus ${udid} -b" exited ${status.exitCode}: ${firstLine(status.stderr) || 'no output'}`, + }; + } + return { ok: true, reason: null }; +} + +/** An already shut-down simulator counts as success. */ +export async function shutdownSimulatorAsync( + tools: DeviceTools, + udid: string +): Promise<{ ok: boolean; reason: string | null }> { + const result = await tools.simctl(['shutdown', udid], { timeoutMs: 60_000 }); + if (result.spawnError) { + return { ok: false, reason: `could not run "xcrun simctl": ${result.spawnError.message}` }; + } + if (result.exitCode !== 0 && !/current state: Shutdown/i.test(result.stderr)) { + return { + ok: false, + reason: `"xcrun simctl shutdown ${udid}" exited ${result.exitCode}: ${firstLine(result.stderr) || 'no output'}`, + }; + } + return { ok: true, reason: null }; +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/lease.ts b/packages/@expo/agent-cli/src/deviceBinding/lease.ts new file mode 100644 index 00000000..65fee380 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/lease.ts @@ -0,0 +1,69 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Lease +// Liveness is a 60 min lease, renewed by use. Nothing else. + +import fs from 'fs'; + +import { + bindingPathFor, + readBindingFile, + withRegistryLockAsync, + writeBindingFile, +} from './registry'; +import type { Binding, BindingBackend, DevicePlatform, DeviceTools } from './types'; + +export const LEASE_MS = 3_600_000; +export const EXTEND_EVERY_MS = 300_000; + +export function leaseFrom(now: Date): { boundAt: string; expiresAt: string } { + return { + boundAt: now.toISOString(), + expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(), + }; +} + +export function isExpired(binding: Binding, now: Date): boolean { + return !(Date.parse(binding.expiresAt) > now.getTime()); +} + +/** Expired, or the root is deleted (RFC Decision 6). */ +export function isStale(binding: Binding, now: Date): boolean { + if (isExpired(binding, now)) { + return true; + } + try { + fs.lstatSync(binding.projectRoot); + return false; + } catch (error: unknown) { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; + } +} + +/** + * Renew a live lease under the lock. An expired or missing one is `lost`, and nothing is written. + * + * @throws `DEVICE_REGISTRY_LOCKED` when the lock is not obtained in `waitMs`. + */ +export async function extendLeaseAsync( + projectRoot: string, + platform: DevicePlatform, + backend: BindingBackend, + tools: DeviceTools, + { waitMs }: { waitMs: number } +): Promise<'extended' | 'lost'> { + const file = bindingPathFor(projectRoot, platform, backend); + return await withRegistryLockAsync( + async () => { + const read = readBindingFile(file); + const now = tools.now(); + if (read.kind !== 'binding' || isExpired(read.binding, now)) { + return 'lost'; + } + writeBindingFile(file, { + ...read.binding, + expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(), + }); + return 'extended'; + }, + { waitMs, tools } + ); +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/registry.ts b/packages/@expo/agent-cli/src/deviceBinding/registry.ts new file mode 100644 index 00000000..4755e702 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/registry.ts @@ -0,0 +1,225 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Records +// @ref llp/0035-one-device-per-worktree.rfc.md §Lock +// The registry directory, its binding files, and the one machine-wide lock every write runs under. + +import { spawnSync } from 'child_process'; +import { randomBytes } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +import { digestOf } from '../devLock/address'; +import { canonicalizeExistingPath } from '../utils/dir'; +import { getExpoHomeDirectory } from '../utils/expoHome'; +import { registryLockedError } from './errors'; +import { event } from './events'; +import { + parseBinding, + type Binding, + type BindingBackend, + type DevicePlatform, + type DeviceTools, +} from './types'; + +const LOCK_POLL_MS = 100; +const LOCK_NAME = '.lock'; +const MARKER = /^pid-(\d+)-[0-9a-f]+$/; + +export function registryDirectory(): string { + return path.join(getExpoHomeDirectory(), 'agent-cli', 'bindings'); +} + +export function digestForRoot(projectRoot: string): string { + return digestOf(canonicalizeExistingPath(projectRoot)); +} + +export function bindingPathFor( + projectRoot: string, + platform: DevicePlatform, + backend: BindingBackend +): string { + return path.join( + registryDirectory(), + `${digestForRoot(projectRoot)}-${platform}-${backend}.json` + ); +} + +export type BindingRead = + | { kind: 'none' } + | { kind: 'unreadable' } + | { kind: 'binding'; binding: Binding }; + +export function readBindingFile(file: string): BindingRead { + let text: string; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { kind: 'none' }; + } + return { kind: 'unreadable' }; + } + try { + const binding = parseBinding(JSON.parse(text)); + return binding ? { kind: 'binding', binding } : { kind: 'unreadable' }; + } catch { + return { kind: 'unreadable' }; + } +} + +/** Only for a caller under the lock. */ +export function writeBindingFile(file: string, binding: Binding): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temp = `${file}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`; + try { + fs.writeFileSync(temp, `${JSON.stringify(binding, null, 2)}\n`); + fs.renameSync(temp, file); + } finally { + fs.rmSync(temp, { force: true }); + } +} + +/** Only for a caller under the lock. */ +export function removeBindingFile(file: string): void { + fs.rmSync(file, { force: true }); +} + +/** + * Run `work` while this process holds the registry lock. + * + * A temp dir holding one `pid--` marker is renamed to `.lock`, so a lock never exists + * without a pid. No nesting. + */ +export async function withRegistryLockAsync( + work: () => Promise, + { waitMs, tools }: { waitMs: number; tools: Pick } +): Promise { + const directory = registryDirectory(); + fs.mkdirSync(directory, { recursive: true }); + const lock = path.join(directory, LOCK_NAME); + const marker = `pid-${process.pid}-${randomBytes(4).toString('hex')}`; + const temp = fs.mkdtempSync(path.join(directory, '.lock-')); + fs.writeFileSync(path.join(temp, marker), ''); + + const deadline = Date.now() + waitMs; + try { + while (!tryRename(temp, lock)) { + removeDeadLock(lock, tools); + if (Date.now() >= deadline) { + throw registryLockedError(describeLock(lock)); + } + await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS)); + } + } catch (error) { + fs.rmSync(temp, { recursive: true, force: true }); + throw error; + } + + try { + return await work(); + } finally { + rmIgnoring(path.join(lock, marker), ['ENOENT']); + rmdirIgnoring(lock, ['ENOENT', 'ENOTEMPTY']); + } +} + +function tryRename(from: string, to: string): boolean { + try { + fs.renameSync(from, to); + return true; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM' || code === 'EACCES') { + return false; + } + throw error; + } +} + +function lockHolder(lock: string): { pid: number; marker: string } | null { + let names: string[]; + try { + names = fs.readdirSync(lock); + } catch { + return null; + } + for (const name of names) { + const match = MARKER.exec(name); + if (match) { + return { pid: Number(match[1]), marker: path.join(lock, name) }; + } + } + return null; +} + +function removeDeadLock(lock: string, tools: Pick): void { + if (!fs.existsSync(lock)) { + return; + } + const holder = lockHolder(lock); + if (holder == null) { + rmdirIgnoring(lock, ['ENOENT', 'ENOTEMPTY']); + return; + } + if (tools.isPidAlive(holder.pid)) { + return; + } + rmIgnoring(holder.marker, ['ENOENT']); + if (rmdirIgnoring(lock, ['ENOENT', 'ENOTEMPTY'])) { + event('device_registry_lock_removed', { lock, pid: holder.pid }); + } +} + +function describeLock(lock: string): { + pid: number | null; + ageMs: number | null; + command: string | null; +} { + const holder = lockHolder(lock); + if (holder == null) { + return { pid: null, ageMs: null, command: null }; + } + let ageMs: number | null = null; + try { + ageMs = Date.now() - fs.statSync(holder.marker).mtimeMs; + } catch { + // The holder gave up between the two reads. + } + return { pid: holder.pid, ageMs, command: commandOf(holder.pid) }; +} + +function commandOf(pid: number): string | null { + if (process.platform === 'win32') { + return null; + } + try { + const result = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { + encoding: 'utf8', + timeout: 2_000, + }); + return (result?.status === 0 && result.stdout?.trim()) || null; + } catch { + return null; + } +} + +function rmIgnoring(file: string, codes: string[]): void { + try { + fs.unlinkSync(file); + } catch (error: unknown) { + if (!codes.includes((error as NodeJS.ErrnoException).code ?? '')) { + throw error; + } + } +} + +function rmdirIgnoring(directory: string, codes: string[]): boolean { + try { + fs.rmdirSync(directory); + return true; + } catch (error: unknown) { + if (!codes.includes((error as NodeJS.ErrnoException).code ?? '')) { + throw error; + } + return false; + } +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/rungs.ts b/packages/@expo/agent-cli/src/deviceBinding/rungs.ts new file mode 100644 index 00000000..343be2fe --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/rungs.ts @@ -0,0 +1,139 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Readers +// The rung loop every read verb walks: the local bindings in iOS, Android order, any `up` wins. + +import type { CommandError } from '../utils/errors'; +import { noBoundDeviceError } from './errors'; +import { inspectBindingAsync, useBoundDeviceAsync } from './inspect'; +import { defaultTools } from './tools'; +import type { BoundDevice, DevicePlatform, DeviceTools, Inspection } from './types'; + +/** + * Main's first-`adb`-device rung, injected by the caller until llp/0035 §Android instance binds Android. A device + * found counts as `up` with no lease; nothing found is `none`; an `adb` that cannot run is the + * `unknown` row with its tool error. + */ +export type AndroidRung = () => Promise<{ device: BoundDevice | null; toolError?: CommandError }>; + +export interface FindBoundDeviceOptions { + platform?: DevicePlatform; + /** Whether the winner's lease is extended; `status` never writes. */ + extend: boolean; + tools?: DeviceTools; + android?: AndroidRung; + hostPlatform?: NodeJS.Platform; +} + +export type BoundDeviceSearch = + | { device: BoundDevice; refusal: null; toolError: null } + | { + device: null; + /** The refusal of the first state seen that was not `none`, for after the EAS rung. */ + refusal: CommandError; + /** The first tool error seen, which outranks the refusal after the EAS rung. */ + toolError: CommandError | null; + }; + +interface Rung { + platform: DevicePlatform; + inspection: Inspection | null; + state: Inspection['state']; + cause?: Inspection['cause']; + toolError?: CommandError; + device: BoundDevice | null; + boundAt: string; +} + +/** + * @throws `NO_BOUND_DEVICE` for a state whose row refuses (`unreadable`, a timed-out check, + * `not-up`), and the tool error at once when a platform flag named the rung it came from. + */ +export async function findBoundDeviceAsync( + projectRoot: string, + { + platform, + extend, + tools = defaultTools(), + android, + hostPlatform = process.platform, + }: FindBoundDeviceOptions +): Promise { + const platforms: DevicePlatform[] = platform + ? [platform] + : hostPlatform === 'darwin' + ? ['ios', 'android'] + : ['android']; + const rungs: Rung[] = []; + for (const each of platforms) { + rungs.push(each === 'ios' ? await iosRung(projectRoot, tools) : await androidRung(android)); + } + + const ups = rungs.filter((rung) => rung.state === 'up'); + if (ups.length > 0) { + const winner = ups.reduce((best, rung) => (rung.boundAt > best.boundAt ? rung : best)); + const device = + extend && winner.inspection + ? await useBoundDeviceAsync(winner.inspection, tools) + : winner.device!; + return { device, refusal: null, toolError: null }; + } + + let toolError: CommandError | null = null; + let refusal: CommandError | null = null; + for (const rung of rungs) { + if (rung.state === 'none') { + continue; + } + if (rung.state === 'unknown' && rung.cause === 'tool') { + if (platform) { + throw rung.toolError; + } + toolError ??= rung.toolError ?? null; + continue; + } + const error = noBoundDeviceError(rung.state as 'unreadable' | 'unknown' | 'not-up' | 'gone', { + platform: rung.platform, + cause: rung.cause, + path: rung.inspection?.path, + }); + if (rung.state === 'gone') { + refusal ??= error; + continue; + } + throw error; + } + refusal ??= noBoundDeviceError('none', { + platform: platform ?? (hostPlatform === 'darwin' ? 'ios' : 'android'), + }); + return { device: null, refusal, toolError }; +} + +async function iosRung(projectRoot: string, tools: DeviceTools): Promise { + const inspection = await inspectBindingAsync(projectRoot, 'ios', 'local-ios', tools); + return { + platform: 'ios', + inspection, + state: inspection.state, + cause: inspection.cause, + toolError: inspection.toolError, + device: inspection.state === 'up' ? inspection.binding!.device : null, + boundAt: inspection.binding?.boundAt ?? '', + }; +} + +async function androidRung(android: AndroidRung | undefined): Promise { + const none: Rung = { + platform: 'android', + inspection: null, + state: 'none', + device: null, + boundAt: '', + }; + if (!android) { + return none; + } + const probe = await android(); + if (probe.toolError) { + return { ...none, state: 'unknown', cause: 'tool', toolError: probe.toolError }; + } + return probe.device ? { ...none, state: 'up', device: probe.device } : none; +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/tools.ts b/packages/@expo/agent-cli/src/deviceBinding/tools.ts new file mode 100644 index 00000000..a426da30 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/tools.ts @@ -0,0 +1,23 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Records +// The platform tools the registry drives, as subprocesses. Injected in tests. + +import { spawnCaptureAsync } from '../utils/spawnCapture'; +import type { DeviceTools } from './types'; + +export function defaultTools(): DeviceTools { + return { + simctl: (args, options) => spawnCaptureAsync('xcrun', ['simctl', ...args], options), + now: () => new Date(), + isPidAlive, + }; +} + +/** `EPERM` is a process this user may not signal, which is a process that is there. */ +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return (error as NodeJS.ErrnoException | undefined)?.code === 'EPERM'; + } +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/types.ts b/packages/@expo/agent-cli/src/deviceBinding/types.ts new file mode 100644 index 00000000..d1e26bed --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/types.ts @@ -0,0 +1,158 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Records +// The records of the device registry: one binding per worktree, platform and backend. + +import type { CommandError } from '../utils/errors'; +import type { Runner } from '../utils/spawnCapture'; + +export type DevicePlatform = 'ios' | 'android'; +export type BindingBackend = 'local-ios' | 'local-android' | 'cloud'; + +export type BoundDevice = + | { + backend: 'local-ios'; + platform: 'ios'; + udid: string; + name: string; + origin: 'created' | 'explicit'; + } + | { + backend: 'local-android'; + platform: 'android'; + serial: string; + origin: + | { kind: 'spawned'; avd: string; port: number; emulatorPid: number } + | { kind: 'explicit' }; + } + | { + backend: 'cloud'; + platform: DevicePlatform; + id: string; + origin: 'started' | 'dotenv'; + tag?: string; + }; + +export interface Binding { + version: 1; + device: BoundDevice; + projectRoot: string; + /** ISO; the session-cap clock, so a cloud reuse keeps it. */ + boundAt: string; + /** ISO, the lease. */ + expiresAt: string; +} + +export interface DeviceTools { + simctl: Runner; + now: () => Date; + isPidAlive: (pid: number) => boolean; +} + +export type AcquireAction = 'reused' | 'created' | 'spawned' | 'explicit'; + +export interface AcquireResult { + device: BoundDevice; + justBooted: boolean; + action: AcquireAction; +} + +export type InspectState = + | 'none' + | 'unreadable' + | 'unknown' + | 'recorded' + | 'up' + | 'not-up' + | 'gone'; + +export type InspectCause = 'expired' | 'device-gone' | 'timeout' | 'tool'; + +export interface Inspection { + binding: Binding | null; + path: string; + state: InspectState; + cause?: InspectCause; + toolError?: CommandError; +} + +export interface ReleasedDevice { + backend: BindingBackend; + platform: DevicePlatform; + id: string; + name: string; + released: boolean; + shutDown: boolean; + reason: string | null; +} + +/** The id a device is known by to its platform tool. */ +export function deviceIdOf(device: BoundDevice): string { + switch (device.backend) { + case 'local-ios': + return device.udid; + case 'local-android': + return device.serial; + case 'cloud': + return device.id; + } +} + +/** The name a person recognises the device by. */ +export function deviceNameOf(device: BoundDevice): string { + return device.backend === 'local-ios' ? device.name : deviceIdOf(device); +} + +/** A record as the file holds it, or null when it is not one this CLI wrote. */ +export function parseBinding(value: unknown): Binding | null { + if (value == null || typeof value !== 'object') { + return null; + } + const { version, device, projectRoot, boundAt, expiresAt } = value as Record; + if ( + version !== 1 || + typeof projectRoot !== 'string' || + typeof boundAt !== 'string' || + typeof expiresAt !== 'string' || + !isBoundDevice(device) + ) { + return null; + } + return { version: 1, device, projectRoot, boundAt, expiresAt }; +} + +function isBoundDevice(value: unknown): value is BoundDevice { + if (value == null || typeof value !== 'object') { + return false; + } + const device = value as Record; + switch (device.backend) { + case 'local-ios': + return ( + device.platform === 'ios' && + typeof device.udid === 'string' && + typeof device.name === 'string' && + (device.origin === 'created' || device.origin === 'explicit') + ); + case 'local-android': { + const origin = device.origin as Record | null; + return ( + device.platform === 'android' && + typeof device.serial === 'string' && + origin != null && + typeof origin === 'object' && + (origin.kind === 'explicit' || + (origin.kind === 'spawned' && + typeof origin.avd === 'string' && + typeof origin.port === 'number' && + typeof origin.emulatorPid === 'number')) + ); + } + case 'cloud': + return ( + (device.platform === 'ios' || device.platform === 'android') && + typeof device.id === 'string' && + (device.origin === 'started' || device.origin === 'dotenv') + ); + default: + return false; + } +} From 43b0667b62e8b8d9302c832b6427052b878eae56 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 12:57:32 +0200 Subject: [PATCH 02/17] =?UTF-8?q?[agent-cli]=20dev=20binds=20the=20worktre?= =?UTF-8?q?e's=20iOS=20simulator,=20and=20every=20verb=20reads=20the=20bin?= =?UTF-8?q?ding=20(llp/0031=20=C2=A7How=20`dev`=20uses=20it,=20=C2=A7Reade?= =?UTF-8?q?rs,=20=C2=A7smoke)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver binds for a serving draft before its presence probe; a building draft is bound after the refusals; every run and install step carries --device , and the open takes the bound device. dev --detach on a running server reuses the parked simulator or refuses not-reusable, and the child's DEVICE_* and NO_BOUND_DEVICE refusals reach the caller unchanged through the verdict's code row. navigate, runtime:*, status and the installed readers walk the rung loop; the EAS fallback accepts the dotenv id only. smoke's iOS boot is the registry's, left bound. The first-booted probe, pickSimulator, the iOS boot and NO_DEVICE go. Co-Authored-By: Claude Fable 5.1 --- .../@expo/agent-cli/src/agents/content.ts | 4 + .../src/deferred/dev-wait/waitAsync.ts | 2 +- .../src/dev/__tests__/childVerdict-test.ts | 28 +- .../src/dev/__tests__/detach-test.ts | 7 +- .../src/dev/__tests__/detachAsync-test.ts | 121 ++++++ .../src/dev/__tests__/devAsync-test.ts | 148 ++++++++ .../src/dev/__tests__/forwardedArgs-test.ts | 30 ++ .../src/dev/__tests__/openApp-test.ts | 119 +++--- .../@expo/agent-cli/src/dev/childVerdict.ts | 42 ++- .../@expo/agent-cli/src/dev/detachAsync.ts | 62 ++- packages/@expo/agent-cli/src/dev/devAsync.ts | 111 +++++- .../@expo/agent-cli/src/dev/forwardedArgs.ts | 32 ++ packages/@expo/agent-cli/src/dev/index.ts | 2 +- packages/@expo/agent-cli/src/dev/openApp.ts | 43 ++- .../src/device/__tests__/appPresence-test.ts | 163 +++++--- .../src/device/__tests__/bootDevice-test.ts | 129 +------ .../src/device/__tests__/localDevice-test.ts | 160 +++++--- .../@expo/agent-cli/src/device/appPresence.ts | 74 ++-- .../@expo/agent-cli/src/device/bootDevice.ts | 355 ++---------------- .../@expo/agent-cli/src/device/localDevice.ts | 166 ++++---- .../src/deviceBinding/__tests__/fakeTools.ts | 10 +- .../agent-cli/src/deviceBinding/index.ts | 19 +- .../src/installedApp/__tests__/ios-test.ts | 26 +- .../src/installedApp/installedAppAsync.ts | 7 +- .../@expo/agent-cli/src/installedApp/ios.ts | 78 +++- .../src/lint/__tests__/foreignFlags-test.ts | 2 - .../src/navigate/__tests__/device-test.ts | 341 ++++++++++------- .../navigate/__tests__/navigateAsync-test.ts | 63 +++- .../@expo/agent-cli/src/navigate/device.ts | 306 ++++++--------- .../@expo/agent-cli/src/navigate/index.ts | 6 +- .../@expo/agent-cli/src/navigate/openRoute.ts | 22 +- .../src/plan/__tests__/resolveAsync-test.ts | 9 +- .../src/plan/__tests__/resolveDevice-test.ts | 187 +++++++++ .../@expo/agent-cli/src/plan/resolveAsync.ts | 174 +++++---- .../src/runtime/__tests__/stopAsync-test.ts | 3 + .../reload/__tests__/reloadAsync-test.ts | 2 + .../agent-cli/src/runtime/reload/index.ts | 2 +- .../@expo/agent-cli/src/runtime/stopAsync.ts | 2 +- .../src/smoke/__tests__/phases-test.ts | 54 +-- .../src/smoke/__tests__/smokeDeps-test.ts | 100 +++++ packages/@expo/agent-cli/src/smoke/phases.ts | 36 +- .../@expo/agent-cli/src/smoke/smokeAsync.ts | 83 ++-- .../@expo/agent-cli/src/start/followUps.ts | 6 +- .../@expo/agent-cli/src/status/statusAsync.ts | 19 +- packages/@expo/agent-cli/src/utils/errors.ts | 12 +- 45 files changed, 2066 insertions(+), 1301 deletions(-) create mode 100644 packages/@expo/agent-cli/src/plan/__tests__/resolveDevice-test.ts create mode 100644 packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts diff --git a/packages/@expo/agent-cli/src/agents/content.ts b/packages/@expo/agent-cli/src/agents/content.ts index 4d22cab7..51484e07 100644 --- a/packages/@expo/agent-cli/src/agents/content.ts +++ b/packages/@expo/agent-cli/src/agents/content.ts @@ -89,6 +89,10 @@ export function generateAgentsMdBlock({ 'Links in the docs point to `https://docs.expo.dev/.md`. To read one locally, open `.md` in the docs directory that `docs:sync` printed.', 'Prefer the local docs over web fetches of docs.expo.dev.', '', + '## Devices', + '', + `Run \`${PROGRAM_PREFIX} dev\` before \`navigate\`, \`runtime:reload\` or \`runtime:stop\`, or they refuse with \`NO_BOUND_DEVICE\` and name the command. Never boot, shut down or delete a simulator named \`agent-cli …\` yourself; it may be bound to another worktree.`, + '', '## Expo skills', '', 'For Expo or EAS work, start with the `expo-overview` skill when it is available in your agent’s skill list. It routes your goal to the relevant Expo or EAS skill. Then read any matching package skills listed below for guidance specific to the installed packages.', diff --git a/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts b/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts index acce56b0..275b5a1f 100644 --- a/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts +++ b/packages/@expo/agent-cli/src/deferred/dev-wait/waitAsync.ts @@ -266,7 +266,7 @@ async function resolveOpenOnAsync( if (!result.ready || result.appsConnected !== 0) { return 'local'; } - const local = await probeLocalDeviceAsync(); + const local = await probeLocalDeviceAsync({ projectRoot }); // `absent` and not `unknown`: a tool that could not be started has said nothing about this // machine's devices, and re-aiming a suggestion on that would be the F49 mistake again. return local.state === 'absent' && readCloudSessionIdSync(projectRoot) != null diff --git a/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts index 8bc94ca8..1568411f 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts @@ -3,7 +3,7 @@ import { stripVTControlCharacters } from 'util'; import { needsHumanError } from '../../needsHuman/error'; import { formatStartPlan } from '../../plan/format'; import type { StartPlan } from '../../project/types'; -import { formatNeedsHumanBlock } from '../../utils/errors'; +import { formatErrorCodeRow, formatNeedsHumanBlock } from '../../utils/errors'; import { parseDetachedChildPhase, parseDetachedChildPort, @@ -56,9 +56,35 @@ describe(parseDetachedChildVerdict, () => { expect(verdict).toEqual({ scenario: null, message: 'CommandError: The plan stopped at "start".\nWhy: the bundler could not start.', + code: null, + exitCode: null, + suggestedCommand: null, }); }); + // @ref llp/0035-one-device-per-worktree.rfc.md §Output and errors — the row `logCmdError` + // writes for a detached child alone, read back with the `Try:` line, so a device refusal + // reaches the parent's caller as the child raised it. + it(`should read the code, the exit and the Try line of a device refusal`, () => { + const verdict = parseDetachedChildVerdict([ + 'CommandError: No ios device is bound to this worktree.', + 'Why: every verb drives the bound device.', + 'How: run "npx @expo/agent-cli dev --ios --detach --wait-ready", then this command again.', + 'Try: npx @expo/agent-cli dev --ios --detach --wait-ready', + formatErrorCodeRow('NO_BOUND_DEVICE', 20), + ]); + + expect(verdict).toMatchObject({ + scenario: null, + code: 'NO_BOUND_DEVICE', + exitCode: 20, + suggestedCommand: 'npx @expo/agent-cli dev --ios --detach --wait-ready', + }); + expect(verdict?.message).not.toContain('Try:'); + expect(verdict?.message).not.toContain('Error code'); + expect(verdict?.message).toContain('How: run'); + }); + it(`should keep the last error, which is the one the child stopped on`, () => { const verdict = parseDetachedChildVerdict([ 'CommandError: the first one, recovered from', diff --git a/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts index 90753ce0..591119ad 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts @@ -80,6 +80,7 @@ describe(buildDetachSpawn, () => { // 7's F61 and the 2026-08-26 live run's S4. Three facts in, one verdict out. describe(resolveDetachFailure, () => { const healthy = { exited: false, verdict: null, statusAnswering: true }; + const noCode = { code: null, exitCode: null, suggestedCommand: null }; it(`should let a live child with a live bundler through`, () => { expect(resolveDetachFailure(healthy)).toBeNull(); @@ -99,7 +100,7 @@ describe(resolveDetachFailure, () => { expect( resolveDetachFailure({ exited: true, - verdict: { scenario: 'macos-automation', message: 'macOS refused' }, + verdict: { scenario: 'macos-automation', message: 'macOS refused', ...noCode }, statusAnswering: false, }) ).toBe('needs-human'); @@ -109,7 +110,7 @@ describe(resolveDetachFailure, () => { expect( resolveDetachFailure({ ...healthy, - verdict: { scenario: 'expo-prompt', message: 'Input is required' }, + verdict: { scenario: 'expo-prompt', message: 'Input is required', ...noCode }, }) ).toBe('needs-human'); }); @@ -119,7 +120,7 @@ describe(resolveDetachFailure, () => { expect( resolveDetachFailure({ exited: true, - verdict: { scenario: null, message: 'CommandError: the plan stopped' }, + verdict: { scenario: null, message: 'CommandError: the plan stopped', ...noCode }, statusAnswering: true, }) ).toBe('child-exited'); diff --git a/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts index 54fbb71a..645e3016 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts @@ -5,8 +5,10 @@ import { vol } from 'memfs'; import path from 'path'; import { readDevServerLockAsync } from '../../devLock'; +import { acquireDeviceAsync, devicesDisabled } from '../../deviceBinding'; import { event as cliEvent } from '../../events'; import { waitForBundlerReadyAsync, type BundlerReadyResult } from '../../runtime/waitReady'; +import { formatErrorCodeRow } from '../../utils/errors'; import { fetchAdvertisedUrlAsync } from '../advertisedUrl'; import { devDetachAsync, OPEN_PLATFORM_GRACE_MS } from '../detachAsync'; import { detachedLogPath } from '../logFile'; @@ -14,6 +16,11 @@ import { isProcessAlive } from '../processLiveness'; import { resolveDevOptions } from '../resolveOptions'; vi.mock('../../devLock', () => ({ readDevServerLockAsync: vi.fn() })); +vi.mock('../../deviceBinding', async (importOriginal) => ({ + ...(await importOriginal()), + devicesDisabled: vi.fn(() => true), + acquireDeviceAsync: vi.fn(), +})); vi.mock('../../runtime/waitReady', () => ({ waitForBundlerReadyAsync: vi.fn() })); vi.mock('../processLiveness', () => ({ isProcessAlive: vi.fn() })); vi.mock('../events', () => ({ event: vi.fn() })); @@ -502,3 +509,117 @@ describe('a lock whose port Metro has not logged', () => { expect(Date.now()).toBeLessThan(20_000); }); }); + +// @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it +describe('the bound device', () => { + const running = { + url: 'http://127.0.0.1:8393', + port: 8393, + pid: 4242, + projectRoot, + startedAt: new Date(0).toISOString(), + }; + const reused = { + device: { + backend: 'local-ios' as const, + platform: 'ios' as const, + udid: 'SIM-1', + name: 'agent-cli 0000', + origin: 'created' as const, + }, + justBooted: true, + action: 'reused' as const, + }; + + beforeEach(() => { + vi.mocked(devicesDisabled).mockReturnValue(false); + vi.mocked(acquireDeviceAsync).mockResolvedValue(reused); + }); + + // already-running-reuses-only + it('boots the parked simulator on a running server, reuse only, before the report', async () => { + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(running); + const { event: cliEvent } = await import('../../events'); + + await expect( + devDetachAsync(projectRoot, resolveDevOptions(['--ios', '--detach', '--local']), { + print: false, + reuseBoundDevice: true, + }) + ).resolves.toBe(0); + + expect(acquireDeviceAsync).toHaveBeenCalledWith(projectRoot, 'ios', { reuseOnly: true }); + expect(spawn).not.toHaveBeenCalled(); + expect(cliEvent).toHaveBeenCalledWith( + 'dev_detach', + expect.objectContaining({ alreadyRunning: true }) + ); + }); + + it('relays the refusal of a running server with nothing to reuse', async () => { + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(running); + vi.mocked(acquireDeviceAsync).mockRejectedValue( + Object.assign(new Error('nothing to reuse'), { code: 'DEVICE_UNAVAILABLE', exitCode: 20 }) + ); + + await expect( + devDetachAsync(projectRoot, resolveDevOptions(['--ios', '--detach', '--local']), { + print: false, + reuseBoundDevice: true, + }) + ).rejects.toMatchObject({ code: 'DEVICE_UNAVAILABLE', exitCode: 20 }); + }); + + // smoke-never-reaches-already-running-reuse + it.each([ + ['without reuseBoundDevice', ['--ios', '--detach', '--local'], {}], + ['for --android', ['--android', '--detach', '--local'], { reuseBoundDevice: true }], + ['for --eas', ['--ios', '--detach', '--eas'], { reuseBoundDevice: true }], + ])('binds nothing on a running server %s', async (_case, argv, extra) => { + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(running); + + await expect( + devDetachAsync(projectRoot, resolveDevOptions(argv), { print: false, ...extra }) + ).resolves.toBe(0); + + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + }); + + // detached-device-error-relayed + it("rethrows the child's device refusal unchanged: code, exit and Try line", async () => { + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(null); + let child: EventEmitter; + vi.mocked(spawn).mockImplementation(() => { + child = Object.assign(new EventEmitter(), { pid: 4242, unref: vi.fn() }); + fs.writeFileSync( + logFile, + [ + 'CommandError: No iOS runtime with an iPhone is installed, so no simulator can be created.', + 'How: run "xcodebuild -downloadPlatform iOS", then this command again.', + 'Try: xcodebuild -downloadPlatform iOS', + formatErrorCodeRow('DEVICE_UNAVAILABLE', 7), + '', + ].join('\n') + ); + setTimeout(() => child.emit('exit', 7, null), 50); + return child as unknown as ChildProcess; + }); + + const result = devDetachAsync( + projectRoot, + resolveDevOptions(['--ios', '--detach', '--local']), + { print: false, reuseBoundDevice: true } + ).catch((error) => error); + await vi.runAllTimersAsync(); + + const error = await result; + expect(error).toMatchObject({ + code: 'DEVICE_UNAVAILABLE', + exitCode: 7, + suggestedCommand: 'xcodebuild -downloadPlatform iOS', + }); + expect(error.message).toBe( + 'No iOS runtime with an iPhone is installed, so no simulator can be created.\nHow: run "xcodebuild -downloadPlatform iOS", then this command again.' + ); + }); +}); diff --git a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts index 7c455c83..1b027f8f 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts @@ -2,6 +2,7 @@ import { vol } from 'memfs'; import os from 'os'; import path from 'path'; +import { acquireDeviceAsync, devicesDisabled, inspectBindingAsync } from '../../deviceBinding'; import type { FollowUp } from '../../followups'; import { Log } from '../../log'; import { emitStartPlan } from '../../plan/emit'; @@ -77,6 +78,14 @@ vi.mock('../../toolchain', async () => { vi.mock('../../device/localDevice', () => ({ probeLocalDeviceAsync: vi.fn(async () => ({ state: 'unknown', device: null, reason: null })), })); +// The registry is off by default, as in the e2e harness; the rows about the binding turn it on +// and script what `acquire` answers (llp/0035 §How `dev` uses it). +vi.mock('../../deviceBinding', async (importOriginal) => ({ + ...(await importOriginal()), + devicesDisabled: vi.fn(() => true), + acquireDeviceAsync: vi.fn(), + inspectBindingAsync: vi.fn(), +})); // The busy-port stop names the process on the port and probes it as a dev server; a unit test // must not read this machine's listeners. vi.mock('../portListener', async () => ({ @@ -1370,4 +1379,143 @@ describe(devAsync, () => { }); }); }); + + // @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it + describe('the bound device', () => { + const device = { + backend: 'local-ios' as const, + platform: 'ios' as const, + udid: 'SIM-1', + name: 'agent-cli 0000', + origin: 'created' as const, + }; + + beforeEach(() => { + vi.mocked(devicesDisabled).mockReturnValue(false); + vi.mocked(acquireDeviceAsync).mockResolvedValue({ + device, + justBooted: true, + action: 'created', + }); + vi.mocked(inspectBindingAsync).mockResolvedValue({ + binding: null, + path: '/b', + state: 'none', + }); + }); + + afterEach(() => { + vi.mocked(devicesDisabled).mockReturnValue(true); + }); + + // withDevice-after-forwarded-args + it(`binds after the refusals and pins the build step to the simulator`, async () => { + mockStaleDevClientState(); + + await expect(devAsync(projectRoot, resolveDevOptions(['--ios', '--local']))).resolves.toBe(0); + + expect(acquireDeviceAsync).toHaveBeenCalledWith(projectRoot, 'ios'); + expect(runDevServerAsync).toHaveBeenCalledWith( + projectRoot, + ['run:ios', '--port', '8081', '--device', 'SIM-1'], + expect.anything() + ); + // The plan an agent reads carries the pin too: the plan approved is the plan run. + expect(emitStartPlan).toHaveBeenCalledWith( + expect.objectContaining({ + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'run', + argv: expect.arrayContaining(['--device', 'SIM-1']), + }), + ]), + }), + expect.anything() + ); + }); + + it(`binds nothing for a run that refuses its EAS route`, async () => { + mockStaleDevClientState(); + vi.mocked(probeProjectStateAsync).mockResolvedValue(mockStaleDevClientState()); + const { detectToolchainAsync } = await import('../../toolchain'); + vi.mocked(detectToolchainAsync).mockResolvedValue({ + platform: 'ios', + status: 'missing', + detail: 'no Xcode', + requirement: 'Xcode', + caveats: [], + impossible: true, + }); + + await expect(devAsync(projectRoot, resolveDevOptions(['--ios']))).rejects.toMatchObject({ + code: 'EAS_ROUTE_NOT_CHOSEN', + }); + + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + }); + + it(`hands the open the device it bound`, async () => { + mockProjectState(); + const { openAppOnDeviceAsync } = await import('../openApp'); + vi.mocked(runDevServerAsync).mockImplementation(async (_root, _args, opts) => { + opts?.onDevServer?.({ url: 'http://127.0.0.1:8081', port: 8081 } as never); + return devServerRun(); + }); + + await devAsync(projectRoot, resolveDevOptions(['--ios'])); + + expect(openAppOnDeviceAsync).toHaveBeenCalledWith( + projectRoot, + expect.objectContaining({ device, justBooted: true }) + ); + }); + + describe('--plan', () => { + it(`pins the steps to the own binding when it is up or down, and acquires nothing`, async () => { + mockStaleDevClientState(); + vi.mocked(inspectBindingAsync).mockResolvedValue({ + binding: { + version: 1, + device, + projectRoot, + boundAt: '2026-10-08T09:00:00.000Z', + expiresAt: '2026-10-08T11:00:00.000Z', + }, + path: '/b', + state: 'not-up', + }); + + await devAsync(projectRoot, resolveDevOptions(['--ios', '--local', '--plan'])); + + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + expect(emitStartPlan).toHaveBeenCalledWith( + expect.objectContaining({ + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'run', + argv: expect.arrayContaining(['--device', 'SIM-1']), + }), + ]), + }), + expect.anything() + ); + }); + + it(`says the run will reuse or create a simulator when none is bound`, async () => { + mockStaleDevClientState(); + + await devAsync(projectRoot, resolveDevOptions(['--ios', '--local', '--plan'])); + + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + expect(emitStartPlan).toHaveBeenCalledWith( + expect.objectContaining({ + reasons: expect.arrayContaining([ + expect.stringContaining('reuse or create a simulator'), + ]), + }), + expect.anything() + ); + }); + }); + }); }); diff --git a/packages/@expo/agent-cli/src/dev/__tests__/forwardedArgs-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/forwardedArgs-test.ts index f8ec0efe..12b386e8 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/forwardedArgs-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/forwardedArgs-test.ts @@ -5,6 +5,7 @@ import type { PlanStep, StartPlan } from '../../project/types'; import { forwardedStepArgs, + withDevice, withDevServerPort, withForwardedExpoArgs, withoutPortArgs, @@ -146,3 +147,32 @@ describe(withDevServerPort, () => { ]); }); }); + +// @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it — withDevice-after-forwarded-args: the +// bound device replaces any `--device` a step carries, on the steps that build or install only. +describe(withDevice, () => { + it(`should pin run and install to the device, replacing one already named`, () => { + const result = withDevice( + plan( + step('prebuild', ['expo', 'prebuild', '--platform', 'ios']), + step('run', ['expo', 'run:ios', '--device', 'OLD', '--port', '8082']), + step('install', ['expo', 'run:ios', '--no-bundler', '--device=OLD']), + step('start', ['expo', 'start', '--dev-client', '--device', 'OLD']) + ), + 'SIM-1' + ); + + expect(result.steps.map((one) => one.argv)).toEqual([ + ['expo', 'prebuild', '--platform', 'ios'], + ['expo', 'run:ios', '--port', '8082', '--device', 'SIM-1'], + ['expo', 'run:ios', '--no-bundler', '--device', 'SIM-1'], + ['expo', 'start', '--dev-client', '--device', 'OLD'], + ]); + }); + + it(`should leave a plan with neither step as it is`, () => { + const serving = plan(step('start', ['expo', 'start'])); + + expect(withDevice(serving, 'SIM-1')).toEqual(serving); + }); +}); diff --git a/packages/@expo/agent-cli/src/dev/__tests__/openApp-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/openApp-test.ts index 959a4dcf..549c2bd4 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/openApp-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/openApp-test.ts @@ -3,15 +3,16 @@ import { checkExpoGoVersionAsync } from '../../device/expoGoVersion'; import { installExpoGoAsync } from '../../device/installExpoGo'; import { simulatorHasAppAsync } from '../../device/installedApps'; import { androidHasAppAsync } from '../../device/androidApps'; -import { probeAndroidDeviceAsync, probeIosSimulatorAsync } from '../../navigate/device'; +import type { BoundDevice } from '../../deviceBinding'; +import { probeAndroidDeviceAsync } from '../../navigate/device'; import { openRouteAsync } from '../../navigate/openRoute'; import { CommandError } from '../../utils/errors'; import { openAppOnDeviceAsync } from '../openApp'; vi.mock('../../log'); vi.mock('../events', () => ({ event: vi.fn(), debugEvent: vi.fn() })); -vi.mock('../../navigate/device', () => ({ - probeIosSimulatorAsync: vi.fn(), +vi.mock('../../navigate/device', async (importOriginal) => ({ + ...(await importOriginal()), probeAndroidDeviceAsync: vi.fn(), })); vi.mock('../../device/bootDevice', () => ({ @@ -28,11 +29,13 @@ vi.mock('../../project/nodeModules', () => ({ readSdkVersionAsync: vi.fn(async ( const projectRoot = '/project'; const DEV_SERVER = 'http://127.0.0.1:8081'; -function mockBootedSimulator(udid = 'UDID-1') { - vi.mocked(probeIosSimulatorAsync).mockResolvedValue({ - device: { backend: 'local-ios', platform: 'ios', deviceId: udid }, - } as any); -} +const BOUND: BoundDevice = { + backend: 'local-ios', + platform: 'ios', + udid: 'UDID-1', + name: 'agent-cli 0000', + origin: 'created', +}; function mockOpenOk() { vi.mocked(openRouteAsync).mockResolvedValue({ @@ -58,17 +61,17 @@ afterEach(() => { }); describe(openAppOnDeviceAsync, () => { - it(`opens on the simulator that is already booted, and boots nothing`, async () => { - mockBootedSimulator(); - + it(`opens on the bound simulator, probes nothing and boots nothing`, async () => { const report = await openAppOnDeviceAsync(projectRoot, { platform: 'ios', expoGo: true, devServerUrl: DEV_SERVER, + device: BOUND, }); expect(report).toMatchObject({ opened: true, deviceId: 'UDID-1', booted: false, reason: null }); expect(bootDeviceAsync).not.toHaveBeenCalled(); + expect(probeAndroidDeviceAsync).not.toHaveBeenCalled(); expect(openRouteAsync).toHaveBeenCalledWith( projectRoot, expect.objectContaining({ @@ -76,88 +79,102 @@ describe(openAppOnDeviceAsync, () => { platform: 'ios', devServerUrl: DEV_SERVER, devServerUrlSource: 'discovered', + device: BOUND, }) ); }); - it(`boots a device when none is up`, async () => { - vi.mocked(probeIosSimulatorAsync).mockResolvedValue({ - device: null, - reason: 'none booted', - } as any); + it(`reports the boot the registry did`, async () => { + const report = await openAppOnDeviceAsync(projectRoot, { + platform: 'ios', + expoGo: true, + devServerUrl: DEV_SERVER, + device: BOUND, + justBooted: true, + }); + + expect(report).toMatchObject({ opened: true, booted: true }); + }); + + it(`stops on iOS with no bound device`, async () => { + const report = await openAppOnDeviceAsync(projectRoot, { + platform: 'ios', + expoGo: true, + devServerUrl: DEV_SERVER, + }); + + expect(report).toMatchObject({ opened: false, reason: expect.stringContaining('bound') }); + expect(openRouteAsync).not.toHaveBeenCalled(); + }); + + it(`boots an emulator when none is up`, async () => { + vi.mocked(probeAndroidDeviceAsync).mockResolvedValue({ device: null, reason: 'none' }); vi.mocked(bootDeviceAsync).mockResolvedValue({ ok: true, - deviceId: 'UDID-2', - backend: 'local-ios', - name: 'iPhone', + deviceId: 'emulator-5554', + backend: 'local-android', + name: 'Pixel', reason: null, - refused: false, - choice: 'first available', + choice: 'the only AVD', }); + vi.mocked(androidHasAppAsync).mockResolvedValue(true); const report = await openAppOnDeviceAsync(projectRoot, { - platform: 'ios', + platform: 'android', expoGo: true, devServerUrl: DEV_SERVER, }); - expect(report).toMatchObject({ opened: true, deviceId: 'UDID-2', booted: true }); + expect(report).toMatchObject({ opened: true, deviceId: 'emulator-5554', booted: true }); }); - it(`stops with the boot's own reason when no device comes up`, async () => { - vi.mocked(probeIosSimulatorAsync).mockResolvedValue({ - device: null, - reason: 'none booted', - } as any); + it(`stops with the boot's own reason when no emulator comes up`, async () => { + vi.mocked(probeAndroidDeviceAsync).mockResolvedValue({ device: null, reason: 'none' }); vi.mocked(bootDeviceAsync).mockResolvedValue({ ok: false, deviceId: null, backend: null, name: null, - reason: 'no simulator runtime is installed', - refused: false, + reason: 'no Android virtual device exists', choice: null, }); const report = await openAppOnDeviceAsync(projectRoot, { - platform: 'ios', + platform: 'android', expoGo: true, devServerUrl: DEV_SERVER, }); - expect(report).toMatchObject({ opened: false, reason: 'no simulator runtime is installed' }); + expect(report).toMatchObject({ opened: false, reason: 'no Android virtual device exists' }); expect(openRouteAsync).not.toHaveBeenCalled(); }); it(`stops on a missing device tool without booting anything`, async () => { - vi.mocked(probeIosSimulatorAsync).mockResolvedValue({ + vi.mocked(probeAndroidDeviceAsync).mockResolvedValue({ device: null, - reason: 'could not run "xcrun simctl"', - toolError: new CommandError( - 'XCRUN_NOT_RUNNABLE', - 'Could not run "xcrun simctl".\nWhy: not found.' - ), - } as any); + reason: 'could not run "adb"', + toolError: new CommandError('ADB_NOT_RUNNABLE', 'Could not run "adb".\nWhy: not found.'), + }); const report = await openAppOnDeviceAsync(projectRoot, { - platform: 'ios', + platform: 'android', expoGo: true, devServerUrl: DEV_SERVER, }); expect(report.opened).toBe(false); - expect(report.reason).toContain('xcrun simctl'); + expect(report.reason).toContain('adb'); expect(bootDeviceAsync).not.toHaveBeenCalled(); }); it(`installs Expo Go when the device has not got it`, async () => { - mockBootedSimulator(); vi.mocked(simulatorHasAppAsync).mockResolvedValue(false); const report = await openAppOnDeviceAsync(projectRoot, { platform: 'ios', expoGo: true, devServerUrl: DEV_SERVER, + device: BOUND, }); expect(installExpoGoAsync).toHaveBeenCalledWith('UDID-1', 'ios', '54.0.0'); @@ -165,13 +182,13 @@ describe(openAppOnDeviceAsync, () => { }); it(`replaces an Expo Go whose version does not match the SDK's release`, async () => { - mockBootedSimulator(); vi.mocked(checkExpoGoVersionAsync).mockResolvedValue({ verdict: 'mismatch' } as any); const report = await openAppOnDeviceAsync(projectRoot, { platform: 'ios', expoGo: true, devServerUrl: DEV_SERVER, + device: BOUND, }); expect(installExpoGoAsync).toHaveBeenCalled(); @@ -179,12 +196,11 @@ describe(openAppOnDeviceAsync, () => { }); it(`installs nothing for a development build, whose install is a build`, async () => { - mockBootedSimulator(); - await openAppOnDeviceAsync(projectRoot, { platform: 'ios', expoGo: false, devServerUrl: DEV_SERVER, + device: BOUND, }); expect(simulatorHasAppAsync).not.toHaveBeenCalled(); @@ -195,7 +211,7 @@ describe(openAppOnDeviceAsync, () => { it(`asks adb on Android, through the same door`, async () => { vi.mocked(probeAndroidDeviceAsync).mockResolvedValue({ device: { backend: 'local-android', platform: 'android', deviceId: 'emulator-5554' }, - } as any); + }); vi.mocked(androidHasAppAsync).mockResolvedValue(true); vi.mocked(checkExpoGoVersionAsync).mockResolvedValue({ verdict: 'match' } as any); @@ -210,12 +226,11 @@ describe(openAppOnDeviceAsync, () => { }); it(`goes no further once the dev server is gone`, async () => { - mockBootedSimulator(); - const report = await openAppOnDeviceAsync(projectRoot, { platform: 'ios', expoGo: true, devServerUrl: DEV_SERVER, + device: BOUND, stillWanted: () => false, }); @@ -225,7 +240,6 @@ describe(openAppOnDeviceAsync, () => { }); it(`reports a refused deep link instead of throwing`, async () => { - mockBootedSimulator(); vi.mocked(openRouteAsync).mockResolvedValue({ exitCode: 1, command: 'xcrun simctl openurl', @@ -235,6 +249,7 @@ describe(openAppOnDeviceAsync, () => { platform: 'ios', expoGo: true, devServerUrl: DEV_SERVER, + device: BOUND, }); expect(report.opened).toBe(false); @@ -242,18 +257,18 @@ describe(openAppOnDeviceAsync, () => { }); it(`turns a thrown open into a reason, never a rejection`, async () => { - mockBootedSimulator(); vi.mocked(openRouteAsync).mockRejectedValue( - new CommandError('NO_IOS_DEVICE', 'No booted iOS simulator was found.\nWhy: gone.') + new CommandError('NO_BOUND_DEVICE', 'The bound ios device is gone.\nWhy: gone.') ); const report = await openAppOnDeviceAsync(projectRoot, { platform: 'ios', expoGo: true, devServerUrl: DEV_SERVER, + device: BOUND, }); expect(report.opened).toBe(false); - expect(report.reason).toContain('No booted iOS simulator'); + expect(report.reason).toContain('The bound ios device is gone'); }); }); diff --git a/packages/@expo/agent-cli/src/dev/childVerdict.ts b/packages/@expo/agent-cli/src/dev/childVerdict.ts index 60ba83e4..85bdaca6 100644 --- a/packages/@expo/agent-cli/src/dev/childVerdict.ts +++ b/packages/@expo/agent-cli/src/dev/childVerdict.ts @@ -29,6 +29,15 @@ export interface DetachedChildVerdict { scenario: string | null; /** The child's own message, as it printed it, without the handoff block under it. */ message: string; + /** + * The `CommandError` code and exit of the child's failure, from the row `logCmdError` writes + * for a detached child alone, so a device refusal reaches the parent's caller unchanged + * (llp/0035 §Output and errors). Null for a log written by nothing of this CLI's. + */ + code: string | null; + exitCode: number | null; + /** The child's `Try:` line, which is its How line as a command. */ + suggestedCommand: string | null; } /** `NeedsHumanError: ` — `Error.toString()` as `Log.exception` prints it. */ @@ -37,8 +46,14 @@ const ERROR_LINE = /^([A-Za-z]*Error): (.*)$/; /** `Needs a human ` — the first row of {@link formatNeedsHumanBlock}'s block. */ const NEEDS_HUMAN_ROW = /^Needs a human\s{2,}(\S+)\s*$/; +/** `Error code exit ` — {@link formatErrorCodeRow}'s row. */ +const ERROR_CODE_ROW = /^Error code\s{2,}(\S+) exit (\d+)\s*$/; + +/** `Try: ` — the last line of every failure that names a next command. */ +const TRY_LINE = /^Try: (.+)$/; + /** The rest of that block, which the parent re-renders from the registry rather than quoting. */ -const HANDOFF_ROW = /^(?:Ask the user|Or set|Try:)\s{2,}?\S/; +const HANDOFF_ROW = /^(?:Ask the user|Or set|Error code|Try:)\s{2,}?\S/; /** * Read the child's verdict out of its log, or null when the log holds none. @@ -57,6 +72,9 @@ export function parseDetachedChildVerdict( // test hands over as one multi-line string is the same text. const lines = rawLines.flatMap((line) => line.split('\n')); let scenario: string | null = null; + let code: string | null = null; + let exitCode: number | null = null; + let suggestedCommand: string | null = null; let errorAt = -1; for (let index = 0; index < lines.length; index++) { @@ -66,6 +84,17 @@ export function parseDetachedChildVerdict( scenario = handoff[1]!; continue; } + const coded = ERROR_CODE_ROW.exec(line); + if (coded) { + code = coded[1]!; + exitCode = Number(coded[2]); + continue; + } + const tried = TRY_LINE.exec(line); + if (tried) { + suggestedCommand = tried[1]!.trim(); + continue; + } if (ERROR_LINE.test(line)) { errorAt = index; } @@ -77,11 +106,18 @@ export function parseDetachedChildVerdict( const message = lines .slice(errorAt < 0 ? lines.length : errorAt) - .filter((line) => !NEEDS_HUMAN_ROW.test(line) && !HANDOFF_ROW.test(line)) + .filter( + (line) => !NEEDS_HUMAN_ROW.test(line) && !HANDOFF_ROW.test(line) && !TRY_LINE.test(line) + ) .join('\n') .trim(); - return { scenario, message }; + return { scenario, message, code, exitCode, suggestedCommand }; +} + +/** The codes the parent of a detached run rethrows unchanged (llp/0035 §Output and errors). */ +export function isRelayedDeviceCode(code: string | null): code is string { + return code != null && (code === 'NO_BOUND_DEVICE' || code.startsWith('DEVICE_')); } /** diff --git a/packages/@expo/agent-cli/src/dev/detachAsync.ts b/packages/@expo/agent-cli/src/dev/detachAsync.ts index b6e954ed..4c00a0ed 100644 --- a/packages/@expo/agent-cli/src/dev/detachAsync.ts +++ b/packages/@expo/agent-cli/src/dev/detachAsync.ts @@ -23,6 +23,7 @@ import { spawn } from 'child_process'; import fs from 'fs'; import { readDevServerLockAsync, type DevServerLockInfo } from '../devLock'; +import { acquireDeviceAsync, acquireLine, devicesDisabled } from '../deviceBinding'; import { PORT_WATCH_TIMEOUT_MS, readLastLoggedDevServerPort } from '../devLock/port'; import { event as cliEvent } from '../events'; import { EXIT_OUTCOME_FAILED } from '../exitCodes'; @@ -40,6 +41,7 @@ import { requestsTunnel } from '../start/followUps'; import { CommandError } from '../utils/errors'; import { fetchAdvertisedUrlAsync, readDevServerLogSync } from './advertisedUrl'; import { + isRelayedDeviceCode, parseDetachedChildPhase, parseDetachedChildPort, parseDetachedChildVerdict, @@ -179,6 +181,15 @@ export interface DevDetachOptions { * way, so a suppressed report is never a silent start. */ print?: boolean; + /** + * On a dev server that is already running, boot this worktree's own simulator and refuse when + * it has none to reuse. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it + * `dev` passes true: its caller asked for the app on a device, and the device created now would + * carry no app, so only a reuse serves. `smoke` leaves it unset and binds in its own boot phase. + */ + reuseBoundDevice?: boolean; } /** @@ -191,7 +202,7 @@ export interface DevDetachOptions { export async function devDetachAsync( projectRoot: string, options: DevOptions, - { print = true }: DevDetachOptions = {} + { print = true, reuseBoundDevice = false }: DevDetachOptions = {} ): Promise { const startedAt = Date.now(); @@ -204,6 +215,14 @@ export async function devDetachAsync( // lock, so nothing would be able to find it or stop it afterwards. const running = await readDevServerLockAsync(projectRoot); if (running) { + if ( + reuseBoundDevice && + options.platform === 'ios' && + options.deviceBackend !== 'eas' && + !devicesDisabled() + ) { + Log.progress(acquireLine(await acquireDeviceAsync(projectRoot, 'ios', { reuseOnly: true }))); + } const hasExited = () => !isProcessAlive(running.pid); async function checkReuseFailureAsync( lock: DevServerLockInfo, @@ -271,8 +290,13 @@ export async function devDetachAsync( // Both streams into the log. `ignore` on stdin, because a detached process has no terminal to // read one from, and an inherited stdin would keep this shell attached to it. stdio: ['ignore', fd, fd], - // The parent command owns usage telemetry; this child only continues its work. - env: { ...process.env, __EXPO_AGENT_CLI_INTERNAL_INVOCATION: '1' }, + // The parent command owns usage telemetry; this child only continues its work. The second + // variable makes the child's failures name their code in the log, which this process reads back. + env: { + ...process.env, + __EXPO_AGENT_CLI_INTERNAL_INVOCATION: '1', + __EXPO_AGENT_CLI_DETACHED: '1', + }, }); // The child owns the file now; a descriptor left open here would keep the parent alive. fs.closeSync(fd); @@ -304,6 +328,13 @@ export async function devDetachAsync( hasExited, }); if (!lock) { + // A device refusal in the child ends it before any lock, and reaches the caller unchanged. + const relayed = relayedDeviceError( + childExit ? await waitForChildVerdictAsync(projectRoot) : readChildVerdictSync(projectRoot) + ); + if (relayed) { + throw relayed; + } throw notStartedError( projectRoot, logFile, @@ -584,13 +615,32 @@ async function waitForChildVerdictAsync( ): Promise { let verdict = readChildVerdictSync(projectRoot, since); const deadline = Date.now() + VERDICT_WAIT_MS; - while (verdict?.scenario == null && Date.now() < deadline) { + // The code row is the last line `logCmdError` writes, so a verdict with one is complete. + while (verdict?.scenario == null && verdict?.code == null && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, OPEN_PLATFORM_POLL_MS)); verdict = readChildVerdictSync(projectRoot, since) ?? verdict; } return verdict; } +/** + * A device refusal the child raised, rebuilt as the child raised it: code, exit and How line. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §Output and errors — the agent's recovery is the + * child's own `dev` or `dev:stop` command, not `dev:logs`. + */ +function relayedDeviceError(verdict: DetachedChildVerdict | null): CommandError | null { + if (verdict == null || !isRelayedDeviceCode(verdict.code)) { + return null; + } + const error = new CommandError(verdict.code, verdict.message.replace(/^[A-Za-z]*Error: /, '')); + error.exitCode = verdict.exitCode ?? EXIT_OUTCOME_FAILED; + if (verdict.suggestedCommand) { + error.suggestedCommand = verdict.suggestedCommand; + } + return error; +} + /** * Why a `--wait-ready` that never got its answer is the child's failure rather than the wait's. * @@ -738,6 +788,10 @@ function detachFailureError( platform: PlanPlatform; } ): CommandError { + const relayed = relayedDeviceError(verdict); + if (relayed) { + return relayed; + } if (kind === 'needs-human' && verdict?.scenario != null) { const scenario = findNeedsHumanScenario(verdict.scenario); const message = [ diff --git a/packages/@expo/agent-cli/src/dev/devAsync.ts b/packages/@expo/agent-cli/src/dev/devAsync.ts index 35153a47..f700cd2c 100644 --- a/packages/@expo/agent-cli/src/dev/devAsync.ts +++ b/packages/@expo/agent-cli/src/dev/devAsync.ts @@ -6,6 +6,14 @@ import type { OpenAppOnEasReport } from './openAppEas'; import { outputTail } from '../deploy/parseOutput'; import { readLastLoggedDevServerPort, readPortArg } from '../devLock/port'; +import { + acquireDeviceAsync, + acquireLine, + deviceIdOf, + devicesDisabled, + inspectBindingAsync, + type AcquireResult, +} from '../deviceBinding'; import { EXIT_OUTCOME_FAILED } from '../exitCodes'; import { buildStartPlanFollowUps, @@ -56,6 +64,7 @@ import { event as devEvent } from './events'; import { forwardedStepArgs, isDevServerStep, + withDevice, withDevServerPort, withForwardedExpoArgs, withPortArg, @@ -86,18 +95,23 @@ export async function devAsync(projectRoot: string, options: DevOptions): Promis // the child does the probe: this run's whole job is to start that child and report on it. if (options.detach) { const { devDetachAsync } = require('./detachAsync') as typeof import('./detachAsync'); - return await devDetachAsync(projectRoot, options); + return await devDetachAsync(projectRoot, options, { reuseBoundDevice: true }); } const state = await probeProjectStateAsync(projectRoot, { fingerprintCache: options.fingerprintCache, }); + // @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it — the resolver binds the device for a + // draft that builds nothing, before its presence probe; a draft that builds is bound below, after + // the refusals, so no simulator is created for a run that then refuses. + const acquireDevice = deviceCallbackFor(projectRoot, options); // @ref llp/0015-backend-selection-and-config.rfc.md §The selection // One call that folds in everything outside the project: the developer's config, the flags they // typed, this host and the toolchain probe. The backend is chosen **here**, before the plan is // printed, so the steps an agent approves are the steps that run — never swapped mid-run // (llp/0008 §Plan-with-cost dry run). - const resolved = await resolveStartPlanAsync(projectRoot, state, { + const { plan: resolved, acquired: boundEarly } = await resolveStartPlanAsync(projectRoot, state, { + acquireDevice, // The caller's own flag, which the resolver requires: the plan builds for this platform and // `expo start` opens the app on it — booting a simulator or an emulator when none is up, the // same way `expo run:ios` and `expo run:android` do. @@ -156,10 +170,19 @@ export async function devAsync(projectRoot: string, options: DevOptions): Promis ); } + // A draft that builds is bound here, not in the resolver: after the port check above and the + // refusals below, so a run that refuses creates no simulator, and `--plan` only inspects. + const bindsHere = resolved.buildLocation != null; + let acquired = boundEarly; + // @ref llp/0009-smart-followups.rfc.md §Examples per command // `--plan` stops here, so its follow-ups are about the plan itself and the plan object is the // whole answer. if (options.mode === 'plan') { + if (bindsHere) { + acquired = await acquireDevice(plan); + } + plan = withPlannedDevice(plan, acquired, options); const followups = followUpsEnabled(options.followups) ? // The typed flag, not the resolved platform: this is the plan the caller asked for, and the // command that runs it has to ask for the same one (F103). @@ -181,6 +204,13 @@ export async function devAsync(projectRoot: string, options: DevOptions): Promis await assertEasProjectConfiguredAsync(projectRoot); } + if (bindsHere) { + acquired = await acquireDevice(plan); + } + if (acquired) { + plan = withDevice(plan, deviceIdOf(acquired.device)); + } + // @ref llp/0010-agent-conventions.rfc.md §The `--json` error envelope // The plan is always emitted before anything runs — on the `cli:start_plan` event for a driving // agent, and as a table for a person. In `--json` mode it is *not* printed here: stdout is @@ -218,7 +248,7 @@ export async function devAsync(projectRoot: string, options: DevOptions): Promis // above, and then it runs. `dev` was asked to get this app onto a device, and a build is how that // is done; there is no second act of consent, in a terminal or out of one. `--plan` is the run // that stops. - const run = await executePlanAsync(projectRoot, plan, state, options); + const run = await executePlanAsync(projectRoot, plan, state, options, acquired); // @ref llp/0010-agent-conventions.rfc.md §The `--json` error envelope // A plan whose step failed has no result to report, so it reports a failure. It used to print @@ -241,6 +271,70 @@ export async function devAsync(projectRoot: string, options: DevOptions): Promis return run.exitCode; } +/** + * The callback the resolver runs for a native draft, and `devAsync` runs for a draft that builds. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it + * iOS only, until llp/0035 §Android instance binds Android; nothing for `--eas`, whose device is a session; nothing + * under `AGENT_CLI_NO_DEVICE`. A run first refuses a named `--port` that is taken, when the draft + * serves, so no simulator is created for a run that then stops on the port. `--plan` inspects the + * own binding and acquires nothing. + */ +function deviceCallbackFor( + projectRoot: string, + options: DevOptions +): (draft: StartPlan) => Promise { + return async (draft) => { + if (!bindsDevice(options)) { + return null; + } + if (options.mode === 'plan') { + return await inspectOwnDeviceAsync(projectRoot); + } + if (options.port != null && draft.steps.some(isDevServerStep)) { + const planned = await resolvePlannedPortAsync(options.port); + if (!planned.bindable) { + throw await portDemandedError(projectRoot, options.port, options.platform); + } + } + const acquired = await acquireDeviceAsync(projectRoot, 'ios'); + Log.progress(acquireLine(acquired)); + return acquired; + }; +} + +function bindsDevice(options: DevOptions): boolean { + return options.platform === 'ios' && options.deviceBackend !== 'eas' && !devicesDisabled(); +} + +/** `--plan`'s read of the own binding: the device as `reused` when it is up or down, else null. */ +async function inspectOwnDeviceAsync(projectRoot: string): Promise { + const inspection = await inspectBindingAsync(projectRoot, 'ios', 'local-ios'); + return inspection.binding && (inspection.state === 'up' || inspection.state === 'not-up') + ? { device: inspection.binding.device, justBooted: false, action: 'reused' } + : null; +} + +/** The `--plan` view: the steps pinned to the bound device, or the reason none is pinned yet. */ +function withPlannedDevice( + plan: StartPlan, + acquired: AcquireResult | null, + options: DevOptions +): StartPlan { + if (acquired) { + return withDevice(plan, deviceIdOf(acquired.device)); + } + return bindsDevice(options) + ? { + ...plan, + reasons: [ + ...plan.reasons, + 'The run will reuse or create a simulator for this worktree, and pin the build steps to it.', + ], + } + : plan; +} + /** * The stop for a plan whose EAS route nobody chose. * @@ -350,7 +444,8 @@ async function executePlanAsync( projectRoot: string, plan: StartPlan, state: ProjectState, - options: DevOptions + options: DevOptions, + acquired: AcquireResult | null ): Promise { const output = stepOutputFor(options); let devServer: DevServerRun | null = null; @@ -409,7 +504,8 @@ async function executePlanAsync( options, server.url, () => stepRunning, - easBuildId + easBuildId, + acquired ); } : undefined, @@ -1192,7 +1288,8 @@ async function openAppForRunAsync( options: DevOptions, devServerUrl: string, stillWanted: () => boolean, - easBuildId: string | null = null + easBuildId: string | null = null, + acquired: AcquireResult | null = null ): Promise { const platform = options.platform as NativePlatform; if (options.deviceBackend === 'eas') { @@ -1214,6 +1311,8 @@ async function openAppForRunAsync( devServerUrl, stillWanted, interactive: isInteractive(), + device: acquired?.device ?? null, + justBooted: acquired?.justBooted ?? false, }); if (report.opened) { Log.progress( diff --git a/packages/@expo/agent-cli/src/dev/forwardedArgs.ts b/packages/@expo/agent-cli/src/dev/forwardedArgs.ts index 3a09e268..6e46c416 100644 --- a/packages/@expo/agent-cli/src/dev/forwardedArgs.ts +++ b/packages/@expo/agent-cli/src/dev/forwardedArgs.ts @@ -125,6 +125,38 @@ export function withPortArg(args: readonly string[], port: number): string[] { : [...rest, ...portArgs]; } +/** + * The same plan, with `--device ` on every `run` and `install` step, replacing any `--device` + * there. A plan with neither is returned as it is. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it — after `withForwardedExpoArgs`, so a + * `--device` the caller typed for `expo start` is replaced by the bound device, and before the + * `--port` block. + */ +export function withDevice(plan: StartPlan, deviceId: string): StartPlan { + return { + ...plan, + steps: plan.steps.map((step) => + step.id === 'run' || step.id === 'install' + ? { ...step, argv: [...withoutDeviceArgs(step.argv), '--device', deviceId] } + : step + ), + }; +} + +function withoutDeviceArgs(argv: readonly string[]): string[] { + const rest: string[] = []; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]!; + if (arg === '--device' || arg === '-d') { + index++; + } else if (!/^(--device|-d)=/.test(arg)) { + rest.push(arg); + } + } + return rest; +} + /** * The same plan, with `--port` on every step that serves, and on no other step. * diff --git a/packages/@expo/agent-cli/src/dev/index.ts b/packages/@expo/agent-cli/src/dev/index.ts index 11231ab2..27b2056a 100644 --- a/packages/@expo/agent-cli/src/dev/index.ts +++ b/packages/@expo/agent-cli/src/dev/index.ts @@ -63,7 +63,7 @@ export const devRunHelp: CommandHelp = { notes: [ `One command, whatever the project needs: the plan decides whether to prebuild, build, or just`, `serve. The platform flag is required; once the dev server is up, the app is opened on that`, - `platform's device — booted, and Expo Go installed, when missing. --no-open skips that step.`, + `platform's simulator bound to this worktree, with Expo Go installed when missing. --no-open skips that step.`, `This command blocks: without --detach it holds this terminal until the dev server stops, so`, `the "Suggested next" commands cannot run in it. --detach starts the same server and exits.`, `A build runs in one of two places, and the plan picks before it prints. A local build`, diff --git a/packages/@expo/agent-cli/src/dev/openApp.ts b/packages/@expo/agent-cli/src/dev/openApp.ts index 533ad8a7..4e0d8ce2 100644 --- a/packages/@expo/agent-cli/src/dev/openApp.ts +++ b/packages/@expo/agent-cli/src/dev/openApp.ts @@ -14,8 +14,9 @@ import { checkExpoGoVersionAsync } from '../device/expoGoVersion'; import { installExpoGoAsync } from '../device/installExpoGo'; import { simulatorHasAppAsync } from '../device/installedApps'; import { androidHasAppAsync } from '../device/androidApps'; +import type { BoundDevice } from '../deviceBinding'; import * as Log from '../log'; -import { probeAndroidDeviceAsync, probeIosSimulatorAsync } from '../navigate/device'; +import { navigateDeviceOf, probeAndroidDeviceAsync } from '../navigate/device'; import { openRouteAsync } from '../navigate/openRoute'; import { EXPO_GO_APP_IDS } from '../navigate/target'; import type { NativePlatform } from '../plan/types'; @@ -44,6 +45,15 @@ export interface OpenAppOptions { stillWanted?: () => boolean; /** Whether a person is watching, which is when the Simulator window is worth surfacing. */ interactive?: boolean; + /** + * The device `dev` bound and booted before the plan ran, so the open probes and boots nothing. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it — every iOS run carries one; Android + * carries none until llp/0035 §Android instance and keeps the probe-and-boot path below. + */ + device?: BoundDevice | null; + /** Whether the registry booted {@link device} for this run, which the report says. */ + justBooted?: boolean; } /** What one open amounted to. Never throws: `dev`'s server outlives a failed open. */ @@ -59,8 +69,9 @@ export interface OpenAppReport { } /** - * Boot a device if none is up, put Expo Go on it if the plan needs one, and open the app on the - * dev server — the deep-link door, which works headless and needs no macOS Automation grant. + * Take the bound simulator, or boot an emulator if none is up, put Expo Go on it if the plan needs + * one, and open the app on the dev server — the deep-link door, which works headless and needs no + * macOS Automation grant. * * Narrates each act on stderr as it starts, because a simulator boot and an Expo Go download are * both waits a caller would otherwise read as a hang. @@ -80,26 +91,27 @@ export async function openAppOnDeviceAsync( ...partial, }); - // A device: the one that is up, or one this run boots. - const probe = - platform === 'ios' ? await probeIosSimulatorAsync() : await probeAndroidDeviceAsync(); - let deviceId = probe.device?.deviceId ?? null; - let booted = false; + // A device: the one `dev` bound, else the Android one that is up, else one this run boots. + const bound = options.device ?? null; + if (platform === 'ios' && bound == null) { + return stopped('no iOS simulator is bound to this worktree'); + } + let deviceId = bound ? navigateDeviceOf(bound).deviceId : null; + let booted = bound != null && options.justBooted === true; if (deviceId == null) { - if (probe.toolError) { + const probe = await probeAndroidDeviceAsync(); + deviceId = probe.device?.deviceId ?? null; + if (deviceId == null && probe.toolError) { return stopped(firstLine(probe.toolError.message)); } + } + if (deviceId == null) { if (!stillWanted()) { return stopped('the dev server stopped before a device was booted'); } Log.progress(`No ${deviceNoun(platform)} is up — booting one.`); event('open_app_boot', { platform }); - const boot = await bootDeviceAsync(platform, { - timeoutMs: BOOT_DEVICE_TIMEOUT_MS[platform], - mayInstall: options.expoGo, - appId: options.expoGo ? EXPO_GO_APP_ID[platform] : null, - appLabel: options.expoGo ? 'Expo Go' : null, - }); + const boot = await bootDeviceAsync('android', { timeoutMs: BOOT_DEVICE_TIMEOUT_MS.android }); if (!boot.ok || boot.deviceId == null) { return stopped(boot.reason ?? 'no device could be booted'); } @@ -163,6 +175,7 @@ export async function openAppOnDeviceAsync( devServerUrlSource: 'discovered', routeCheck: false, command: 'navigate', + device: bound ?? undefined, }); if (result.exitCode !== 0) { return stopped( diff --git a/packages/@expo/agent-cli/src/device/__tests__/appPresence-test.ts b/packages/@expo/agent-cli/src/device/__tests__/appPresence-test.ts index eef92d67..497775eb 100644 --- a/packages/@expo/agent-cli/src/device/__tests__/appPresence-test.ts +++ b/packages/@expo/agent-cli/src/device/__tests__/appPresence-test.ts @@ -5,16 +5,24 @@ // `unknown` is the state every run was in before this module existed, so it can only ever plan // what was planned yesterday. +import type { BoundDevice } from '../../deviceBinding'; import type { NavigateDevice } from '../../navigate/device'; -import { probeAppPresenceAsync } from '../appPresence'; +import { probeAppPresenceAsync, type AppPresenceDevice } from '../appPresence'; import type { LocalDeviceProbe } from '../localDevice'; const APP_ID = 'com.example.app'; const projectRoot = '/project'; -function iosDevice(deviceId = 'UDID-1'): NavigateDevice { - return { backend: 'local-ios', platform: 'ios', deviceId, name: 'iPhone 17 Pro' }; -} +const BOUND: BoundDevice = { + backend: 'local-ios', + platform: 'ios', + udid: 'UDID-1', + name: 'agent-cli 0000', + origin: 'created', +}; +const reused: AppPresenceDevice = { device: BOUND, action: 'reused' }; +const created: AppPresenceDevice = { device: BOUND, action: 'created' }; +const unbound: AppPresenceDevice = { device: null, action: null }; function androidDevice(deviceId = 'emulator-5554'): NavigateDevice { return { backend: 'local-android', platform: 'android', deviceId }; @@ -29,11 +37,11 @@ function probeOf(devices: NavigateDevice[]): () => Promise { }); } -/** The defaults every row overrides one of: an iOS simulator that has the app. */ -function deps(overrides: Parameters[2] = {}) { +/** The defaults every row overrides one of: a device that has the app. */ +function deps(overrides: Parameters[3] = {}) { return { readAppId: () => APP_ID, - probeDeviceAsync: probeOf([iosDevice()]), + probeDeviceAsync: probeOf([androidDevice()]), hasAppOnDevice: async () => true, androidDeviceName: async () => 'tuft-pixel', ...overrides, @@ -47,46 +55,100 @@ beforeEach(() => { }); describe(probeAppPresenceAsync, () => { - it(`should answer present when the device has the app`, async () => { - expect(await probeAppPresenceAsync(projectRoot, 'ios', deps())).toEqual({ + it(`should answer present when the bound device has the app`, async () => { + expect(await probeAppPresenceAsync(projectRoot, 'ios', reused, deps())).toEqual({ presence: 'present', installDevice: null, }); }); - it(`should answer missing, naming the device, when it has not got the app`, async () => { + it(`should answer missing, with no device to pin, when the bound simulator has not got it`, async () => { const probe = await probeAppPresenceAsync( projectRoot, 'ios', + reused, deps({ hasAppOnDevice: async () => false }) ); - // The UDID as-is: that is what `expo run:ios --device` takes. - expect(probe).toEqual({ presence: 'missing', installDevice: 'UDID-1' }); + // `dev` pins every build step to the bound simulator itself (`withDevice`), so nothing here. + expect(probe).toEqual({ presence: 'missing', installDevice: null }); }); - it(`should name an Android device by the name expo run takes, not the serial`, async () => { + // created-device-missing-without-app-id + it(`should answer missing for a created device without asking, even with no app id`, async () => { + const hasAppOnDevice = vi.fn(async () => true); + const probe = await probeAppPresenceAsync( projectRoot, - 'android', + 'ios', + created, + deps({ readAppId: () => null, hasAppOnDevice }) + ); + + expect(probe).toEqual({ presence: 'missing', installDevice: null }); + expect(hasAppOnDevice).not.toHaveBeenCalled(); + }); + + // null-device-ios-unprobed + it(`should answer unknown on iOS with no bound device, asking nothing`, async () => { + const probeDeviceAsync = vi.fn(probeOf([androidDevice()])); + + const probe = await probeAppPresenceAsync( + projectRoot, + 'ios', + unbound, + deps({ probeDeviceAsync }) + ); + + expect(probe.presence).toBe('unknown'); + expect(probeDeviceAsync).not.toHaveBeenCalled(); + }); + + it(`should ask the bound simulator, never the local probe`, async () => { + const probeDeviceAsync = vi.fn(probeOf([androidDevice()])); + const asked: string[] = []; + await probeAppPresenceAsync( + projectRoot, + 'ios', + reused, deps({ - probeDeviceAsync: probeOf([androidDevice()]), - hasAppOnDevice: async () => false, + probeDeviceAsync, + hasAppOnDevice: async (deviceId) => { + asked.push(deviceId); + return true; + }, }) ); + expect(asked).toEqual(['UDID-1']); + expect(probeDeviceAsync).not.toHaveBeenCalled(); + }); + + it(`should name an Android device by the name expo run takes, not the serial`, async () => { + const probe = await probeAppPresenceAsync( + projectRoot, + 'android', + unbound, + deps({ hasAppOnDevice: async () => false }) + ); + expect(probe).toEqual({ presence: 'missing', installDevice: 'tuft-pixel' }); }); + it(`should hand the local probe the root it is about`, async () => { + const probeDeviceAsync = vi.fn(probeOf([androidDevice()])); + + await probeAppPresenceAsync(projectRoot, 'android', unbound, deps({ probeDeviceAsync })); + + expect(probeDeviceAsync).toHaveBeenCalledWith(projectRoot); + }); + it(`should leave the install unpinned when Android cannot name the device`, async () => { const probe = await probeAppPresenceAsync( projectRoot, 'android', - deps({ - probeDeviceAsync: probeOf([androidDevice()]), - hasAppOnDevice: async () => false, - androidDeviceName: async () => null, - }) + unbound, + deps({ hasAppOnDevice: async () => false, androidDeviceName: async () => null }) ); expect(probe).toEqual({ presence: 'missing', installDevice: null }); @@ -97,6 +159,7 @@ describe(probeAppPresenceAsync, () => { await probeAppPresenceAsync( projectRoot, 'ios', + reused, deps({ hasAppOnDevice: async (_deviceId, _backend, appId) => { asked.push(appId); @@ -111,48 +174,25 @@ describe(probeAppPresenceAsync, () => { // A project that declares no `bundleIdentifier` cannot be looked for under any name, and asking // about the Expo Go id instead would answer about a different app entirely. it(`should answer unknown when the config names no app id`, async () => { - const probe = await probeAppPresenceAsync(projectRoot, 'ios', deps({ readAppId: () => null })); - - expect(probe.presence).toBe('unknown'); - }); - - // Not `missing`: `expo start` boots a device itself, so a plan is in no position to say the app - // is not on one that does not exist yet. - it(`should answer unknown when this machine has no device for the platform`, async () => { const probe = await probeAppPresenceAsync( projectRoot, 'ios', - deps({ probeDeviceAsync: probeOf([]) }) + reused, + deps({ readAppId: () => null }) ); expect(probe.presence).toBe('unknown'); }); - it(`should answer unknown when the only device is the other platform's`, async () => { + it(`should answer unknown when this machine has no Android device`, async () => { const probe = await probeAppPresenceAsync( - projectRoot, - 'ios', - deps({ probeDeviceAsync: probeOf([androidDevice()]) }) - ); - - expect(probe.presence).toBe('unknown'); - }); - - it(`should ask the device of the platform being planned for`, async () => { - const asked: string[] = []; - await probeAppPresenceAsync( projectRoot, 'android', - deps({ - probeDeviceAsync: probeOf([iosDevice('UDID-1'), androidDevice('emulator-5554')]), - hasAppOnDevice: async (deviceId) => { - asked.push(deviceId); - return true; - }, - }) + unbound, + deps({ probeDeviceAsync: probeOf([]) }) ); - expect(asked).toEqual(['emulator-5554']); + expect(probe.presence).toBe('unknown'); }); // @ref ../hasApp — `null` is "could not look": an unreadable simulator tree, an adb that would @@ -161,6 +201,7 @@ describe(probeAppPresenceAsync, () => { const probe = await probeAppPresenceAsync( projectRoot, 'ios', + reused, deps({ hasAppOnDevice: async () => null }) ); @@ -170,11 +211,9 @@ describe(probeAppPresenceAsync, () => { it(`should answer unknown when the deadline expires, and not hang the plan`, async () => { const probe = await probeAppPresenceAsync( projectRoot, - 'ios', - deps({ - budgetMs: 20, - probeDeviceAsync: () => new Promise(() => {}), - }) + 'android', + unbound, + deps({ budgetMs: 20, probeDeviceAsync: () => new Promise(() => {}) }) ); expect(probe.presence).toBe('unknown'); @@ -186,8 +225,9 @@ describe(probeAppPresenceAsync, () => { const probe = await probeAppPresenceAsync( projectRoot, 'ios', + created, deps({ - probeDeviceAsync: () => { + hasAppOnDevice: () => { throw new Error('the harness said no device, and something probed anyway'); }, }) @@ -204,11 +244,11 @@ describe(probeAppPresenceAsync, () => { it.each([ [ 'the device probe throws', - { probeDeviceAsync: () => Promise.reject(new Error('simctl exploded')) }, + { probeDeviceAsync: () => Promise.reject(new Error('adb exploded')) }, ], [ 'the app lookup throws', - { hasAppOnDevice: () => Promise.reject(new Error('no such simulator')) }, + { hasAppOnDevice: () => Promise.reject(new Error('no such device')) }, ], [ 'reading the app id throws', @@ -219,7 +259,12 @@ describe(probeAppPresenceAsync, () => { }, ], ])(`should answer unknown rather than reject when %s`, async (_case, overrides) => { - const probe = await probeAppPresenceAsync(projectRoot, 'ios', deps(overrides as never)); + const probe = await probeAppPresenceAsync( + projectRoot, + 'android', + unbound, + deps(overrides as never) + ); expect(probe.presence).toBe('unknown'); }); diff --git a/packages/@expo/agent-cli/src/device/__tests__/bootDevice-test.ts b/packages/@expo/agent-cli/src/device/__tests__/bootDevice-test.ts index 15007372..02c2c1d9 100644 --- a/packages/@expo/agent-cli/src/device/__tests__/bootDevice-test.ts +++ b/packages/@expo/agent-cli/src/device/__tests__/bootDevice-test.ts @@ -6,27 +6,14 @@ import path from 'path'; -import { EMULATOR_SERIAL, parseAvds, pickSimulator, resolveEmulator } from '../bootDevice'; -import { parseSimulators, type SimulatorEntry } from '../simulators'; +import { EMULATOR_SERIAL, parseAvds, resolveEmulator } from '../bootDevice'; +import { parseSimulators } from '../simulators'; /** A `simctl list devices -j` payload, in the shape the real tool prints. */ function listing(devices: Record): string { return JSON.stringify({ devices }); } -function simulator(overrides: Partial = {}): SimulatorEntry { - return { - udid: 'SIM-1', - name: 'iPhone 17 Pro', - runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5', - version: [26, 5], - state: 'Shutdown', - isAvailable: true, - lastBootedAt: 0, - ...overrides, - }; -} - describe(parseSimulators, () => { it(`reads the devices of every iOS runtime`, () => { const parsed = parseSimulators( @@ -97,118 +84,6 @@ describe(parseSimulators, () => { expect(parseSimulators('')).toEqual([]); }); }); - -describe(pickSimulator, () => { - // The rule, and it is about **installed apps** rather than about recency. Expo Go and a - // development build both live on one device, so a simulator nobody has booted is a device the - // `app` phase could never have answered against — and this machine lists ten of them beside the - // one in use. - it(`takes the simulator this developer last used, over a newer one nobody has`, () => { - const picked = pickSimulator([ - simulator({ udid: 'FRESH', name: 'iPhone 17 Pro Max', version: [26, 5], lastBootedAt: 0 }), - simulator({ - udid: 'IN-USE', - name: 'iPhone 17 Pro', - version: [26, 5], - lastBootedAt: Date.parse('2026-08-26T04:50:48Z'), - }), - ]); - - expect(picked?.udid).toBe('IN-USE'); - }); - - it(`takes an iPhone on the newest runtime`, () => { - const picked = pickSimulator([ - simulator({ udid: 'OLD-PHONE', name: 'iPhone 15', version: [18, 0] }), - simulator({ udid: 'NEW-PAD', name: 'iPad Pro 13-inch', version: [26, 5] }), - simulator({ udid: 'NEW-PHONE', name: 'iPhone 17 Pro', version: [26, 5] }), - ]); - - expect(picked?.udid).toBe('NEW-PHONE'); - }); - - // A run on some device is worth much more than a run on none, so an iPad is taken when that is - // all this machine has. - it(`takes whatever there is when there is no iPhone`, () => { - expect(pickSimulator([simulator({ udid: 'PAD', name: 'iPad Pro 13-inch' })])?.udid).toBe('PAD'); - }); - - // The caller only reaches this after its own probe found nothing booted, so a `Booted` device - // here is a race — and joining it is both faster and less disruptive than booting a second one. - it(`joins a simulator that is already booted, whatever its runtime`, () => { - const picked = pickSimulator([ - simulator({ udid: 'NEW', name: 'iPhone 17 Pro', version: [26, 5] }), - simulator({ udid: 'UP', name: 'iPhone 15', version: [18, 0], state: 'Booted' }), - ]); - - expect(picked?.udid).toBe('UP'); - }); - - it(`takes no device whose runtime is gone, and none at all when there is none`, () => { - expect(pickSimulator([simulator({ isAvailable: false })])).toBeNull(); - expect(pickSimulator([])).toBeNull(); - }); -}); - -// @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app. -// -// The rule above chose the simulator most recently used, which is a *proxy* for "the one with the -// apps on it". A live run found where the proxy breaks: a dev-client project booted a fresh -// simulator and the deep link came back `115` — no handler — after a 12.4 s boot for a device that -// could never have opened it. So the app itself is the rule now, and `lastBootedAt` is what breaks -// the tie among the devices that have it. -describe(`${pickSimulator.name} when the app decides`, () => { - const fresh = simulator({ udid: 'FRESH', name: 'iPhone 17', lastBootedAt: 0 }); - const used = simulator({ - udid: 'USED', - name: 'iPhone 17 Pro', - lastBootedAt: Date.parse('2026-08-30T08:00:00Z'), - }); - const older = simulator({ - udid: 'OLDER', - name: 'iPhone Air', - lastBootedAt: Date.parse('2026-08-20T08:00:00Z'), - }); - - it(`takes the device that has the app over the one used more recently`, () => { - const picked = pickSimulator([fresh, used, older], { - hasApp: (entry) => entry.udid === 'OLDER', - }); - - expect(picked?.udid).toBe('OLDER'); - }); - - it(`breaks a tie between devices that have it the way it always did`, () => { - const picked = pickSimulator([fresh, used, older], { - hasApp: (entry) => entry.udid !== 'FRESH', - }); - - expect(picked?.udid).toBe('USED'); - }); - - // The whole point: a boot that could not have opened the app is worse than no boot, because it - // costs the minute *and* answers nothing. - it(`takes nothing when no device has the app`, () => { - expect(pickSimulator([fresh, used, older], { hasApp: () => false })).toBeNull(); - }); - - // A booted device still wins outright, and still without asking about the app: the caller only - // reaches this when its own probe found none, so one here is a race worth joining. - it(`still joins a simulator that is already booted`, () => { - const up = simulator({ udid: 'UP', state: 'Booted', lastBootedAt: 0 }); - - expect(pickSimulator([used, up], { hasApp: (entry) => entry.udid === 'USED' })?.udid).toBe( - 'UP' - ); - }); - - // No question asked is not the same as answered no. A caller that does not know which app it is - // about gets the rule that was there before this one. - it(`falls back to the most recently used when nothing asks about an app`, () => { - expect(pickSimulator([fresh, used, older])?.udid).toBe('USED'); - }); -}); - describe(parseAvds, () => { it(`reads one name per line`, () => { expect(parseAvds('Pixel_7_API_35\ntuft-pixel\n')).toEqual(['Pixel_7_API_35', 'tuft-pixel']); diff --git a/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts b/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts index 73305cc2..4eb13f9d 100644 --- a/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts +++ b/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts @@ -1,95 +1,121 @@ // @ref llp/0009-smart-followups.rfc.md §Device-aware ladders +// @ref llp/0035-one-device-per-worktree.rfc.md §Readers // // The three answers matter more than the probe: `absent` is what turns `navigate` off, and it may -// only be given by a tool that ran and reported nothing. A tool that could not run at all is +// only be given by a rung that ran and reported nothing. A tool that could not run at all is // silence, and silence has to read as `unknown` — a machine with no `adb` is not a machine with no // device. +import type { Inspection } from '../../deviceBinding'; import type { DeviceProbe } from '../../navigate/device'; import { CommandError } from '../../utils/errors'; import { probeLocalDeviceAsync, readLocalDeviceProbe, resetLocalDeviceCache } from '../localDevice'; -/** A probe that ran and found something. */ -const found: DeviceProbe = { - device: { backend: 'local-ios', platform: 'ios', deviceId: 'UDID-1', name: 'iPhone 17' }, +const projectRoot = '/project'; + +function inspection(state: Inspection['state'], extra: Partial = {}): Inspection { + return { + binding: + state === 'none' || state === 'unreadable' + ? null + : { + version: 1, + device: { + backend: 'local-ios', + platform: 'ios', + udid: 'UDID-1', + name: 'agent-cli 0000', + origin: 'created', + }, + projectRoot, + boundAt: '2026-10-08T09:00:00.000Z', + expiresAt: '2026-10-08T11:00:00.000Z', + }, + path: '/home/.expo/agent-cli/bindings/x-ios-local-ios.json', + state, + ...extra, + }; +} + +/** An attached Android emulator, as the probe reports it. */ +const foundAndroid: DeviceProbe = { + device: { + backend: 'local-android', + platform: 'android', + deviceId: 'emulator-5554', + name: 'sdk_gphone64_arm64', + }, }; -/** A probe that ran and found nothing. */ -const none: DeviceProbe = { +const noAndroid: DeviceProbe = { device: null, - reason: 'no booted iOS simulator was found', + reason: 'no Android device or emulator is attached', }; -/** - * A probe whose tool is not installed, which establishes nothing about the machine. - * - * `toolError` is the device probe's own signal for it, and the same one the failure message uses - * to avoid reporting a missing SDK as a missing device (`src/device/adb.ts`, friction run 6's F49). - */ -const unrunnable: DeviceProbe = { +/** An `adb` that is not installed, which establishes nothing about the machine (F49). */ +const adbUnrunnable: DeviceProbe = { device: null, reason: 'could not run "adb": spawn adb ENOENT', toolError: new CommandError('ADB_NOT_RUNNABLE', 'adb could not be run'), }; -/** An attached Android emulator, as the second probe reports it. */ -const foundAndroid: DeviceProbe = { - device: { - backend: 'local-android', - platform: 'android', - deviceId: 'emulator-5554', - name: 'sdk_gphone64_arm64', - }, -}; - describe(readLocalDeviceProbe, () => { - it(`reports the device when one platform found one`, () => { - expect(readLocalDeviceProbe([found, none])).toEqual({ - state: 'present', - device: found.device, - devices: [found.device], - reason: null, - }); + it(`reports the bound simulator when it is up`, () => { + const probe = readLocalDeviceProbe(inspection('up'), noAndroid); + + expect(probe.state).toBe('present'); + expect(probe.device).toMatchObject({ backend: 'local-ios', deviceId: 'UDID-1' }); + expect(probe.devices).toHaveLength(1); }); - // F106 — MED, found live on 2026-08-27. This fold took `probes.find((probe) => probe.device)` and - // iOS is probed first on macOS, so with a booted simulator *and* an attached emulator the report - // named the simulator and said nothing about the emulator at all. Live: `status` printed - // `device ios iPhone 17 Pro (C159CF99-…)` while the only app on the dev server was Expo Go on - // `emulator-5554`, which is the reading an agent takes as "the app is on iOS". - // - // The singular `device` is unchanged — first found, which is what every ladder already branches on - // — and `devices` is what makes the report true rather than merely not-wrong. - it(`reports every device that was found, not only the first (F106)`, () => { - const probe = readLocalDeviceProbe([found, foundAndroid]); + // probe-local-combines-platforms + it(`reports every up device, the bound simulator first (F106)`, () => { + const probe = readLocalDeviceProbe(inspection('up'), foundAndroid); expect(probe.state).toBe('present'); - expect(probe.device).toEqual(found.device); - expect(probe.devices).toEqual([found.device, foundAndroid.device]); + expect(probe.devices.map((device) => device.deviceId)).toEqual(['UDID-1', 'emulator-5554']); }); - it(`reports an empty device list when nothing was found (F106)`, () => { - expect(readLocalDeviceProbe([none, unrunnable]).devices).toEqual([]); - expect(readLocalDeviceProbe([]).devices).toEqual([]); + it(`reports the emulator alone when the simulator is not up`, () => { + const probe = readLocalDeviceProbe(inspection('not-up'), foundAndroid); + + expect(probe.state).toBe('present'); + expect(probe.devices.map((device) => device.deviceId)).toEqual(['emulator-5554']); }); - it(`reports absent when a tool ran and reported nothing`, () => { - const probe = readLocalDeviceProbe([none, unrunnable]); + it.each([ + ['none', inspection('none'), 'no simulator is bound'], + ['gone', inspection('gone', { cause: 'expired' }), 'lease'], + ['not-up', inspection('not-up'), 'not up'], + ] as const)(`reports absent for a %s binding and no emulator`, (_state, ios, reason) => { + const probe = readLocalDeviceProbe(ios, noAndroid); expect(probe.state).toBe('absent'); expect(probe.device).toBeNull(); - expect(probe.reason).toContain('no booted iOS simulator was found'); + expect(probe.devices).toEqual([]); + expect(probe.reason).toContain(reason); + expect(probe.reason).toContain('no Android device'); }); - it(`reports unknown when no tool could run at all`, () => { - const probe = readLocalDeviceProbe([unrunnable, unrunnable]); + it.each([ + ['unreadable', inspection('unreadable')], + ['unknown', inspection('unknown', { cause: 'timeout' })], + ] as const)(`reports unknown for an %s binding`, (_state, ios) => { + expect(readLocalDeviceProbe(ios, noAndroid).state).toBe('unknown'); + }); + + it(`reports unknown when adb could not run and nothing is bound`, () => { + const probe = readLocalDeviceProbe(inspection('none'), adbUnrunnable); expect(probe.state).toBe('unknown'); expect(probe.reason).toContain('could not run'); }); - it(`reports unknown for a host that was asked nothing`, () => { - expect(readLocalDeviceProbe([]).state).toBe('unknown'); + it(`reads only the Android rung off macOS`, () => { + expect(readLocalDeviceProbe(null, noAndroid)).toMatchObject({ + state: 'absent', + reason: 'Android: no Android device or emulator is attached', + }); }); }); @@ -97,25 +123,39 @@ describe(probeLocalDeviceAsync, () => { beforeEach(() => resetLocalDeviceCache()); afterEach(() => resetLocalDeviceCache()); - it(`asks the platform tools once, however many callers ask`, async () => { - const probes = vi.fn(async () => [none]); + it(`asks the rungs once per root, however many callers ask`, async () => { + const inspectIosAsync = vi.fn(async () => inspection('none')); + const probeAndroidAsync = vi.fn(async () => noAndroid); + const options = { + projectRoot, + inspectIosAsync, + probeAndroidAsync, + hostPlatform: 'darwin' as const, + }; const [first, second] = await Promise.all([ - probeLocalDeviceAsync({ probesAsync: probes }), - probeLocalDeviceAsync({ probesAsync: probes }), + probeLocalDeviceAsync(options), + probeLocalDeviceAsync(options), ]); + const other = await probeLocalDeviceAsync({ ...options, projectRoot: '/other' }); - expect(probes).toHaveBeenCalledTimes(1); + expect(inspectIosAsync).toHaveBeenCalledTimes(2); + expect(inspectIosAsync).toHaveBeenCalledWith(projectRoot); + expect(inspectIosAsync).toHaveBeenCalledWith('/other'); expect(first).toBe(second); + expect(other).not.toBe(first); expect(first.state).toBe('absent'); }); // A probe is a convenience: a suggestion ladder must never be the thing that fails a command. - it(`answers unknown when the probe itself throws`, async () => { + it(`answers unknown when a rung itself throws`, async () => { const probe = await probeLocalDeviceAsync({ - probesAsync: async () => { + projectRoot, + inspectIosAsync: async () => { throw new Error('simctl exploded'); }, + probeAndroidAsync: async () => noAndroid, + hostPlatform: 'darwin', }); expect(probe.state).toBe('unknown'); diff --git a/packages/@expo/agent-cli/src/device/appPresence.ts b/packages/@expo/agent-cli/src/device/appPresence.ts index 6883bc1d..9b6cb315 100644 --- a/packages/@expo/agent-cli/src/device/appPresence.ts +++ b/packages/@expo/agent-cli/src/device/appPresence.ts @@ -15,6 +15,7 @@ // free to give up: no device, no app id, a tool that would not run, a deadline that expired, a // platform this host cannot ask about. None of them are failures. +import { devicesDisabled, type AcquireAction, type BoundDevice } from '../deviceBinding'; import type { NativePlatform } from '../plan/types'; import { readConfiguredAppId } from '../runtime/appId'; import { hasAppOnDeviceAsync } from './hasApp'; @@ -34,18 +35,28 @@ export type AppPresence = export interface AppPresenceProbe { presence: AppPresence; /** - * What `expo run: --device` calls the device that answered, or null. + * What `expo run:android --device` calls the device that answered, or null. * - * Set only for `missing`, which is the one answer that plans an install — the plan pins the - * install to the device that was actually checked, so a machine with two devices cannot install - * on one and keep serving nothing to the other. iOS takes the UDID as-is; Android takes a *name*, - * which `androidDeviceNameAsync` resolves from the serial (@ref ./installDevBuild). A device the - * platform cannot name stays null, and the install runs unpinned — the Expo CLI then picks the - * attached device itself, which on a one-device machine is the same device. + * Set only for `missing` on Android, which is the one answer that plans an install there: the + * plan pins the install to the device that was actually checked. The name is resolved from the + * serial (`androidDeviceNameAsync`); a device the platform cannot name stays null, and the + * install runs unpinned. iOS carries nothing here: `dev` pins every `run` and `install` step to + * the bound simulator itself (`src/dev/forwardedArgs.ts` §withDevice). */ installDevice: string | null; } +/** + * The device `dev` bound for this run, and how. Null when the run bound none: Android until + * llp/0035 §Android instance, `--eas`, or a harness with devices off. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it + */ +export interface AppPresenceDevice { + device: BoundDevice | null; + action: AcquireAction | null; +} + /** * How long the whole question may take before the answer is `unknown`. * @@ -67,7 +78,7 @@ const UNPROBED: AppPresenceProbe = { presence: 'unknown', installDevice: null }; export interface ProbeAppPresenceOptions { /** Injected for tests. Defaults to the process-cached probe every other caller shares. */ - probeDeviceAsync?: () => Promise; + probeDeviceAsync?: (projectRoot: string) => Promise; /** Injected for tests. */ readAppId?: typeof readConfiguredAppId; /** Injected for tests. */ @@ -79,12 +90,12 @@ export interface ProbeAppPresenceOptions { } /** - * Whether the development build of this project is installed on a device this machine has. + * Whether the development build of this project is installed on the device this run has. * - * The device is the one the run would open the app on: the first the local probe found for this - * platform, which is the same choice `navigate` and `smoke` make. A machine with no device for the - * platform answers `unknown` rather than `missing` — nothing was asked, and `expo start` boots one - * itself, so a plan is in no position to claim the app is not on a device that does not exist yet. + * With a bound device the question is asked of that device, and a device this run `created` is + * `missing` without asking, because a fresh simulator never has the app. With none on iOS nothing + * is asked. With none on Android the device is the first the local probe found, until llp/0035 §Android instance + * binds Android too. * * `AGENT_CLI_NO_DEVICE` answers `unknown` without spawning anything, the same way it turns off the * open (`src/dev/devAsync.ts`): a stubbed harness has no device this could be true about, and a @@ -95,11 +106,15 @@ export interface ProbeAppPresenceOptions { export async function probeAppPresenceAsync( projectRoot: string, platform: NativePlatform, + bound: AppPresenceDevice, options: ProbeAppPresenceOptions = {} ): Promise { - if (process.env.AGENT_CLI_NO_DEVICE === '1') { + if (devicesDisabled()) { return UNPROBED; } + if (bound.device != null && bound.action === 'created') { + return { presence: 'missing', installDevice: null }; + } const budgetMs = options.budgetMs ?? APP_PRESENCE_BUDGET_MS; let timer: NodeJS.Timeout | undefined; @@ -108,7 +123,10 @@ export async function probeAppPresenceAsync( timer.unref?.(); }); try { - return await Promise.race([askDeviceAsync(projectRoot, platform, options), expired]); + return await Promise.race([ + askDeviceAsync(projectRoot, platform, bound.device, options), + expired, + ]); } catch { // A probe is not allowed to be the thing that fails `dev`. return UNPROBED; @@ -121,8 +139,9 @@ export async function probeAppPresenceAsync( async function askDeviceAsync( projectRoot: string, platform: NativePlatform, + bound: BoundDevice | null, { - probeDeviceAsync = probeLocalDeviceAsync, + probeDeviceAsync = (root) => probeLocalDeviceAsync({ projectRoot: root }), readAppId = readConfiguredAppId, hasAppOnDevice = hasAppOnDeviceAsync, androidDeviceName = androidDeviceNameAsync, @@ -136,15 +155,25 @@ async function askDeviceAsync( return UNPROBED; } - const probe = await probeDeviceAsync(); + if (bound?.backend === 'local-ios') { + const installed = await hasAppOnDevice(bound.udid, 'local-ios', appId); + return installed == null + ? UNPROBED + : { presence: installed ? 'present' : 'missing', installDevice: null }; + } + if (platform === 'ios') { + return UNPROBED; + } + + const probe = await probeDeviceAsync(projectRoot); const device = probe.devices.find((candidate) => candidate.platform === platform); if (device == null) { return UNPROBED; } - // Three-valued through and through (@ref ./hasApp): `null` is "could not look" — a simulator - // whose data directory is unreadable, an adb that would not run, a cloud device this machine - // cannot see — and it must never read as `missing`, whose cost is an install. + // Three-valued through and through (@ref ./hasApp): `null` is "could not look" — an adb that + // would not run, a cloud device this machine cannot see — and it must never read as `missing`, + // whose cost is an install. const installed = await hasAppOnDevice(device.deviceId, device.backend, appId); if (installed == null) { return UNPROBED; @@ -152,8 +181,5 @@ async function askDeviceAsync( if (installed) { return { presence: 'present', installDevice: null }; } - return { - presence: 'missing', - installDevice: platform === 'ios' ? device.deviceId : await androidDeviceName(device.deviceId), - }; + return { presence: 'missing', installDevice: await androidDeviceName(device.deviceId) }; } diff --git a/packages/@expo/agent-cli/src/device/bootDevice.ts b/packages/@expo/agent-cli/src/device/bootDevice.ts index ab48890b..fc6d1ffb 100644 --- a/packages/@expo/agent-cli/src/device/bootDevice.ts +++ b/packages/@expo/agent-cli/src/device/bootDevice.ts @@ -1,17 +1,18 @@ // @ref llp/0005-runtime-loop-tools.rfc.md §The run brings its own environment -// Boot a local device, and shut one down again. +// Boot an Android emulator, and shut one down again. The iOS simulator is the worktree's bound +// one, booted by the device registry (`src/deviceBinding/`); Android follows with llp/0035 §Android instance. // // The device probes next door (`src/navigate/device.ts`) answer "is there one", which is all every // command before this needed: a run that found none reported that and stopped. `smoke` now brings // its own, so somebody has to answer "make one", and this is the module that does — through the -// same platform tools as subprocesses, with no simulator or emulator library linked in. +// same platform tools as subprocesses, with no emulator library linked in. // // **Only what this module started is ever shut down.** Nothing here decides that; the caller does // (`src/smoke/phases.ts`). What this module guarantees is the other half of it: `bootAsync` reports // the id it booted before it waits for anything, so a boot that hangs is still a device its caller // knows it is holding. // -// Three live facts are pinned here rather than rediscovered, and each of them cost somebody a run: +// Two live facts are pinned here rather than rediscovered, and each of them cost somebody a run: // // 1. **`-ports 5554,5555` on the emulator.** Without it the emulator binds ephemeral ports and // `adb devices` never lists it at all — not "offline", *absent* [observed — friction run 6, @@ -19,20 +20,14 @@ // 2. **`sys.boot_completed`, not `adb devices`.** `adb` reports the serial as `offline` for the // first seconds and then `device`, and Android itself is up later still. An `adb shell` before // that answers `device offline`. -// 3. **`simctl boot`, and never Simulator.app.** Opening the UI app is what needs a macOS -// Automation grant, and a refusal there is the failure llp/0005 §The run brings its own environment -// uses records for `expo start --ios`. `simctl boot` needs no grant, and `simctl openurl` and -// `simctl io … screenshot` both work against a simulator whose window nobody opened. import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; -import type { DeviceBackend } from '../navigate/device'; import { spawnCaptureAsync } from '../utils/spawnCapture'; +import { firstLine } from '../utils/text'; import { resolveAdb, runAdbAsync, type AdbResolution } from './adb'; -import { simulatorHasAppAsync } from './installedApps'; -import { parseSimulators, type SimulatorEntry } from './simulators'; /** The serial an emulator started with `-ports 5554,5555` is always listed under. */ export const EMULATOR_SERIAL = 'emulator-5554'; @@ -57,33 +52,15 @@ export interface BootDeviceResult { ok: boolean; /** The device that came up, or the one that was asked to and did not. */ deviceId: string | null; - backend: DeviceBackend | null; + backend: 'local-android' | null; /** The name a person would recognise it by, when the tool reported one. */ name: string | null; /** Why none came up, as one sentence. Null exactly when {@link ok} is true. */ reason: string | null; - /** - * Nothing was booted **on purpose**, because no device could have opened the app. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app - * Distinct from an ordinary failure, and the distinction is the whole point of asking before - * booting: a boot that could not have opened the app costs a minute and answers nothing, so - * declining it is the *right* outcome and the report has to say so rather than describing a - * simulator that would not start. - */ - refused: boolean; - /** - * This device was booted **without** having the app, for a caller that said it could install it. - * - * @ref ./bootDevice §mayInstall. Only ever true when the caller passed `mayInstall`, and it is - * the caller's cue to install before it opens anything. - */ - installNeeded?: boolean; /** * Why this device was chosen, for a report that has to explain itself. * - * Null when none was. One clause, in the terms of the rule that picked it — "has Expo Go - * installed", "already booted". + * Null when none was. One clause, in the terms of the rule that picked it. */ choice: string | null; } @@ -103,106 +80,11 @@ export interface BootDeviceOptions { * The caller registers its cleanup from here, so a boot that is issued and then hangs is still * a device somebody is responsible for shutting down. */ - onBooting?: (device: { deviceId: string; backend: DeviceBackend }) => void; + onBooting?: (device: { deviceId: string; backend: 'local-android' }) => void; /** The clock, so a test can drive the wait without one. */ now?: () => number; /** Injected for the tests: the emulator this host would spawn. */ adb?: AdbResolution; - /** - * The application the caller is about to open, when it knows. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app - * Given, the boot only ever chooses a device that **has this app installed**, and declines - * rather than booting one that could not open it. Absent, the choice is the ordering that was - * here before — which is right for a caller that has no particular app in mind, and was wrong - * for `smoke`, whose whole next phase is opening one. - */ - appId?: string | null; - /** What the app is called in a sentence, for the refusal. Defaults to {@link appId}. */ - appLabel?: string | null; - /** The command that puts the app on a device, named by the refusal when there is one. */ - installWith?: string | null; - /** Injected for the tests: which apps a simulator has. */ - hasAppAsync?: (udid: string, appId: string) => Promise; - /** - * The caller can put {@link appId} on a device itself, so a machine with none is not a refusal. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §Putting Expo Go on a simulator that has not got it - * Without this, "no simulator has the app" ends the run — which is right for a caller that can - * only open what is already there. A caller that can *install* needs the opposite: a booted - * device to install onto. So the app filter is dropped rather than the boot refused, and - * {@link BootDeviceResult.installNeeded} tells the caller it has an install to do. - */ - mayInstall?: boolean; -} - -/** - * Choose the simulator to boot. - * - * **The one this developer last used**, and that is the whole rule rather than a tie-break. Apps - * are installed per device: a simulator nobody has ever booted has no Expo Go on it and no - * development build either, so a run that picked "the newest iPhone on the newest runtime" would, - * on a machine with eleven simulators and one in use, spend a minute booting a device that could - * never have answered the `app` phase. `lastBootedAt` is `simctl`'s own record of which one that - * is [observed — 2026-08-30, a machine listing five iPhones and five iPads, one of them the - * `iPhone 17 Pro` every live run of this package has used]. - * - * The two rules under it are for a machine where nothing has ever been booted, which is a fresh - * Xcode install: the newest iOS runtime, and an iPhone on it. An iPad is taken only when there is - * no iPhone at all, because a run on some device is worth much more than a run on none. - * - * A simulator that is already `Booted` wins outright — the caller only reaches this when its probe - * found none, so one here is a race, and joining it is better than booting a second. - * - * **`hasApp` outranks all of it**, and that is the correction the proxy above needed - * (llp/0005 §The device that can open the app). "Most recently used" is a guess at "has the apps - * on it"; the app itself is the fact. A dev-client project booted a fresh simulator on that guess - * and the deep link came back `115` — no handler for the scheme — after a 12.4 s boot for a device - * that could never have opened it [observed, 2026-08-30]. When no device - * has the app this answers **null**, because a boot that cannot open the app is worse than no boot: - * it costs the minute and answers nothing. - * - * `hasApp` absent is "nobody asked", which is not "the answer is no": a caller that does not know - * which app it is about gets the ordering that was here before. - */ -export function pickSimulator( - simulators: SimulatorEntry[], - { hasApp }: { hasApp?: (simulator: SimulatorEntry) => boolean } = {} -): SimulatorEntry | null { - const available = simulators.filter((simulator) => simulator.isAvailable); - if (available.length === 0) { - return null; - } - const alreadyUp = available.find((simulator) => simulator.state === 'Booted'); - if (alreadyUp) { - return alreadyUp; - } - const usable = hasApp == null ? available : available.filter(hasApp); - if (usable.length === 0) { - return null; - } - const ranked = [...usable].sort((left, right) => { - const byLastBooted = right.lastBootedAt - left.lastBootedAt; - if (byLastBooted !== 0) { - return byLastBooted; - } - const byVersion = compareVersions(right.version, left.version); - if (byVersion !== 0) { - return byVersion; - } - return Number(right.name.startsWith('iPhone')) - Number(left.name.startsWith('iPhone')); - }); - return ranked[0] ?? null; -} - -function compareVersions(left: number[], right: number[]): number { - for (let index = 0; index < Math.max(left.length, right.length); index += 1) { - const difference = (left[index] ?? 0) - (right[index] ?? 0); - if (difference !== 0) { - return difference; - } - } - return 0; } /** Read the AVD names out of `emulator -list-avds`. */ @@ -217,215 +99,41 @@ export function parseAvds(stdout: string): string[] { } /** - * Boot a device for this platform, and wait until it will answer. + * Boot an emulator, and wait until it will answer. * * @returns what came up, or why nothing did. Never rejects. */ export async function bootDeviceAsync( - platform: 'ios' | 'android', + _platform: 'android', options: BootDeviceOptions ): Promise { - return platform === 'ios' ? await bootSimulatorAsync(options) : await bootEmulatorAsync(options); + return await bootEmulatorAsync(options); } -/** Shut down a device this process booted. Never rejects. */ +/** Shut down an emulator this process booted. Never rejects. */ export async function shutdownDeviceAsync( deviceId: string, - backend: DeviceBackend, { adb }: { adb?: AdbResolution } = {} ): Promise { - if (backend === 'local-ios') { - const result = await spawnCaptureAsync('xcrun', ['simctl', 'shutdown', deviceId], { - timeoutMs: 60_000, - }); - if (result.spawnError) { - return { ok: false, reason: `could not run "xcrun simctl": ${result.spawnError.message}` }; - } - // A simulator that is already off is the state this asked for, and `simctl` says so with a - // non-zero code. Reporting it as a failed cleanup would be reporting the outcome we wanted. - if (result.exitCode !== 0 && !/current state: Shutdown/i.test(result.stderr)) { - return { - ok: false, - reason: `"xcrun simctl shutdown ${deviceId}" exited ${result.exitCode}: ${firstLine(result.stderr) || 'no output'}`, - }; - } - return { ok: true, reason: null }; - } - - if (backend === 'local-android') { - const result = await runAdbAsync(['-s', deviceId, 'emu', 'kill'], { - adb, - timeoutMs: 60_000, - }); - if (result.notRunnable) { - return { - ok: false, - reason: `"adb" could not be run (${result.spawnError?.message ?? 'no reason given'})`, - }; - } - return result.exitCode === 0 - ? { ok: true, reason: null } - : { - ok: false, - reason: `"${result.adb.bin} -s ${deviceId} emu kill" exited ${result.exitCode}: ${firstLine(result.stderr) || 'no output'}`, - }; - } - - // The cloud backend is not this module's to shut down, and it is never registered for cleanup: - // a session is somebody's paid resource, started outside this run and outliving it on purpose - // (llp/0005 §Cloud simulator). - return { - ok: false, - reason: `${backend} is not a device this command boots, so it has nothing to shut down`, - }; -} - -/** The iOS half: pick a simulator, boot it, and wait for `bootstatus` to say it is up. */ -async function bootSimulatorAsync({ - timeoutMs, - onBooting, - now = Date.now, - appId = null, - appLabel = null, - installWith = null, - mayInstall = false, - hasAppAsync = (udid, id) => simulatorHasAppAsync(udid, id), -}: BootDeviceOptions): Promise { - const none = (reason: string, refused = false): BootDeviceResult => ({ - ok: false, - deviceId: null, - backend: null, - name: null, - reason, - refused, - choice: null, - }); - const app = appLabel ?? appId; - - const deadline = now() + timeoutMs; - const left = () => Math.max(1_000, deadline - now()); - - const listed = await spawnCaptureAsync('xcrun', ['simctl', 'list', 'devices', '-j'], { - timeoutMs: Math.min(60_000, left()), + const result = await runAdbAsync(['-s', deviceId, 'emu', 'kill'], { + adb, + timeoutMs: 60_000, }); - if (listed.spawnError) { - return none( - `could not run "xcrun simctl", so no simulator could be booted: ${listed.spawnError.message}. Install Xcode and its command line tools, which provide it` - ); - } - if (listed.exitCode !== 0) { - return none( - `"xcrun simctl list devices" exited ${listed.exitCode}: ${firstLine(listed.stderr) || 'no output'}` - ); - } - - const simulators = parseSimulators(listed.stdout); - if (simulators.filter((entry) => entry.isAvailable).length === 0) { - return none( - `this machine has no iOS simulator to boot. Install an iOS runtime in Xcode's Settings › Components, then run this command again` - ); - } - - // @ref ./bootDevice §pickSimulator. Asked **before** anything is booted, which is the whole - // point: the answer decides between a minute well spent and a minute spent on a device that - // could never have opened the app. - const withApp = - appId == null - ? null - : new Set( - ( - await Promise.all( - simulators.map(async (entry) => - (await hasAppAsync(entry.udid, appId)) ? entry.udid : null - ) - ) - ).filter((udid): udid is string => udid != null) - ); - - const simulator = pickSimulator( - simulators, - withApp == null ? {} : { hasApp: (entry) => withApp.has(entry.udid) } - ); - // @ref ./bootDevice §mayInstall. A caller that can put the app there needs the opposite of a - // refusal: a booted device to install onto. So the app filter is dropped and the ordering that - // was here before it decides — and the caller is told it has an install to do. - const forInstall = simulator == null && mayInstall ? pickSimulator(simulators) : null; - if (simulator == null && forInstall == null) { - // Nothing was booted, and that is the answer rather than a failure to boot: no simulator on - // this machine has the app, so every one of them would have refused the deep link. - return none( - [ - `no iOS simulator has ${app} installed, so booting one would open nothing`, - `— ${simulators.length} ${simulators.length === 1 ? 'simulator was' : 'simulators were'} looked at`, - installWith ? `. Run "${installWith}" once to put the app on a simulator` : '', - ].join(''), - true - ); - } - - // From here on there is one device, whichever branch produced it. - const chosen = simulator ?? forInstall!; - const installNeeded = simulator == null; - const choice = - chosen.state === 'Booted' - ? 'it was already booted' - : installNeeded - ? `no simulator has ${app}, so this one was booted to install it onto` - : withApp == null - ? 'it is the simulator this machine last used' - : `it has ${app} installed`; - - // Before the boot, so the caller is holding it from here on (@ref ./bootDevice §onBooting). - onBooting?.({ deviceId: chosen.udid, backend: 'local-ios' }); - - const booted = await spawnCaptureAsync('xcrun', ['simctl', 'boot', chosen.udid], { - timeoutMs: left(), - }); - // "Unable to boot device in current state: Booted" is the race this run is happy to lose: the - // device is up, which is the whole request. - if (booted.exitCode !== 0 && !/current state: Booted/i.test(booted.stderr)) { + if (result.notRunnable) { return { ok: false, - deviceId: chosen.udid, - backend: 'local-ios', - name: chosen.name, - reason: `"xcrun simctl boot ${chosen.udid}" exited ${booted.exitCode}: ${firstLine(booted.stderr) || 'no output'}`, - refused: false, - choice, + reason: `"adb" could not be run (${result.spawnError?.message ?? 'no reason given'})`, }; } - - // `bootstatus` blocks until the device has finished booting, which is a different moment from - // the boot command returning: `simctl boot` returns as soon as the boot has *started*, and an - // `openurl` before springboard is up is refused. - const status = await spawnCaptureAsync('xcrun', ['simctl', 'bootstatus', chosen.udid], { - timeoutMs: left(), - }); - if (status.exitCode !== 0) { - return { - ok: false, - deviceId: chosen.udid, - backend: 'local-ios', - name: chosen.name, - reason: `${chosen.name} (${chosen.udid}) was asked to boot and "xcrun simctl bootstatus" did not report it up within ${timeoutMs}ms`, - refused: false, - choice, - }; - } - - return { - ok: true, - deviceId: chosen.udid, - backend: 'local-ios', - name: chosen.name, - reason: null, - refused: false, - installNeeded, - choice, - }; + return result.exitCode === 0 + ? { ok: true, reason: null } + : { + ok: false, + reason: `"${result.adb.bin} -s ${deviceId} emu kill" exited ${result.exitCode}: ${firstLine(result.stderr) || 'no output'}`, + }; } -/** The Android half: pick an AVD, spawn the emulator detached, and poll `sys.boot_completed`. */ +/** Pick an AVD, spawn the emulator detached, and poll `sys.boot_completed`. */ async function bootEmulatorAsync({ timeoutMs, onBooting, @@ -438,7 +146,6 @@ async function bootEmulatorAsync({ backend: null, name: null, reason, - refused: false, choice: null, }); @@ -480,6 +187,7 @@ async function bootEmulatorAsync({ ); } + const choice = 'it is the only Android virtual device this machine has'; const deadline = now() + timeoutMs; for (;;) { if (spawnFailure != null) { @@ -489,8 +197,7 @@ async function bootEmulatorAsync({ backend: 'local-android', name: avd, reason: `"${emulator} -avd ${avd}" could not be started: ${spawnFailure}`, - refused: false, - choice: 'it is the only Android virtual device this machine has', + choice, }; } const probe = await runAdbAsync( @@ -504,8 +211,7 @@ async function bootEmulatorAsync({ backend: 'local-android', name: avd, reason: null, - refused: false, - choice: 'it is the only Android virtual device this machine has', + choice, }; } if (now() >= deadline) { @@ -515,8 +221,7 @@ async function bootEmulatorAsync({ backend: 'local-android', name: avd, reason: `the emulator ${avd} did not finish booting within ${timeoutMs}ms — "${adb.bin} -s ${EMULATOR_SERIAL} shell getprop sys.boot_completed" never answered 1`, - refused: false, - choice: 'it is the only Android virtual device this machine has', + choice, }; } await new Promise((resolve) => setTimeout(resolve, BOOT_POLL_MS)); @@ -538,7 +243,3 @@ export function resolveEmulator( const beside = path.join(path.dirname(path.dirname(adb.bin)), 'emulator', executable); return exists(beside) ? beside : executable; } - -function firstLine(text: string): string { - return text.trim().split('\n')[0] ?? ''; -} diff --git a/packages/@expo/agent-cli/src/device/localDevice.ts b/packages/@expo/agent-cli/src/device/localDevice.ts index aabf1b29..f34707cf 100644 --- a/packages/@expo/agent-cli/src/device/localDevice.ts +++ b/packages/@expo/agent-cli/src/device/localDevice.ts @@ -1,36 +1,33 @@ // @ref llp/0009-smart-followups.rfc.md §Device-aware ladders -// Does this machine have a device to open an app on? +// @ref llp/0035-one-device-per-worktree.rfc.md §Readers +// Does this worktree have a device to open an app on? // // Every rung of the CLI's ladders that reaches a screen — `@expo/agent-cli navigate /`, the screenshot -// follow-up, `status`'s own `next` line — needs a *local* device: a booted iOS simulator, or an -// Android device `adb` can see. A dogfood session drove Expo Go on a **cloud** simulator through a -// tunnel, from a machine with neither, and every one of those suggestions was an instruction to run -// something that could not work [observed — 2026-08-24]. The CLI had no way to know, because it had -// never asked. +// follow-up, `status`'s own `next` line — needs a *local* device: the iOS simulator this worktree +// bound with `dev`, or an Android device `adb` can see. A dogfood session drove Expo Go on a +// **cloud** simulator through a tunnel, from a machine with neither, and every one of those +// suggestions was an instruction to run something that could not work [observed — 2026-08-24]. The +// CLI had no way to know, because it had never asked. // -// So it asks, once per process, and the answer has three values rather than two. `absent` is the -// one that changes what is suggested, and it may only be given by a platform tool that **ran** and -// reported nothing. A tool that is not installed establishes nothing — a Linux machine with no -// `adb` is not a machine with no device — and that case answers `unknown`, which leaves every -// ladder exactly as it was. -// -// That distinction is the device probe's own: `DeviceProbe.toolError` is set exactly when the tool -// could not be started, and it is the same fact friction run 6's F49 exists for — "no Android -// device or emulator is attached" must never be printed for a missing SDK (`src/device/adb.ts`). -// One signal, read by the failure message there and by the suggestion ladders here. +// So it asks, once per process and root, and the answer has three values rather than two. `absent` +// is the one that changes what is suggested, and it may only be given by a platform tool that +// **ran** and reported nothing. A tool that is not installed establishes nothing — a Linux machine +// with no `adb` is not a machine with no device — and that case answers `unknown`, which leaves +// every ladder exactly as it was. +import { inspectBindingCachedAsync, type Inspection } from '../deviceBinding'; import { + navigateDeviceOf, probeAndroidDeviceAsync, - probeIosSimulatorAsync, type DeviceProbe, type NavigateDevice, } from '../navigate/device'; /** What this machine has to open an app on. */ export type LocalDeviceState = - /** A booted simulator or an attached device was found. */ + /** A bound simulator is up, or an attached device was found. */ | 'present' - /** A platform tool ran and reported none. */ + /** Every rung ran and reported none. */ | 'absent' /** Nothing could be established, so nothing may be concluded. */ | 'unknown'; @@ -54,41 +51,52 @@ export interface LocalDeviceProbe { reason: string | null; } -/** Options of {@link probeLocalDeviceAsync}, for tests and for nothing else. */ export interface ProbeLocalDeviceOptions { - /** The platform probes to run. Defaults to the ones this host can have. */ - probesAsync?: () => Promise; + /** The worktree whose bindings are read. */ + projectRoot: string; + /** The iOS rung, injected for tests. Defaults to the cached inspection of the own binding. */ + inspectIosAsync?: (projectRoot: string) => Promise; + /** The Android rung, injected for tests. Defaults to main's first-`adb`-device probe. */ + probeAndroidAsync?: () => Promise; + hostPlatform?: NodeJS.Platform; } -/** - * One probe per process. - * - * The promise is cached rather than the result, so the several callers of one command — the status - * sections and its follow-ups, say — share a single pair of subprocesses. - */ -let cached: Promise | null = null; +const cached = new Map>(); /** Forget what this process probed. For tests, and for nothing else. */ export function resetLocalDeviceCache(): void { - cached = null; + cached.clear(); } /** - * Whether this machine has a device to open an app on. + * Whether this worktree has a device to open an app on. * - * Never rejects: a suggestion ladder must not be the thing that fails a command, so a probe that - * throws answers `unknown` and every caller carries on as it did before this existed. + * One probe per process and root: the promise is cached rather than the result, so the several + * callers of one command — the status sections and its follow-ups, say — share one inspection and + * one `adb`. Never rejects: a suggestion ladder must not be the thing that fails a command, so a + * probe that throws answers `unknown`. */ -export function probeLocalDeviceAsync( - options: ProbeLocalDeviceOptions = {} -): Promise { - cached ??= runProbesAsync(options); - return cached; +export function probeLocalDeviceAsync(options: ProbeLocalDeviceOptions): Promise { + let pending = cached.get(options.projectRoot); + if (!pending) { + pending = runProbesAsync(options); + cached.set(options.projectRoot, pending); + } + return pending; } -async function runProbesAsync(options: ProbeLocalDeviceOptions): Promise { +async function runProbesAsync({ + projectRoot, + inspectIosAsync = (root) => inspectBindingCachedAsync(root, 'ios', 'local-ios'), + probeAndroidAsync = probeAndroidDeviceAsync, + hostPlatform = process.platform, +}: ProbeLocalDeviceOptions): Promise { try { - return readLocalDeviceProbe(await (options.probesAsync ?? defaultProbesAsync)()); + const [ios, android] = await Promise.all([ + hostPlatform === 'darwin' ? inspectIosAsync(projectRoot) : null, + probeAndroidAsync(), + ]); + return readLocalDeviceProbe(ios, android); } catch (error: unknown) { return { state: 'unknown', @@ -99,47 +107,61 @@ async function runProbesAsync(options: ProbeLocalDeviceOptions): Promise { - return await Promise.all([ - ...(process.platform === 'darwin' ? [probeIosSimulatorAsync()] : []), - probeAndroidDeviceAsync(), - ]); -} - /** - * Fold the platform probes into one answer. + * Fold the two rungs into one answer. * - * Pure, so the rule that decides `absent` from `unknown` is testable without a simulator: a device - * anywhere wins; otherwise one tool that ran and found nothing is enough to say `absent`; and a - * round where nothing ran is `unknown`. + * Pure, so the rule that decides `absent` from `unknown` is testable without a simulator: any `up` + * binding or attached device is `present`; else an `unreadable` or `unknown` binding, or an `adb` + * that could not run, is `unknown`; else `absent`, with the first state seen as the reason. */ -export function readLocalDeviceProbe(probes: DeviceProbe[]): LocalDeviceProbe { - const devices = probes - .map((probe) => probe.device) - .filter((device): device is NavigateDevice => device != null); +export function readLocalDeviceProbe( + ios: Inspection | null, + android: DeviceProbe +): LocalDeviceProbe { + const devices: NavigateDevice[] = []; + if (ios?.state === 'up' && ios.binding) { + devices.push(navigateDeviceOf(ios.binding.device)); + } + if (android.device) { + devices.push(android.device); + } if (devices.length > 0) { return { state: 'present', device: devices[0]!, devices, reason: null }; } - const answered = probes.filter((probe) => probe.toolError == null); - const reason = probes - .map((probe) => probe.reason) - .filter(Boolean) - .join('; '); - - if (answered.length === 0) { - return { - state: 'unknown', - device: null, - devices: [], - reason: reason || 'no platform tool could be run, so nothing is known about this machine', - }; - } + const reasons = [ + ios ? `iOS: ${iosReason(ios)}` : null, + android.reason ? `Android: ${android.reason}` : null, + ].filter((reason): reason is string => reason != null); + const unknown = + ios?.state === 'unreadable' || ios?.state === 'unknown' || android.toolError != null; return { - state: 'absent', + state: unknown ? 'unknown' : 'absent', device: null, devices: [], - reason: reason || 'no device was found', + reason: + reasons.join('; ') || + (unknown ? 'nothing is known about this machine' : 'no device was found'), }; } + +function iosReason(inspection: Inspection): string { + switch (inspection.state) { + case 'none': + return 'no simulator is bound to this worktree'; + case 'unreadable': + return `the binding file ${inspection.path} does not parse`; + case 'unknown': + return inspection.cause === 'timeout' + ? 'the simulator check timed out' + : (inspection.toolError?.message.split('\n')[0] ?? 'the device tool could not run'); + case 'gone': + return inspection.cause === 'expired' + ? 'the lease on the bound simulator expired' + : 'the bound simulator is gone'; + case 'not-up': + return 'the bound simulator is not up'; + default: + return inspection.state; + } +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts index d304eee1..bed8687a 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import path from 'path'; import type { SpawnCaptureResult } from '../../utils/spawnCapture'; -import { registryDirectory } from '../registry'; +import { bindingPathFor, registryDirectory } from '../registry'; import type { Binding, DeviceTools } from '../types'; export const RUNTIME = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0'; @@ -157,3 +157,11 @@ export function writeBinding(file: string, binding: Binding): void { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(binding)); } + +/** Bind `udid` to the worktree, live for a year, for a suite that hands a verb a booted simulator. */ +export function seedIosBinding(projectRoot: string, udid: string, name = 'iPhone 17'): void { + writeBinding( + bindingPathFor(projectRoot, 'ios', 'local-ios'), + bindingFor(projectRoot, udid, { name, expiresAt: '2999-01-01T00:00:00.000Z' }) + ); +} diff --git a/packages/@expo/agent-cli/src/deviceBinding/index.ts b/packages/@expo/agent-cli/src/deviceBinding/index.ts index 0d63db1a..ea64ad6a 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/index.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/index.ts @@ -23,7 +23,15 @@ import { writeBindingFile, } from './registry'; import { defaultTools } from './tools'; -import type { AcquireAction, AcquireResult, Binding, DeviceTools, ReleasedDevice } from './types'; +import { + deviceIdOf, + deviceNameOf, + type AcquireAction, + type AcquireResult, + type Binding, + type DeviceTools, + type ReleasedDevice, +} from './types'; export { deviceCommand, noBoundDeviceError, deviceUnavailableError } from './errors'; export { @@ -34,6 +42,7 @@ export { } from './inspect'; export { findBoundDeviceAsync, type AndroidRung, type BoundDeviceSearch } from './rungs'; export { defaultTools } from './tools'; +export { deviceIdOf, deviceNameOf } from './types'; export type * from './types'; /** The waits of `acquire` and release; read verbs wait less (`./inspect.ts`). */ @@ -44,6 +53,14 @@ export function devicesDisabled(): boolean { return process.env.AGENT_CLI_NO_DEVICE === '1'; } +/** The one line a caller prints on stderr: which device the run bound, and how. */ +export function acquireLine({ device, action }: AcquireResult): string { + const label = `${deviceNameOf(device)} (${deviceIdOf(device)})`; + return action === 'created' + ? `Created the iOS simulator ${label} for this worktree.` + : `Reusing this worktree's iOS simulator ${label}.`; +} + export interface AcquireDeviceOptions { /** Any choice but a reuse is refused `not-reusable`. */ reuseOnly?: boolean; diff --git a/packages/@expo/agent-cli/src/installedApp/__tests__/ios-test.ts b/packages/@expo/agent-cli/src/installedApp/__tests__/ios-test.ts index a373d1eb..6a070435 100644 --- a/packages/@expo/agent-cli/src/installedApp/__tests__/ios-test.ts +++ b/packages/@expo/agent-cli/src/installedApp/__tests__/ios-test.ts @@ -74,7 +74,14 @@ function read( device?: string ) { const listPhones = vi.fn(async () => phones); + // The bound simulator is the first listed one; `--device` lists and filters instead. + const findBoundSimulatorAsync = vi.fn(async () => + simulators[0] + ? { simulator: { identifier: simulators[0].udid, name: simulators[0].name }, hint: null } + : { simulator: null, hint: 'How: run "npx @expo/agent-cli dev --ios --detach --wait-ready".' } + ); const result = readInstalledFingerprintIosAsync({ + projectRoot: '/project', expectedHash: 'current', appId, scheme: 'myapp', @@ -83,9 +90,10 @@ function read( ...fakeSimctl(simulators), listConnectedIosDevicesAsync: listPhones, readInstalledFingerprintIosDeviceAsync: readPhones, + findBoundSimulatorAsync, }, }); - return { result, listPhones, readPhones }; + return { result, listPhones, readPhones, findBoundSimulatorAsync }; } describe(readInstalledFingerprintIosAsync, () => { @@ -109,12 +117,24 @@ describe(readInstalledFingerprintIosAsync, () => { expect(readPhones).not.toHaveBeenCalled(); }); - it(`answers no-device when nothing is booted and no reachable phone is connected`, async () => { + // The registry's How line leads the hint, and the phone hint follows it when there is one. + it(`answers no-device with the dev command when nothing is bound and no reachable phone is connected`, async () => { const { result, readPhones } = read([], [phone({ reachable: false })]); - await expect(result).resolves.toEqual({ status: 'no-device' }); + await expect(result).resolves.toEqual({ + status: 'no-device', + hint: 'How: run "npx @expo/agent-cli dev --ios --detach --wait-ready".', + }); expect(readPhones).not.toHaveBeenCalled(); }); + it(`puts the dev command before the phone hint`, async () => { + const { result } = read([], [phone()]); + const answer = await result; + expect(answer.hint).toMatch( + /^How: run "npx @expo\/agent-cli dev --ios[^"]*"\. A physical iOS device/ + ); + }); + it(`asks for --device instead of probing several phones`, async () => { const { result, readPhones } = read( [], diff --git a/packages/@expo/agent-cli/src/installedApp/installedAppAsync.ts b/packages/@expo/agent-cli/src/installedApp/installedAppAsync.ts index 6106848d..cb0c06ac 100644 --- a/packages/@expo/agent-cli/src/installedApp/installedAppAsync.ts +++ b/packages/@expo/agent-cli/src/installedApp/installedAppAsync.ts @@ -58,6 +58,8 @@ export interface InstalledAppReport { /** Reads a fingerprint off a device. Injected by the tests; the platform readers are the defaults. */ export type InstalledFingerprintReader = (input: { + /** The worktree whose bound simulator answers on iOS. */ + projectRoot: string; platform: InstalledAppPlatform; appId: string; device: string | null; @@ -95,9 +97,9 @@ async function hasUnrecordedGeneratedDirAsync( return !checkedIn && readLastBuildRecord(projectRoot)[platform] == null; } -const defaultReader: InstalledFingerprintReader = ({ platform, device, ...input }) => +const defaultReader: InstalledFingerprintReader = ({ projectRoot, platform, device, ...input }) => platform === 'ios' - ? readInstalledFingerprintIosAsync({ ...input, device: device ?? undefined }) + ? readInstalledFingerprintIosAsync({ ...input, projectRoot, device: device ?? undefined }) : readInstalledFingerprintAndroidAsync({ ...input, device: device ?? undefined }); export async function checkInstalledAppAsync( @@ -185,6 +187,7 @@ async function checkPlatformAsync( // The device is read last, once the verdict is known to need it: a read has a cost, and on a // phone it launches the app. const installed = await deps.readInstalled({ + projectRoot, platform, appId, device: options.device, diff --git a/packages/@expo/agent-cli/src/installedApp/ios.ts b/packages/@expo/agent-cli/src/installedApp/ios.ts index 2a177572..40e2b039 100644 --- a/packages/@expo/agent-cli/src/installedApp/ios.ts +++ b/packages/@expo/agent-cli/src/installedApp/ios.ts @@ -1,14 +1,23 @@ // @ref llp/0005-runtime-loop-tools.rfc.md §How the file is read -// Which iOS device answers: booted simulators first, a physical device only when `--device` names -// it. Reading a simulator is a file read; reading a phone launches the app on it. +// @ref llp/0035-one-device-per-worktree.rfc.md §Readers +// Which iOS device answers: the simulator this worktree bound, a booted simulator `--device` +// names, or a physical device only when `--device` names it. Reading a simulator is a file read; +// reading a phone launches the app on it. import { listConnectedIosDevicesAsync, type IosDevice } from '../device/devicectl'; import { listBootedIosSimulatorsAsync } from '../device/simulators'; +import { findBoundDeviceAsync } from '../deviceBinding'; +import { CommandError } from '../utils/errors'; import { readInstalledFingerprintIosDeviceAsync } from './iosDevice'; import { readSimulatorsAsync, type IosSimulatorReaderDependencies } from './iosSimulator'; -import { matchesDeviceFilter, type InstalledFingerprintResult } from './installedFingerprint'; +import { + matchesDeviceFilter, + type InstalledAppDevice, + type InstalledFingerprintResult, +} from './installedFingerprint'; export interface IosReaderOptions { + projectRoot: string; expectedHash: string; device?: string; appId: string; @@ -18,15 +27,46 @@ export interface IosReaderOptions { deps?: IosSimulatorReaderDependencies & { listConnectedIosDevicesAsync?: typeof listConnectedIosDevicesAsync; readInstalledFingerprintIosDeviceAsync?: typeof readInstalledFingerprintIosDeviceAsync; + findBoundSimulatorAsync?: typeof findBoundSimulatorAsync; }; } +/** + * The bound simulator, without extending its lease, because `status` never writes. + * + * @returns the simulator, or the How line of the registry's refusal. + */ +async function findBoundSimulatorAsync( + projectRoot: string +): Promise<{ simulator: InstalledAppDevice | null; hint: string | null }> { + try { + const found = await findBoundDeviceAsync(projectRoot, { platform: 'ios', extend: false }); + if (found.device?.backend === 'local-ios') { + return { + simulator: { identifier: found.device.udid, name: found.device.name || found.device.udid }, + hint: null, + }; + } + return { simulator: null, hint: lastLine(found.refusal?.message) }; + } catch (error: unknown) { + if (error instanceof CommandError) { + return { simulator: null, hint: lastLine(error.message) }; + } + throw error; + } +} + +function lastLine(text: string | undefined): string | null { + return text?.trim().split('\n').at(-1) ?? null; +} + /** * The physical-device path runs only when `--device` names a phone that no simulator matches. * Everywhere else the phone is named in a hint and left alone, because probing it launches the app * on it and `status` starts nothing it was not asked to start. Naming the phone is the consent. */ export async function readInstalledFingerprintIosAsync({ + projectRoot, expectedHash, device: deviceFilter, appId, @@ -37,8 +77,8 @@ export async function readInstalledFingerprintIosAsync({ const { listConnectedIosDevicesAsync: listPhones = listConnectedIosDevicesAsync, readInstalledFingerprintIosDeviceAsync: readPhones = readInstalledFingerprintIosDeviceAsync, + findBoundSimulatorAsync: findBound = findBoundSimulatorAsync, } = deps; - let simulators = await listBootedIosSimulatorsAsync(deps); // Enumerating phones costs `devicectl` over a second, so it happens at most once and only // when a simulator cannot answer. @@ -47,27 +87,31 @@ export async function readInstalledFingerprintIosAsync({ const readPhonesAsync = (devices: IosDevice[]) => readPhones({ expectedHash, device: deviceFilter, appId, devices, scheme, timeoutMs }); + let simulators: InstalledAppDevice[]; if (deviceFilter) { - const matching = simulators.filter((simulator) => matchesDeviceFilter(deviceFilter, simulator)); - if (!matching.length) { + // `--device` lists to filter and binds nothing (llp/0035 Contract 3). + const booted = await listBootedIosSimulatorsAsync(deps); + simulators = booted.filter((simulator) => matchesDeviceFilter(deviceFilter, simulator)); + if (!simulators.length) { const devices = await listPhonesOnce(); const matchesPhone = devices.some((device) => matchesDeviceFilter(deviceFilter, { name: device.name, identifier: device.udid }) ); return matchesPhone ? readPhonesAsync(devices) : { status: 'no-device' }; } - simulators = matching; - } else if (!simulators.length) { - // A phone is never probed unasked, even when it is the only iOS device this machine has. - const devices = (await listPhonesOnce()).filter((device) => device.reachable); - if (!devices.length) { - return { status: 'no-device' }; + } else { + const bound = await findBound(projectRoot); + if (bound.simulator == null) { + // A phone is never probed unasked, even when it is the only iOS device this machine has. + const devices = (await listPhonesOnce()).filter((device) => device.reachable); + const names = devices.map((device) => device.name).join(', '); + const phoneHint = devices.length + ? `${devices.length > 1 ? `Physical iOS devices are connected (${names})` : `A physical iOS device is connected (${names})`}; check one with --device "", which launches the app on it.` + : null; + const hint = [bound.hint, phoneHint].filter(Boolean).join(' '); + return hint ? { status: 'no-device', hint } : { status: 'no-device' }; } - const names = devices.map((device) => device.name).join(', '); - return { - status: 'no-device', - hint: `${devices.length > 1 ? `Physical iOS devices are connected (${names})` : `A physical iOS device is connected (${names})`}; check one with --device "", which launches the app on it.`, - }; + simulators = [bound.simulator]; } const result = await readSimulatorsAsync(simulators, appId, expectedHash, deps); diff --git a/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts b/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts index 857cfe9d..f8382d08 100644 --- a/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts +++ b/packages/@expo/agent-cli/src/lint/__tests__/foreignFlags-test.ts @@ -183,8 +183,6 @@ describe('the flags this CLI writes onto a command line', () => { "-d src/device/installExpoGo.ts", "-extract src/device/expoGoVersion.ts", "-extract src/device/installedApps.ts", - "-j src/device/bootDevice.ts", - "-j src/navigate/device.ts", "-j src/runtime/targetPlatform.ts", "-list-avds src/device/bootDevice.ts", "-nP src/dev/portListener.ts", diff --git a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts index 74b9b997..61cf834f 100644 --- a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts +++ b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts @@ -3,14 +3,10 @@ import { EventEmitter } from 'events'; import { vol } from 'memfs'; import path from 'path'; -import { - parseBootedIosSimulator, - parseFirstAndroidDevice, - probeAndroidDeviceAsync, - probeIosSimulatorAsync, - resolveDeviceAsync, -} from '../device'; import { parseBootedIosSimulators } from '../../device/simulators'; +import { bindingPathFor } from '../../deviceBinding/registry'; +import type { Binding } from '../../deviceBinding'; +import { parseFirstAndroidDevice, probeAndroidDeviceAsync, resolveDeviceAsync } from '../device'; const realPlatform = process.platform; @@ -37,6 +33,8 @@ function mockSpawnQueue(answers: { stdout?: string; exitCode?: number | null }[] }) as any); } +const projectRoot = '/project'; + const BOOTED_SIMCTL_JSON = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.watchOS-26-0': [ @@ -49,24 +47,39 @@ const BOOTED_SIMCTL_JSON = JSON.stringify({ }, }); +const SHUTDOWN_SIMCTL_JSON = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { udid: 'IOS-1', name: 'iPhone 17', state: 'Shutdown', dataPath: '/ios' }, + ], + }, +}); + const ADB_DEVICES = ['List of devices attached', 'ZZZZ\tunauthorized', 'emulator-5554\tdevice', '']; +const NO_ADB_DEVICES = 'List of devices attached\n'; + +/** This worktree's iOS binding, naming `IOS-1`, an hour from expiry. */ +function bindSimulator(udid = 'IOS-1', { expiresAt = '2999-01-01T00:00:00.000Z' } = {}): void { + const binding: Binding = { + version: 1, + device: { backend: 'local-ios', platform: 'ios', udid, name: 'iPhone 17', origin: 'created' }, + projectRoot, + boundAt: '2026-10-08T09:00:00.000Z', + expiresAt, + }; + vol.fromJSON({ [bindingPathFor(projectRoot, 'ios', 'local-ios')]: JSON.stringify(binding) }); +} + +beforeEach(() => { + vol.reset(); + vol.fromJSON({ '/project/package.json': '{}' }); +}); afterEach(() => { mockPlatform(realPlatform); }); -describe(parseBootedIosSimulator, () => { - it(`should pick the first booted iOS simulator and skip other runtimes`, () => { - expect(parseBootedIosSimulator(BOOTED_SIMCTL_JSON)).toEqual({ - udid: 'IOS-1', - name: 'iPhone 17', - }); - }); - - it(`should return null when nothing is booted`, () => { - expect(parseBootedIosSimulator(JSON.stringify({ devices: {} }))).toBeNull(); - }); - +describe(parseBootedIosSimulators, () => { it(`should list every booted iOS simulator, and no watch`, () => { expect(parseBootedIosSimulators(BOOTED_SIMCTL_JSON)).toEqual([ { udid: 'IOS-1', name: 'iPhone 17' }, @@ -74,9 +87,9 @@ describe(parseBootedIosSimulator, () => { ]); }); - it(`should return null for output that is not simctl JSON`, () => { - expect(parseBootedIosSimulator('not json')).toBeNull(); - expect(parseBootedIosSimulator('')).toBeNull(); + it(`should list nothing for output that is not simctl JSON`, () => { + expect(parseBootedIosSimulators('not json')).toEqual([]); + expect(parseBootedIosSimulators('')).toEqual([]); }); }); @@ -90,36 +103,6 @@ describe(parseFirstAndroidDevice, () => { }); }); -describe(probeIosSimulatorAsync, () => { - it(`should return the booted simulator`, async () => { - mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }]); - - await expect(probeIosSimulatorAsync()).resolves.toEqual({ - device: { backend: 'local-ios', platform: 'ios', deviceId: 'IOS-1', name: 'iPhone 17' }, - }); - expect(spawn).toHaveBeenCalledWith( - 'xcrun', - ['simctl', 'list', 'devices', 'booted', '-j'], - expect.anything() - ); - }); - - it(`should report no simulator when none is booted`, async () => { - mockSpawnQueue([{ stdout: JSON.stringify({ devices: {} }) }]); - - const probe = await probeIosSimulatorAsync(); - - expect(probe.device).toBeNull(); - expect(probe.reason).toMatch(/booted/i); - }); - - it(`should report a failing simctl call`, async () => { - mockSpawnQueue([{ stdout: '', exitCode: 1 }]); - - expect((await probeIosSimulatorAsync()).device).toBeNull(); - }); -}); - describe(probeAndroidDeviceAsync, () => { it(`should return the first attached device`, async () => { mockSpawnQueue([{ stdout: ADB_DEVICES.join('\n') }]); @@ -160,7 +143,7 @@ describe(probeAndroidDeviceAsync, () => { }); it(`should report no device when none is attached`, async () => { - mockSpawnQueue([{ stdout: 'List of devices attached\n' }]); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }]); const probe = await probeAndroidDeviceAsync(); @@ -169,66 +152,105 @@ describe(probeAndroidDeviceAsync, () => { }); }); +// @ref llp/0035-one-device-per-worktree.rfc.md §Readers describe(resolveDeviceAsync, () => { - it(`should use the booted iOS simulator when --ios is given`, async () => { + it(`should use the bound iOS simulator when --ios is given, and extend its lease`, async () => { + bindSimulator(); mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }]); - await expect(resolveDeviceAsync('ios')).resolves.toEqual({ + await expect(resolveDeviceAsync('ios', { projectRoot })).resolves.toEqual({ backend: 'local-ios', platform: 'ios', deviceId: 'IOS-1', name: 'iPhone 17', }); + // One subprocess for the one binding, and never the booted-only listing. + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'list', 'devices', '-j'], + expect.anything() + ); + const written = JSON.parse( + vol.readFileSync(bindingPathFor(projectRoot, 'ios', 'local-ios'), 'utf8') as string + ); + expect(Date.parse(written.expiresAt)).toBeGreaterThan(Date.now() + 3_500_000); }); // The cloud backend costs money and spawns an `eas`, so it is on the ladder only for the callers - // that put it there. Every `runtime:*` action keeps the old two-backend resolution exactly. + // that put it there. Every `runtime:*` action keeps the local resolution exactly. it(`never looks for a cloud session unless the caller asked for one`, async () => { mockPlatform('darwin'); - mockSpawnQueue([ - { stdout: JSON.stringify({ devices: {} }) }, - { stdout: 'List of devices attached\n' }, - ]); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }]); - await resolveDeviceAsync().catch(() => {}); + await resolveDeviceAsync(undefined, { projectRoot }).catch(() => {}); - // Two probes and nothing else: no `eas` was started to find out about a session. - expect(spawn).toHaveBeenCalledTimes(2); + // The Android rung and nothing else: no binding, so no `simctl`, and no `eas`. + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it(`should name the dev command when --ios finds no binding`, async () => { + const error = await resolveDeviceAsync('ios', { projectRoot }).catch((e) => e); + + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.data).toEqual({ reason: 'none' }); + expect(error.message).toContain('npx @expo/agent-cli dev --ios --detach --wait-ready'); + expect(spawn).not.toHaveBeenCalled(); }); - it(`should explain how to boot a simulator when --ios finds none`, async () => { - mockSpawnQueue([{ stdout: JSON.stringify({ devices: {} }) }]); + it(`should refuse a bound simulator that is not up, naming the dev command`, async () => { + bindSimulator(); + mockSpawnQueue([{ stdout: SHUTDOWN_SIMCTL_JSON }]); - const error = await resolveDeviceAsync('ios').catch((e) => e); + const error = await resolveDeviceAsync('ios', { projectRoot }).catch((e) => e); - expect(error.code).toBe('NO_IOS_DEVICE'); - expect(error.message).toContain('npx @expo/agent-cli dev --ios --detach'); + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.data).toEqual({ reason: 'not-up' }); + expect(error.exitCode).toBe(20); }); - it(`should explain how to start an emulator when --android finds none`, async () => { - mockSpawnQueue([{ stdout: 'List of devices attached\n' }]); + // expired-is-gone + it(`should refuse an expired binding as gone, even with the simulator up`, async () => { + bindSimulator('IOS-1', { expiresAt: '2020-01-01T00:00:00.000Z' }); + mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }]); + + const error = await resolveDeviceAsync('ios', { projectRoot }).catch((e) => e); + + expect(error.data).toEqual({ reason: 'gone' }); + expect(error.message).toContain('lease'); + }); - const error = await resolveDeviceAsync('android').catch((e) => e); + it(`should keep the first adb device as the Android rung, until llp/0035 §Android instance`, async () => { + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }]); + const none = await resolveDeviceAsync('android', { projectRoot }).catch((e) => e); + expect(none.code).toBe('NO_BOUND_DEVICE'); + expect(none.message).toContain('npx @expo/agent-cli dev --android --detach --wait-ready'); - expect(error.code).toBe('NO_ANDROID_DEVICE'); - expect(error.message).toContain('adb devices'); + mockSpawnQueue([{ stdout: ADB_DEVICES.join('\n') }]); + await expect(resolveDeviceAsync('android', { projectRoot })).resolves.toMatchObject({ + backend: 'local-android', + deviceId: 'emulator-5554', + adb: expect.objectContaining({ bin: expect.any(String) }), + }); }); - it(`should prefer a booted iOS simulator on macOS when no platform is given`, async () => { + // any-up-wins-across-platforms + it(`should take the bound simulator on macOS when no platform is given`, async () => { mockPlatform('darwin'); - mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }]); + bindSimulator(); + mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }, { stdout: ADB_DEVICES.join('\n') }]); - await expect(resolveDeviceAsync()).resolves.toMatchObject({ platform: 'ios' }); + await expect(resolveDeviceAsync(undefined, { projectRoot })).resolves.toMatchObject({ + platform: 'ios', + }); }); - it(`should fall back to Android on macOS when no simulator is booted`, async () => { + it(`should fall back to Android on macOS when the simulator is not up`, async () => { mockPlatform('darwin'); - mockSpawnQueue([ - { stdout: JSON.stringify({ devices: {} }) }, - { stdout: ADB_DEVICES.join('\n') }, - ]); + bindSimulator(); + mockSpawnQueue([{ stdout: SHUTDOWN_SIMCTL_JSON }, { stdout: ADB_DEVICES.join('\n') }]); - await expect(resolveDeviceAsync()).resolves.toMatchObject({ + await expect(resolveDeviceAsync(undefined, { projectRoot })).resolves.toMatchObject({ platform: 'android', deviceId: 'emulator-5554', }); @@ -236,24 +258,58 @@ describe(resolveDeviceAsync, () => { it(`should only look for an Android device off macOS`, async () => { mockPlatform('linux'); + bindSimulator(); mockSpawnQueue([{ stdout: ADB_DEVICES.join('\n') }]); - await expect(resolveDeviceAsync()).resolves.toMatchObject({ platform: 'android' }); + await expect(resolveDeviceAsync(undefined, { projectRoot })).resolves.toMatchObject({ + platform: 'android', + }); expect(spawn).toHaveBeenCalledTimes(1); }); - it(`should name both platforms when nothing is booted`, async () => { + // rung-refusal-reports-first-state + it(`should report the first state that is not none when nothing is up`, async () => { mockPlatform('darwin'); - mockSpawnQueue([ - { stdout: JSON.stringify({ devices: {} }) }, - { stdout: 'List of devices attached\n' }, - ]); + bindSimulator(); + mockSpawnQueue([{ stdout: SHUTDOWN_SIMCTL_JSON }, { stdout: NO_ADB_DEVICES }]); - const error = await resolveDeviceAsync().catch((e) => e); + const error = await resolveDeviceAsync(undefined, { projectRoot }).catch((e) => e); - expect(error.code).toBe('NO_DEVICE'); + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.data).toEqual({ reason: 'not-up' }); expect(error.message).toContain('--ios'); - expect(error.message).toContain('--android'); + }); + + it(`should name iOS on macOS when nothing is bound anywhere`, async () => { + mockPlatform('darwin'); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }]); + + const error = await resolveDeviceAsync(undefined, { projectRoot }).catch((e) => e); + + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.message).toContain('dev --ios'); + }); + + // platform-flag-throws-tool-error-first + it(`should throw an unrunnable adb at once with --android, and after the EAS rung without`, async () => { + const unrunnable = () => { + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + process.nextTick(() => + child.emit('error', Object.assign(new Error('spawn adb ENOENT'), { code: 'ENOENT' })) + ); + return child as any; + }; + mockPlatform('linux'); + vi.mocked(spawn).mockImplementation(unrunnable as any); + + const flagged = await resolveDeviceAsync('android', { projectRoot }).catch((e) => e); + expect(flagged.code).toBe('ADB_NOT_RUNNABLE'); + + const unflagged = await resolveDeviceAsync(undefined, { projectRoot }).catch((e) => e); + expect(unflagged.code).toBe('ADB_NOT_RUNNABLE'); }); }); @@ -266,8 +322,7 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { * The runner every `eas` invocation goes through, planted where the resolver will look. * * A real `PATH` entry of this process, because these suites run on memfs and the resolver searches - * `process.env.PATH` (`src/utils/easCli.ts` §resolveEasCli). Planting a `node_modules/.bin/eas` - * used to be what made this hermetic; there is no rung that reads one any more. + * `process.env.PATH` (`src/utils/easCli.ts` §resolveEasCli). */ const RUNNER_DIR = (process.env.PATH ?? '/usr/local/bin').split(path.delimiter)[0]!; @@ -299,72 +354,89 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { /** Nothing running, and an account that does have the feature. */ const noSessions = [{ stdout: listing() }, { stdout: '{"available": true}' }]; - afterEach(() => vol.reset()); - - it(`opens on the cloud session when this machine has no local device`, async () => { + it(`opens on the dotenv session when this worktree has no local device`, async () => { mockPlatform('darwin'); cloudProject('sess-1'); - mockSpawnQueue([ - { stdout: JSON.stringify({ devices: {} }) }, - { stdout: 'List of devices attached\n' }, - { stdout: liveSession }, - ]); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, { stdout: liveSession }]); await expect( - resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot: '/project' }) + resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot }) ).resolves.toMatchObject({ backend: 'cloud', platform: 'ios', deviceId: 'sess-1' }); }); // The local device is free, instant, and the one a developer is looking at. A cloud session must // never quietly take a run away from it. - it(`prefers the local simulator over a session that is also up`, async () => { + it(`prefers the bound simulator over a session that is also up`, async () => { mockPlatform('darwin'); cloudProject('sess-1'); - mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }]); + bindSimulator(); + mockSpawnQueue([{ stdout: BOOTED_SIMCTL_JSON }, { stdout: ADB_DEVICES.join('\n') }]); await expect( - resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot: '/project' }) + resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot }) ).resolves.toMatchObject({ backend: 'local-ios' }); - expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(2); }); - // The rung asks the service rather than the filesystem now, so a project with no dotenv still - // finds a session somebody else started — and a project with nothing running still says so. - it(`asks the service even when the project has no dotenv`, async () => { + // fallback-eas-rung-dotenv-only: a worktree with no binding must not drive another worktree's + // session, so the newest session is never taken until llp/0035 §EAS session binding binds sessions. + it(`refuses a session the project's dotenv does not name`, async () => { mockPlatform('linux'); cloudProject(null); - mockSpawnQueue([{ stdout: 'List of devices attached\n' }, { stdout: liveSession }]); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, { stdout: liveSession }]); - await expect( - resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot: '/project' }) - ).resolves.toMatchObject({ backend: 'cloud', deviceId: 'sess-1' }); + const error = await resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot }).catch( + (e) => e + ); + + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.message).toContain('running EAS Simulator session'); + expect(error.message.split('\n').at(-1)).toMatch(/^How: /); }); it(`still reports no device when the service lists nothing`, async () => { mockPlatform('linux'); cloudProject(null); - mockSpawnQueue([{ stdout: 'List of devices attached\n' }, ...noSessions]); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, ...noSessions]); - const error = await resolveDeviceAsync(undefined, { - cloud: 'fallback', - projectRoot: '/project', - }).catch((e) => e); + const error = await resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot }).catch( + (e) => e + ); - expect(error.code).toBe('NO_DEVICE'); + expect(error.code).toBe('NO_BOUND_DEVICE'); }); it(`names a session that is on record and not running, in the failure`, async () => { mockPlatform('linux'); cloudProject('sess-1'); - mockSpawnQueue([{ stdout: 'List of devices attached\n' }, ...noSessions]); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, ...noSessions]); + + const error = await resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot }).catch( + (e) => e + ); + + expect(error.message).toContain('EAS Simulator session on record'); + expect(error.message).toContain('npx @expo/agent-cli dev --ios --eas'); + }); + + it(`keeps the route URL above the How line of the refusal`, async () => { + mockPlatform('linux'); + cloudProject(null); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, ...noSessions]); const error = await resolveDeviceAsync(undefined, { cloud: 'fallback', - projectRoot: '/project', + projectRoot, + url: 'exp://127.0.0.1:8081/--/settings', + devServerRunning: true, }).catch((e) => e); - expect(error.message).toContain('EAS Simulator session on record'); - expect(error.message).toContain('npx @expo/agent-cli dev --ios --eas'); + const lines = error.message.split('\n'); + expect(lines.find((line: string) => line.startsWith('Or: this is the URL'))).toContain( + 'exp://127.0.0.1:8081/--/settings' + ); + expect(lines.at(-1)).toMatch(/^How: /); + expect(error.suggestedCommand).toBe('npx @expo/agent-cli navigate / --print-url'); }); // `--eas` names the device, so no local tool is asked at all. @@ -374,7 +446,7 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { mockSpawnQueue([{ stdout: liveSession }]); await expect( - resolveDeviceAsync(undefined, { cloud: 'required', projectRoot: '/project' }) + resolveDeviceAsync(undefined, { cloud: 'required', projectRoot }) ).resolves.toMatchObject({ backend: 'cloud', deviceId: 'sess-1' }); expect(spawn).toHaveBeenCalledTimes(1); }); @@ -383,10 +455,9 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { cloudProject('sess-1'); mockSpawnQueue([{ stdout: liveSession }]); - const error = await resolveDeviceAsync('android', { - cloud: 'required', - projectRoot: '/project', - }).catch((e) => e); + const error = await resolveDeviceAsync('android', { cloud: 'required', projectRoot }).catch( + (e) => e + ); expect(error.code).toBe('CLOUD_SIMULATOR_PLATFORM_MISMATCH'); expect(error.message).toContain('--ios'); @@ -396,10 +467,9 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { cloudProject(null); mockSpawnQueue(noSessions); - const error = await resolveDeviceAsync(undefined, { - cloud: 'required', - projectRoot: '/project', - }).catch((e) => e); + const error = await resolveDeviceAsync(undefined, { cloud: 'required', projectRoot }).catch( + (e) => e + ); expect(error.code).toBe('NO_CLOUD_SIMULATOR_SESSION'); expect(error.message).toContain('npx @expo/agent-cli dev --ios --eas'); @@ -410,10 +480,9 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { cloudProject('sess-1'); mockSpawnQueue([{ stdout: '', exitCode: 0 }]); - const error = await resolveDeviceAsync(undefined, { - cloud: 'required', - projectRoot: '/project', - }).catch((e) => e); + const error = await resolveDeviceAsync(undefined, { cloud: 'required', projectRoot }).catch( + (e) => e + ); expect(error.code).toBe('CLOUD_SIMULATOR_SESSION_UNKNOWN'); expect(error.message).not.toContain('simulator:start'); diff --git a/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts b/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts index c37b53cc..3a3a0c72 100644 --- a/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts +++ b/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import { EventEmitter } from 'events'; import { vol } from 'memfs'; +import { seedIosBinding } from '../../deviceBinding/__tests__/fakeTools'; import { resetApprovedSchemes } from '../../device/approveScheme'; import { readDevServerLockAsync, readLastLoggedDevServerPort } from '../../devLock'; @@ -128,6 +129,9 @@ function mockDevServersAt(byOrigin: { [origin: string]: unknown[] }) { function options(overrides: Partial = {}): NavigateOptions { return { route: '/profile/42', + // Named, so the rung loop inspects the bound simulator alone; the no-flag walk over both + // platforms is pinned in `device-test.ts` (llp/0035 §Readers). + platform: 'ios', devServerUrl: 'http://127.0.0.1:8081', printUrl: false, cloud: 'fallback', @@ -159,6 +163,7 @@ beforeEach(() => { vi.mocked(readDevServerLockAsync).mockResolvedValue(null); vi.mocked(readLastLoggedDevServerPort).mockReturnValue(null); mockPlatform('darwin'); + seedIosBinding(projectRoot, 'IOS-1'); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -758,7 +763,9 @@ describe(navigateAsync, () => { mockDevServer([EXPO_GO_TARGET]); mockSpawnQueue([]); - await expect(navigateAsync(projectRoot, options({ printUrl: true }))).resolves.toBe(0); + await expect( + navigateAsync(projectRoot, options({ printUrl: true, platform: undefined })) + ).resolves.toBe(0); expect(spawn).not.toHaveBeenCalled(); expect(printed()).toContain('exp://127.0.0.1:8081/--/profile/42'); @@ -770,7 +777,10 @@ describe(navigateAsync, () => { mockTunnelledRun('znakdiwe5j2n5o0.boltexpo.dev'); mockDevServer([EXPO_GO_TARGET]); - await navigateAsync(projectRoot, options({ devServerUrl: null, printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ devServerUrl: null, printUrl: true, json: true, platform: undefined }) + ); const report = JSON.parse(printed()); expect(report.url).toBe('exp://znakdiwe5j2n5o0.boltexpo.dev/--/profile/42'); @@ -783,7 +793,10 @@ describe(navigateAsync, () => { mockTunnelledRun('znakdiwe5j2n5o0.boltexpo.dev'); mockDevServer([EXPO_GO_TARGET]); - await navigateAsync(projectRoot, options({ devServerUrl: null, printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ devServerUrl: null, printUrl: true, json: true, platform: undefined }) + ); expect(JSON.parse(printed())).toMatchObject({ hostType: 'tunnel' }); }); @@ -797,7 +810,10 @@ describe(navigateAsync, () => { mockExpoGoProject(); mockDevServer([{ id: '1', appId: 'com.example.demo' }]); - await navigateAsync(projectRoot, options({ printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ printUrl: true, json: true, platform: undefined }) + ); const report = JSON.parse(printed()); expect(report.url).toBe('demoapp://profile/42'); @@ -817,7 +833,10 @@ describe(navigateAsync, () => { mockTunnelledRun('znakdiwe5j2n5o0.boltexpo.dev'); mockDevServer([{ id: '1', appId: 'com.example.demo' }]); - await navigateAsync(projectRoot, options({ devServerUrl: null, printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ devServerUrl: null, printUrl: true, json: true, platform: undefined }) + ); expect(JSON.parse(printed()).connect[0].url).toBe( 'demoapp://?__expo_url=https%3A%2F%2Fznakdiwe5j2n5o0.boltexpo.dev&__expo_disable_onboarding=1&__expo_disable_fab=1&__expo_disable_auto_launch=1' @@ -828,7 +847,7 @@ describe(navigateAsync, () => { mockExpoGoProject(); mockDevServer([{ id: '1', appId: 'com.example.demo' }]); - await navigateAsync(projectRoot, options({ printUrl: true })); + await navigateAsync(projectRoot, options({ printUrl: true, platform: undefined })); expect(printed()).toContain('demoapp://?__expo_url='); expect(printed()).toContain('open in the development build'); @@ -880,7 +899,10 @@ describe(navigateAsync, () => { }); mockDevServer([EXPO_GO_TARGET]); - await navigateAsync(projectRoot, options({ printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ printUrl: true, json: true, platform: undefined }) + ); expect(JSON.parse(printed()).url).toBe('exp://127.0.0.1:8081/--/profile/42'); }); @@ -895,7 +917,10 @@ describe(navigateAsync, () => { }); mockDevServer([]); - await navigateAsync(projectRoot, options({ printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ printUrl: true, json: true, platform: undefined }) + ); const report = JSON.parse(printed()); expect(report.connect).toEqual([ @@ -916,7 +941,7 @@ describe(navigateAsync, () => { }); mockDevServer([]); - await navigateAsync(projectRoot, options({ printUrl: true })); + await navigateAsync(projectRoot, options({ printUrl: true, platform: undefined })); expect(printed()).toContain('could not be established, so both'); expect(printed()).toContain('Expo Go'); @@ -929,7 +954,10 @@ describe(navigateAsync, () => { mockExpoGoProject(); mockDevServer([{ id: '1', appId: 'com.example.demo' }]); - await navigateAsync(projectRoot, options({ printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ printUrl: true, json: true, platform: undefined }) + ); const report = JSON.parse(printed()); expect(report.url).toBe('demoapp://profile/42'); @@ -943,7 +971,10 @@ describe(navigateAsync, () => { mockExpoGoProject(); mockDevServer([EXPO_GO_TARGET]); - await navigateAsync(projectRoot, options({ printUrl: true, json: true })); + await navigateAsync( + projectRoot, + options({ printUrl: true, json: true, platform: undefined }) + ); const report = JSON.parse(printed()); expect(report).toMatchObject({ @@ -1007,14 +1038,10 @@ describe(navigateAsync, () => { [`${projectRoot}/app.json`]: JSON.stringify({ expo: { slug: 'demo', scheme: 'demoapp' } }), }); mockDevServer([EXPO_GO_TARGET]); - // No booted simulator, and no attached Android device. - mockSpawnQueue([ - { stdout: JSON.stringify({ devices: {} }) }, - { stdout: 'List of devices attached\n' }, - ]); - + // The bound simulator is gone from the listing, and no platform flag names the other rung. + mockSpawnQueue([{ stdout: JSON.stringify({ devices: {} }) }]); await expect(navigateAsync(projectRoot, options())).rejects.toMatchObject({ - code: 'NO_DEVICE', + code: 'NO_BOUND_DEVICE', message: expect.stringContaining('exp://127.0.0.1:8081/--/profile/42'), suggestedCommand: 'npx @expo/agent-cli navigate / --print-url', }); diff --git a/packages/@expo/agent-cli/src/navigate/device.ts b/packages/@expo/agent-cli/src/navigate/device.ts index 1d43fcfb..3dfc30ef 100644 --- a/packages/@expo/agent-cli/src/navigate/device.ts +++ b/packages/@expo/agent-cli/src/navigate/device.ts @@ -1,8 +1,10 @@ // @ref llp/0005-runtime-loop-tools.rfc.md // @ref llp/0005-runtime-loop-tools.rfc.md §Cloud simulator -// Device discovery for deep-link navigation. Three backends, and the first two are the first booted -// iOS simulator and the first attached Android device, read from the platform tools as subprocesses -// so no simulator or emulator library is linked into the CLI. +// @ref llp/0035-one-device-per-worktree.rfc.md §Readers +// Device resolution for deep-link navigation. Three backends. The local ones are the devices this +// worktree bound with `dev` (`src/deviceBinding/`), read from the registry and verified through +// the platform tools as subprocesses; until llp/0035 §Android instance binds Android, that rung is still the first +// attached `adb` device. // // The third is not on this machine at all: an EAS Simulator session, driven through `eas // simulator:*` (`src/device/cloudSimulator.ts`). It is opt-in per caller rather than always @@ -15,6 +17,7 @@ import { adbNotRunnableError, parseAndroidDevices, + resolveAdb, runAdbAsync, type AdbResolution, } from '../device/adb'; @@ -25,12 +28,12 @@ import { cloudSessionUnavailableError, cloudSessionUnknownError, probeCloudSessionAsync, + readCloudSessionIdSync, type CloudSessionProbe, } from '../device/cloudSimulator'; -import { parseBootedIosSimulators } from '../device/simulators'; +import { findBoundDeviceAsync, type AndroidRung, type BoundDevice } from '../deviceBinding'; import { PROGRAM_PREFIX } from '../programName'; import { CommandError } from '../utils/errors'; -import { spawnCaptureAsync } from '../utils/spawnCapture'; import { debugEvent } from './events'; export type NavigatePlatform = 'ios' | 'android'; @@ -76,14 +79,21 @@ export interface DeviceProbe { toolError?: CommandError; } -/** - * Read the first booted iOS simulator out of `simctl list devices booted -j`. - * - * Only iOS runtimes are considered: a booted watchOS or tvOS simulator cannot open an app deep - * link for this project. - */ -export function parseBootedIosSimulator(stdout: string): { udid: string; name: string } | null { - return parseBootedIosSimulators(stdout)[0] ?? null; +/** A bound device as the deep-link ladder drives it. */ +export function navigateDeviceOf(device: BoundDevice): NavigateDevice { + switch (device.backend) { + case 'local-ios': + return { backend: 'local-ios', platform: 'ios', deviceId: device.udid, name: device.name }; + case 'local-android': + return { + backend: 'local-android', + platform: 'android', + deviceId: device.serial, + adb: resolveAdb(), + }; + case 'cloud': + return { backend: 'cloud', platform: device.platform, deviceId: device.id }; + } } /** Read the first ready device out of `adb devices`. */ @@ -91,56 +101,6 @@ export function parseFirstAndroidDevice(stdout: string): string | null { return parseAndroidDevices(stdout)[0]?.deviceId ?? null; } -/** Look for a booted iOS simulator. Never throws: no simulator is an answer. */ -export async function probeIosSimulatorAsync(): Promise { - const { stdout, stderr, exitCode, spawnError } = await spawnCaptureAsync('xcrun', [ - 'simctl', - 'list', - 'devices', - 'booted', - '-j', - ]); - - if (spawnError) { - return { - device: null, - reason: `could not run "xcrun simctl": ${spawnError.message}`, - // The same distinction the Android probe draws (F49): a tool that could not be started has - // said nothing about this machine's devices, and a caller that turns "no device" into a - // changed suggestion has to be able to tell the two apart (`src/device/localDevice.ts`). - toolError: new CommandError( - 'XCRUN_NOT_RUNNABLE', - [ - `Could not run "xcrun simctl", so no iOS simulator was looked for.`, - `Why: ${spawnError.message}`, - `How: install Xcode and its command line tools, which provide "xcrun simctl", then run this command again.`, - ].join('\n') - ), - }; - } - if (exitCode !== 0) { - return { - device: null, - reason: `"xcrun simctl list devices booted" failed: ${stderr.trim() || `exit code ${exitCode}`}`, - }; - } - - const simulator = parseBootedIosSimulator(stdout); - if (!simulator) { - return { device: null, reason: 'no booted iOS simulator was found' }; - } - - debugEvent('device_resolved', { platform: 'ios', deviceId: simulator.udid }); - return { - device: { - backend: 'local-ios', - platform: 'ios', - deviceId: simulator.udid, - name: simulator.name || undefined, - }, - }; -} - /** * Look for an attached Android device or emulator. Never throws: no device is an answer. * @@ -186,6 +146,32 @@ export async function probeAndroidDeviceAsync(): Promise { }; } +/** + * Main's Android rung as the rung loop takes it, until llp/0035 §Android instance binds Android. The `adb` the probe + * resolved is kept on the side, so `openRoute`'s `adb reverse` still runs with it. + */ +const androidRung = (found: { adb?: AdbResolution; model?: string }): AndroidRung => { + return async () => { + const probe = await probeAndroidDeviceAsync(); + if (probe.toolError) { + return { device: null, toolError: probe.toolError }; + } + if (!probe.device) { + return { device: null }; + } + found.adb = probe.device.adb; + found.model = probe.device.model; + return { + device: { + backend: 'local-android', + platform: 'android', + serial: probe.device.deviceId, + origin: { kind: 'explicit' }, + }, + }; + }; +}; + /** * Look for a cloud simulator session this project can drive. Never throws: no session is an answer. * @@ -232,114 +218,45 @@ export async function probeCloudDeviceAsync( * Resolve the device to open the deep link on. * * Three backends and one order. `--eas` (`cloud: 'required'`) names the cloud session and nothing - * else is looked at, because a caller that named a device meant that device. Otherwise a **local** - * device wins: it is free, it is instant, and it is what a developer at a keyboard is looking at. - * The cloud is the last rung, taken only when the local probes found nothing and this project has a - * session on record — which composes with wave 9's answer for that machine: instead of only being - * handed the URL, a run with a live session opens it. + * else is looked at, because a caller that named a device meant that device. Otherwise the + * **bound** local device wins: the rung loop of llp/0035 §Readers inspects this worktree's + * bindings, any `up` one wins, and the winner's lease is extended. The cloud is the last rung, + * taken only when every local rung passed and this project has a session on record. * - * With no platform flag, macOS prefers a booted iOS simulator and falls back to Android, because - * an iOS simulator is the device an Expo project on a Mac usually has open. Every other host has - * no iOS simulator at all, so only Android is checked there. - * - * @throws {CommandError} when the requested platform, or no backend at all, has a device. + * @throws {CommandError} `NO_BOUND_DEVICE` when no local device is bound and no session serves, + * and the tool error when a device tool could not run. */ export async function resolveDeviceAsync( - platform?: NavigatePlatform, - context: ResolveDeviceContext = {} + platform: NavigatePlatform | undefined, + context: ResolveDeviceContext ): Promise { if (context.cloud === 'required') { return await resolveCloudDeviceAsync(platform, context); } - if (platform === 'ios') { - const probe = await probeIosSimulatorAsync(); - if (probe.device) { - return probe.device; - } - if (probe.toolError) { - throw probe.toolError; - } - const cloud = await cloudFallbackAsync('ios', context); - if (cloud.device) { - return cloud.device; - } - throw noDeviceError( - 'NO_IOS_DEVICE', - [ - 'No booted iOS simulator was found, so there is no device to open the deep link on.', - `Why: ${probe.reason}.`, - `How: boot a simulator (from Xcode, or with "xcrun simctl boot"), start the dev server with "${PROGRAM_PREFIX} dev --ios --detach", then run this command again.`, - ], - context, - cloud.probe - ); - } - - if (platform === 'android') { - const probe = await probeAndroidDeviceAsync(); - if (probe.device) { - return probe.device; - } - // The tool, not the devices. Raised as its own failure so the reader is not sent to boot an - // emulator they already have running (F49). - if (probe.toolError) { - throw probe.toolError; - } - const cloud = await cloudFallbackAsync('android', context); - if (cloud.device) { - return cloud.device; - } - throw noDeviceError( - 'NO_ANDROID_DEVICE', - [ - 'No Android device or emulator was found, so there is no device to open the deep link on.', - `Why: ${probe.reason}.`, - 'How: start an emulator or connect a device, check that "adb devices" lists it, then run this command again.', - ], - context, - cloud.probe - ); - } - - const iosProbe = process.platform === 'darwin' ? await probeIosSimulatorAsync() : null; - if (iosProbe?.device) { - return iosProbe.device; - } - - const androidProbe = await probeAndroidDeviceAsync(); - if (androidProbe.device) { - return androidProbe.device; + const android: { adb?: AdbResolution; model?: string } = {}; + const found = await findBoundDeviceAsync(context.projectRoot, { + platform, + extend: true, + android: androidRung(android), + }); + if (found.device) { + const device = navigateDeviceOf(found.device); + debugEvent('device_resolved', { platform: device.platform, deviceId: device.deviceId }); + return device.backend === 'local-android' ? { ...device, ...android } : device; } // The cloud rung, before the tool failure and before the verdict: a machine whose `adb` will not // start is exactly the machine this backend is for, and a session that is up answers the question // whichever platform tool is missing. - const cloud = await cloudFallbackAsync(undefined, context); + const cloud = await cloudFallbackAsync(platform, context); if (cloud.device) { return cloud.device; } - - // With no platform flag on a host with no simulator, an unrunnable `adb` is the whole reason - // nothing was found, and saying "no booted device" would hide it. - if (androidProbe.toolError && iosProbe == null) { - throw androidProbe.toolError; + if (found.toolError) { + throw found.toolError; } - - const reasons = [ - iosProbe ? `iOS: ${iosProbe.reason}` : 'iOS: simulators only exist on macOS', - `Android: ${androidProbe.reason}`, - ]; - throw noDeviceError( - 'NO_DEVICE', - [ - 'No booted device was found, so there is no device to open the deep link on.', - `Why: ${reasons.join('; ')}.`, - `How: open the app on a simulator or device (for example with "${PROGRAM_PREFIX} run:ios" or "${PROGRAM_PREFIX} run:android"), then run this command again. Pass --ios or --android to name the platform to look on.`, - ], - context, - cloud.probe - ); + throw withOtherDoors(found.refusal, context, cloud.probe); } /** How much of the ladder a caller wants: whether the cloud backend is on it, and how. */ @@ -366,12 +283,15 @@ export interface ResolveDeviceContext { devServerRunning?: boolean; /** Whether the cloud backend is on this run's ladder. Defaults to `off`. */ cloud?: CloudPreference; - /** The project whose session is looked for. Required for anything but `off`. */ - projectRoot?: string; + /** The worktree whose bindings, and whose session, are looked for. */ + projectRoot: string; } /** - * The cloud rung of the ladder, taken after the local probes found nothing. + * The cloud rung of the ladder, taken after the local rungs passed. + * + * Until llp/0035 §EAS session binding binds sessions, the fallback accepts the dotenv id only, never the newest + * session: a worktree with no binding must not drive another worktree's session. * * A session on the **other** platform is not this run's device: `--ios` named iOS, and an Android * session cannot open an iOS link. It is still reported, through the probe, so the failure can say @@ -381,11 +301,14 @@ async function cloudFallbackAsync( platform: NavigatePlatform | undefined, context: ResolveDeviceContext ): Promise<{ device: NavigateDevice | null; probe: CloudSessionProbe | null }> { - if (context.cloud !== 'fallback' || context.projectRoot == null) { + if (context.cloud !== 'fallback') { return { device: null, probe: null }; } const { device, probe } = await probeCloudDeviceAsync(context.projectRoot, { platform }); - const usable = device != null && (platform == null || device.platform === platform); + const usable = + device != null && + device.deviceId === readCloudSessionIdSync(context.projectRoot) && + (platform == null || device.platform === platform); return { device: usable ? device : null, probe }; } @@ -393,19 +316,12 @@ async function cloudFallbackAsync( * Resolve the cloud session as the device, for a run that named it. * * Every failure here is about the session rather than about this machine, which is why none of them - * reuses {@link noDeviceError}: a caller that passed `--eas` is not helped by "boot a simulator". + * reuses {@link withOtherDoors}: a caller that passed `--eas` is not helped by "boot a simulator". */ async function resolveCloudDeviceAsync( platform: NavigatePlatform | undefined, context: ResolveDeviceContext ): Promise { - if (context.projectRoot == null) { - throw new CommandError( - 'CLOUD_SIMULATOR_UNRESOLVED', - 'A cloud simulator was asked for and this command did not say which project to look for a session in, which is a bug in this CLI.' - ); - } - const { device, probe } = await probeCloudDeviceAsync(context.projectRoot, { platform }); if (device) { @@ -435,41 +351,48 @@ async function resolveCloudDeviceAsync( } /** - * The failure for a machine with no device, plus the URL when one was resolved. + * The registry's refusal, plus the URL when one was resolved and the session when one is on record. * - * "No device found" is the whole truth and less than half the answer for the case this exists for: - * a dogfood session drove Expo Go on a **cloud** simulator, from a laptop with no simulator of its + * "No device" is the whole truth and less than half the answer for the case this exists for: a + * dogfood session drove Expo Go on a **cloud** simulator, from a laptop with no simulator of its * own, and every `navigate` it ran stopped here [observed — 2026-08-24]. The URL was resolved a * step earlier and thrown away with the error, and the URL is exactly what an external opener * needs. So it is named, and so is the flag that prints it without asking for a device at all. * - * Deliberately **not** applied to the tool failures above: an unrunnable `adb` or `xcrun` has a - * headline about this machine's SDK, and burying it under an alternative is friction run 6's F49. + * Deliberately **not** applied to the tool failures: an unrunnable `adb` or `xcrun` has a headline + * about this machine's SDK, and burying it under an alternative is friction run 6's F49. */ -function noDeviceError( - code: string, - lines: string[], +function withOtherDoors( + refusal: CommandError, context: ResolveDeviceContext, - cloudProbe: CloudSessionProbe | null = null + cloudProbe: CloudSessionProbe | null ): CommandError { - const withUrl = context.url - ? [ - ...lines, - `Or: this is the URL for that route — ${context.url}${ - context.devServerRunning ? '' : ' (no dev server answered, so it may not load yet)' - }. Open it on a phone, a cloud simulator, or anywhere else that can reach the dev server; "${PROGRAM_PREFIX} navigate --print-url" prints it without looking for a device.`, - ] - : lines; - + const lines = refusal.message.split('\n'); + // The How line stays last (llp/0035 §Output and errors), so the doors go in above it. + const how = lines.pop()!; + if (context.url) { + lines.push( + `Or: this is the URL for that route — ${context.url}${ + context.devServerRunning ? '' : ' (no dev server answered, so it may not load yet)' + }. Open it on a phone, a cloud simulator, or anywhere else that can reach the dev server; "${PROGRAM_PREFIX} navigate --print-url" prints it without looking for a device.` + ); + } // A session on record that this run could not use is the one alternative worth naming above the // URL: it is a device this CLI *can* drive, and the reader is one command away from it. Only when - // there is one — a project that has never started a session gets the generic line above and no - // advertisement for a paid feature it may not have. + // there is one — a project that has never started a session gets no advertisement for a paid + // feature it may not have. const cloudLine = cloudSessionLine(cloudProbe); - const error = new CommandError(code, [...withUrl, ...(cloudLine ? [cloudLine] : [])].join('\n')); + if (cloudLine) { + lines.push(cloudLine); + } + const error = new CommandError(refusal.code, [...lines, how].join('\n')); + error.exitCode = refusal.exitCode; + error.data = refusal.data; // A caller that has no device does not get one by running the same command again, so the `Try:` - // is the mode that answers without one. - error.suggestedCommand = context.url ? `${PROGRAM_PREFIX} navigate / --print-url` : undefined; + // is the mode that answers without one when there is a URL. + error.suggestedCommand = context.url + ? `${PROGRAM_PREFIX} navigate / --print-url` + : refusal.suggestedCommand; return error; } @@ -488,7 +411,8 @@ function cloudSessionLine(probe: CloudSessionProbe | null): string | null { : null; } if (probe.state === 'active') { - // Reached when the session is live and is for the *other* platform, or reported none. + // Reached when the session is live and is for the *other* platform, is not this worktree's + // dotenv session, or reported none. return `Or: this project has a running EAS Simulator session${ probe.platform ? ` (${probe.platform})` : '' }, and it is not the device this run asked for. Run this command again with --eas and no platform flag to use it.`; diff --git a/packages/@expo/agent-cli/src/navigate/index.ts b/packages/@expo/agent-cli/src/navigate/index.ts index 0fd53fcf..f3774e14 100644 --- a/packages/@expo/agent-cli/src/navigate/index.ts +++ b/packages/@expo/agent-cli/src/navigate/index.ts @@ -9,7 +9,7 @@ export const navigateHelp: CommandHelp = { usage: `${PROGRAM_PREFIX} navigate `, options: [ `--scheme URL scheme of the app, instead of the one in app.json`, - `--ios Open the link on the booted iOS simulator`, + `--ios Open the link on the iOS simulator bound to this worktree`, `--android Open the link on the attached Android device`, `--eas Open the link on this project's EAS Simulator session`, `--app-id Application id of the target app`, @@ -25,11 +25,11 @@ export const navigateHelp: CommandHelp = { examples: [ { run: `${PROGRAM_PREFIX} navigate /`, - gets: 'the app opens on a booted device, at the root route', + gets: 'the app opens on the bound device, at the root route', }, { run: `${PROGRAM_PREFIX} navigate /profile/42 --ios`, - gets: 'that route opens on the booted iOS simulator', + gets: 'that route opens on the bound iOS simulator', }, { run: `${PROGRAM_PREFIX} navigate "/search?q=shoes" --json`, diff --git a/packages/@expo/agent-cli/src/navigate/openRoute.ts b/packages/@expo/agent-cli/src/navigate/openRoute.ts index 4e1a07d9..ba37fef7 100644 --- a/packages/@expo/agent-cli/src/navigate/openRoute.ts +++ b/packages/@expo/agent-cli/src/navigate/openRoute.ts @@ -61,7 +61,9 @@ import { resolveDeepLinkUrl, type ProjectSchemeConfig, } from './deepLink'; +import type { BoundDevice } from '../deviceBinding'; import { + navigateDeviceOf, resolveDeviceAsync, type CloudPreference, type DeviceBackend, @@ -138,6 +140,12 @@ export interface OpenRouteOptions { * @see llp/0005-runtime-loop-tools.rfc.md §Cloud simulator */ cloud?: CloudPreference; + /** + * The device `dev` already bound and booted for this run, so the open inspects nothing twice. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it + */ + device?: BoundDevice; } /** Everything the URL alone amounts to: the answer of a run that opens nothing. */ @@ -460,12 +468,14 @@ export async function openRouteAsync( // The URL is known by now, so a machine with no device is told what to do with it rather than // only that it has none: an agent driving a cloud simulator has somewhere else to open it. - const device = await resolveDeviceAsync(platform, { - url: resolved.url, - devServerRunning: resolved.devServerReachable, - cloud: options.cloud, - projectRoot, - }); + const device = options.device + ? navigateDeviceOf(options.device) + : await resolveDeviceAsync(platform, { + url: resolved.url, + devServerRunning: resolved.devServerReachable, + cloud: options.cloud, + projectRoot, + }); // @ref src/device/cloudSimulator.ts §cloudNeedsTunnelError — refused before anything is opened. // A cloud simulator is on EAS's network, so a `127.0.0.1` or LAN host in the link resolves to diff --git a/packages/@expo/agent-cli/src/plan/__tests__/resolveAsync-test.ts b/packages/@expo/agent-cli/src/plan/__tests__/resolveAsync-test.ts index 35f4ba57..9388f242 100644 --- a/packages/@expo/agent-cli/src/plan/__tests__/resolveAsync-test.ts +++ b/packages/@expo/agent-cli/src/plan/__tests__/resolveAsync-test.ts @@ -9,7 +9,14 @@ import type { LastBuildRecord } from '../lastBuild'; import { resetSettingsCache } from '../../settings'; import { detectToolchainAsync } from '../../toolchain'; import type { ToolchainProbe, ToolchainStatus } from '../../toolchain/types'; -import { resolveStartPlanAsync } from '../resolveAsync'; +import { resolveStartPlanAsync as resolve, type ResolveStartPlanOptions } from '../resolveAsync'; + +/** The plan alone, for the rows that bind nothing. */ +const resolveStartPlanAsync = async ( + root: string, + state: ProjectState, + options?: ResolveStartPlanOptions +) => (await resolve(root, state, options)).plan; vi.mock('../../toolchain', async () => { const actual = await vi.importActual('../../toolchain'); diff --git a/packages/@expo/agent-cli/src/plan/__tests__/resolveDevice-test.ts b/packages/@expo/agent-cli/src/plan/__tests__/resolveDevice-test.ts new file mode 100644 index 00000000..f87cd3d0 --- /dev/null +++ b/packages/@expo/agent-cli/src/plan/__tests__/resolveDevice-test.ts @@ -0,0 +1,187 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it +// Where the resolver binds the device, and what it hands the presence probe. +import { vol } from 'memfs'; + +import type { AcquireResult } from '../../deviceBinding'; +import type { ProjectState } from '../../project/types'; +import { resetSettingsCache } from '../../settings'; +import { detectToolchainAsync } from '../../toolchain'; +import type { ToolchainProbe } from '../../toolchain/types'; +import { resolveStartPlanAsync, type ResolveStartPlanOptions } from '../resolveAsync'; + +vi.mock('../../toolchain', async () => { + const actual = await vi.importActual('../../toolchain'); + return { ...actual, detectToolchainAsync: vi.fn() }; +}); + +const projectRoot = '/project'; + +const ACQUIRED: AcquireResult = { + device: { + backend: 'local-ios', + platform: 'ios', + udid: 'SIM-1', + name: 'agent-cli 0000', + origin: 'created', + }, + justBooted: true, + action: 'created', +}; + +function state(overrides: Partial = {}): ProjectState { + return { + projectRoot, + isExpoApp: true, + sdkVersion: '54.0.0', + nativeDirs: { ios: false, android: false }, + usesDevClient: false, + hasWeb: true, + expoGo: { compatible: true, reasons: [] }, + fingerprint: { hash: 'abc123def4567890' }, + ...overrides, + }; +} + +/** A managed project whose recorded iOS build matches: the draft serves and asks the device. */ +function freshDevClient() { + const project = state({ usesDevClient: true, expoGo: { compatible: false, reasons: [] } }); + return { state: project, lastBuild: { ios: { hash: project.fingerprint.hash!, sources: null } } }; +} + +function options(overrides: Partial = {}): ResolveStartPlanOptions { + return { + platform: 'ios', + requestedPlatform: 'ios', + probeAppPresence: async () => ({ presence: 'unknown', installDevice: null }), + ...overrides, + }; +} + +beforeEach(() => { + resetSettingsCache(); + vol.reset(); + vol.fromJSON({ [`${projectRoot}/package.json`]: JSON.stringify({ name: 'app' }) }); + vi.mocked(detectToolchainAsync).mockImplementation(async (platform): Promise => ({ + platform, + status: 'present', + detail: '', + requirement: '', + caveats: [], + impossible: false, + })); +}); + +describe('the device the resolver binds', () => { + // expo-go-and-bare-drafts-acquire + it.each([ + ['expo-go', state()], + ['dev-client', freshDevClient().state], + [ + 'bare', + state({ + nativeDirs: { ios: true, android: true }, + expoGo: { compatible: false, reasons: [] }, + }), + ], + ])(`binds for an %s draft`, async (_target, project) => { + const acquireDevice = vi.fn(async () => ACQUIRED); + + const { acquired } = await resolveStartPlanAsync(projectRoot, project, { + ...options({ acquireDevice }), + lastBuild: { ios: { hash: project.fingerprint.hash!, sources: null } }, + }); + + expect(acquireDevice).toHaveBeenCalledTimes(1); + expect(acquired).toBe(ACQUIRED); + }); + + it(`binds before the --no-open exit, so the install step can be pinned`, async () => { + const acquireDevice = vi.fn(async () => ACQUIRED); + const probeAppPresence = vi.fn(async () => ({ + presence: 'unknown' as const, + installDevice: null, + })); + + const { plan, acquired } = await resolveStartPlanAsync(projectRoot, freshDevClient().state, { + ...options({ acquireDevice, probeAppPresence, open: false }), + lastBuild: freshDevClient().lastBuild, + }); + + expect(acquired).toBe(ACQUIRED); + expect(probeAppPresence).not.toHaveBeenCalled(); + expect(plan.rule).toBe('dev-client-fresh'); + }); + + it.each([ + ['a web draft', state(), { platform: 'web' as const, requestedPlatform: 'web' as const }], + ['no requested platform', state(), { requestedPlatform: undefined }], + ['--eas', state(), { deviceBackend: 'eas' as const }], + ])(`binds nothing for %s`, async (_case, project, overrides) => { + const acquireDevice = vi.fn(async () => ACQUIRED); + + const { acquired } = await resolveStartPlanAsync( + projectRoot, + project, + options({ acquireDevice, ...overrides }) + ); + + expect(acquireDevice).not.toHaveBeenCalled(); + expect(acquired).toBeNull(); + }); + + it(`binds nothing for a draft that builds, which dev binds after its refusals`, async () => { + const acquireDevice = vi.fn(async () => ACQUIRED); + + const { plan, acquired } = await resolveStartPlanAsync( + projectRoot, + state({ usesDevClient: true, expoGo: { compatible: false, reasons: [] } }), + options({ acquireDevice }) + ); + + expect(plan.buildLocation).not.toBeNull(); + expect(acquireDevice).not.toHaveBeenCalled(); + expect(acquired).toBeNull(); + }); + + // created-device-plans-install + it(`hands the probe the bound device and its action, and plans the install it answers`, async () => { + const probeAppPresence = vi.fn(async () => ({ + presence: 'missing' as const, + installDevice: null, + })); + const { state: project, lastBuild } = freshDevClient(); + + const { plan } = await resolveStartPlanAsync(projectRoot, project, { + ...options({ acquireDevice: async () => ACQUIRED, probeAppPresence }), + lastBuild, + }); + + expect(probeAppPresence).toHaveBeenCalledWith(projectRoot, 'ios', { + device: ACQUIRED.device, + action: 'created', + }); + expect(plan.rule).toBe('dev-client-install'); + expect(plan.steps.map((step) => step.argv)).toEqual([ + ['expo', 'run:ios', '--no-bundler'], + ['expo', 'start', '--dev-client'], + ]); + }); + + it(`hands the probe no device when nothing bound one`, async () => { + const probeAppPresence = vi.fn(async () => ({ + presence: 'unknown' as const, + installDevice: null, + })); + const { state: project, lastBuild } = freshDevClient(); + + await resolveStartPlanAsync(projectRoot, project, { + ...options({ probeAppPresence }), + lastBuild, + }); + + expect(probeAppPresence).toHaveBeenCalledWith(projectRoot, 'ios', { + device: null, + action: null, + }); + }); +}); diff --git a/packages/@expo/agent-cli/src/plan/resolveAsync.ts b/packages/@expo/agent-cli/src/plan/resolveAsync.ts index c51ec28c..7984a5b8 100644 --- a/packages/@expo/agent-cli/src/plan/resolveAsync.ts +++ b/packages/@expo/agent-cli/src/plan/resolveAsync.ts @@ -3,10 +3,16 @@ // flags they typed, this host, and what the toolchain probe found. Everything it calls is pure // except the probe and two file reads, and it is the only module that knows the order they go in. -import { probeAppPresenceAsync, type AppPresenceProbe } from '../device/appPresence'; +import { + probeAppPresenceAsync, + type AppPresenceDevice, + type AppPresenceProbe, +} from '../device/appPresence'; +import type { AcquireResult } from '../deviceBinding'; import { easJsonExistsSync } from '../followups/projectFiles'; import type { ProjectState, StartPlan } from '../project/types'; import { readAgentCliSettings, settingsBuildBackend } from '../settings'; +import type { AgentCliSettings } from '../settings/types'; import type { BuildBackend, RunTarget } from '../settings/types'; import { applyToolchainProbe, detectToolchainAsync } from '../toolchain'; import { selectBuildBackend } from '../toolchain/selectBackend'; @@ -18,24 +24,11 @@ import type { DecideStartPlanOptions, NativePlatform, PlanEasBuild } from './typ import { EAS_SIMULATOR_PROFILE } from '../toolchain/runsOn'; import { hasBuildProfileSync } from '../utils/easJson'; -/** - * Whether this plan assumes an app that is already on a device, without having asked one. - * - * @ref llp/0004-smart-start-and-project-state.rfc.md §A current build is not an installed app - * - * Read off the plan rather than listed by name. A list of rule names is a second copy of the - * decision table, kept in a file the table does not import — and a row added there would go on - * planning a dev server for an app that is not installed, silently, because nothing would fail - * [review of #21]. What actually matters is the two properties the caller below acts on, and both - * are in the plan already: - * - * - **it builds nothing** — a plan that ends in `expo run:*` installs what it built, so there is - * nothing to ask and nothing to add; - * - **it runs a native app of this project** — `web` opens a browser, `expo-go` runs in a - * published app `expo start` offers to install itself, and `not-expo-app` has no app at all. - */ -function awaitsADevice(plan: StartPlan): boolean { - return plan.buildLocation == null && (plan.target === 'dev-client' || plan.target === 'bare'); +/** The plan, and the device `dev` bound while it was decided. */ +export interface ResolvedStartPlan { + plan: StartPlan; + /** Null when the caller passed no `acquireDevice`, or its callback answered null. */ + acquired: AcquireResult | null; } export interface ResolveStartPlanOptions extends DecideStartPlanOptions { @@ -48,8 +41,18 @@ export interface ResolveStartPlanOptions extends DecideStartPlanOptions { /** Injected for tests, so the device question is answerable without a device. */ probeAppPresence?: ( projectRoot: string, - platform: 'ios' | 'android' + platform: 'ios' | 'android', + bound: AppPresenceDevice ) => Promise; + /** + * Bind this worktree's device for a native draft, before the presence probe asks it. + * + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it + * Only `dev` passes one. It runs for every `expo-go`, `dev-client` and `bare` draft of a native + * platform without `--eas`, and before the `--no-open` exit, because the install step must be + * pinned whether or not this run opens the app. Null means the run bound nothing. + */ + acquireDevice?: (draft: StartPlan) => Promise; /** * Injected for tests: whether EAS has a finished simulator build of this fingerprint. * @@ -86,12 +89,13 @@ export async function resolveStartPlanAsync( projectRoot: string, state: ProjectState, options: ResolveStartPlanOptions = {} -): Promise { +): Promise { const { requestedBackend = null, requestedTarget = null, hostPlatform, probeAppPresence = probeAppPresenceAsync, + acquireDevice, lookUpEasBuild = (root, platform) => lookUpEasSimulatorBuildAsync(root, platform, { fingerprintCache: options.fingerprintCache }), hasSimulatorProfile = (root) => hasBuildProfileSync(root, EAS_SIMULATOR_PROFILE), @@ -107,56 +111,12 @@ export async function resolveStartPlanAsync( const draft = decideStartPlan(state, { ...planOptions, runTarget }); if (!draft.buildLocation) { - // @ref llp/0004-smart-start-and-project-state.rfc.md §A current build is not an installed app - // - // The device question is asked only where its answer would be acted on, and every guard here - // is one of the ways it would not be: - // - // - The rule has to be one that assumed an installed app. `web` opens a browser, `expo-go` is - // a runtime `expo start` offers to install itself, `not-expo-app` has no app. A plan that - // builds never reaches this branch, and rightly: it ends in `expo run:*`, which installs - // what it built. - // - This run has to be the one that opens the app. The install exists to serve the open, so a - // `--no-open` caller — `smoke`, which installs the app itself in its own phase, or an agent - // that opens with `navigate` — keeps the serve-only plan, and this also keeps `dev` from - // touching a device a caller said to leave alone. `status` passes no `requestedPlatform`, - // so it skips too, which keeps its report instant and internally consistent. - // - The backend has to be local. `expo run:* --no-bundler` compiles when the toolchain cache - // is cold, and a caller who routed builds to EAS with `--eas` or config did not ask for a - // local compile on the way to a dev server. - // - The device has to be on this machine. An EAS Simulator session started with `--build-id` - // has the app by construction (llp/0027), so there is no presence to ask about, and no - // local tool to ask it with. - const opensOn = - planOptions.open !== false && - planOptions.deviceBackend !== 'eas' && - (planOptions.requestedPlatform === 'ios' || planOptions.requestedPlatform === 'android') - ? planOptions.requestedPlatform - : null; - if (!awaitsADevice(draft) || opensOn == null) { - return draft; - } - if ((requestedBackend ?? settingsBuildBackend(settings, opensOn)) === 'eas') { - return draft; - } - const { presence, installDevice } = await probeAppPresence(projectRoot, opensOn); - if (presence === 'missing') { - // The install needs the local toolchain even when nothing compiles — `expo run:ios` runs - // through Xcode either way — so a machine without it keeps the serve-only plan rather than - // gaining a step that can only fail. The same probe a building plan pays for, on the one - // fresh path that is about to act like one. - const toolchain = await detectToolchainAsync(opensOn); - if (toolchain.status !== 'present') { - return draft; - } - } - // Run again rather than patch the draft: the table is the one place a rule and its steps are - // decided together, and a plan assembled anywhere else is a second table to keep in step. - return decideStartPlan(state, { - ...planOptions, - runTarget, - appPresence: presence, - installDevice, + return await resolveServingDraftAsync(projectRoot, state, draft, { + planOptions: { ...planOptions, runTarget }, + settings, + requestedBackend, + probeAppPresence, + acquireDevice, }); } @@ -193,5 +153,75 @@ export async function resolveStartPlanAsync( // The probe's caveats — an SDK the tooling finds and a tool of it the shell does not — belong to // a plan that still builds here. A plan that moved to the cloud has no use for them. - return plan.buildLocation?.runsOn === 'local' && probe ? applyToolchainProbe(plan, probe) : plan; + return { + plan: plan.buildLocation?.runsOn === 'local' && probe ? applyToolchainProbe(plan, probe) : plan, + acquired: null, + }; +} + +/** + * A draft that builds nothing: bind the device, then ask it whether the app is there. + * + * @ref llp/0004-smart-start-and-project-state.rfc.md §A current build is not an installed app + * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it — the exits, in order. The device question + * is asked only where its answer would be acted on: a `web` or `none` draft has no app on a device, + * `--eas` puts the device on EAS, an `expo-go` draft is a runtime `dev` installs itself, a + * `--no-open` caller keeps the serve-only plan, and a build routed to EAS did not ask for a local + * compile on the way to a dev server. The binding happens before the `expo-go` and `--no-open` + * exits, because the install step must be pinned whether or not this run opens the app. + */ +async function resolveServingDraftAsync( + projectRoot: string, + state: ProjectState, + draft: StartPlan, + { + planOptions, + settings, + requestedBackend, + probeAppPresence, + acquireDevice, + }: { + planOptions: DecideStartPlanOptions; + settings: AgentCliSettings; + requestedBackend: BuildBackend | null; + probeAppPresence: NonNullable; + acquireDevice: ResolveStartPlanOptions['acquireDevice']; + } +): Promise { + const requested = planOptions.requestedPlatform; + if ( + draft.target === 'web' || + draft.target === 'none' || + (requested !== 'ios' && requested !== 'android') || + planOptions.deviceBackend === 'eas' + ) { + return { plan: draft, acquired: null }; + } + const acquired = (await acquireDevice?.(draft)) ?? null; + if (draft.target === 'expo-go' || planOptions.open === false) { + return { plan: draft, acquired }; + } + if ((requestedBackend ?? settingsBuildBackend(settings, requested)) === 'eas') { + return { plan: draft, acquired }; + } + const { presence, installDevice } = await probeAppPresence(projectRoot, requested, { + device: acquired?.device ?? null, + action: acquired?.action ?? null, + }); + if (presence === 'missing') { + // The install needs the local toolchain even when nothing compiles — `expo run:ios` runs + // through Xcode either way — so a machine without it keeps the serve-only plan rather than + // gaining a step that can only fail. The same probe a building plan pays for, on the one + // fresh path that is about to act like one. + const toolchain = await detectToolchainAsync(requested); + if (toolchain.status !== 'present') { + return { plan: draft, acquired }; + } + } + // Run again rather than patch the draft: the table is the one place a rule and its steps are + // decided together, and a plan assembled anywhere else is a second table to keep in step. + return { + plan: decideStartPlan(state, { ...planOptions, appPresence: presence, installDevice }), + acquired, + }; } diff --git a/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts b/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts index 153e60cf..58662972 100644 --- a/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts +++ b/packages/@expo/agent-cli/src/runtime/__tests__/stopAsync-test.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import { EventEmitter } from 'events'; import { vol } from 'memfs'; +import { seedIosBinding } from '../../deviceBinding/__tests__/fakeTools'; import { readDevServerLockAsync, readLastLoggedDevServerPort } from '../../devLock'; import type { RuntimeStopOptions } from '../resolveStopOptions'; @@ -62,6 +63,7 @@ function mockDevServer(targets: unknown[] | null) { function options(overrides: Partial = {}): RuntimeStopOptions { return { devServerUrl: 'http://127.0.0.1:8081', + platform: 'ios', cloud: false, json: true, followups: false, @@ -80,6 +82,7 @@ beforeEach(() => { vi.mocked(readDevServerLockAsync).mockResolvedValue(null); vi.mocked(readLastLoggedDevServerPort).mockReturnValue(null); Object.defineProperty(process, 'platform', { value: 'darwin' }); + seedIosBinding(projectRoot, 'IOS-1'); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); vol.fromJSON({ [`${projectRoot}/app.json`]: JSON.stringify({ expo: { slug: 'demo' } }) }); diff --git a/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts b/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts index a6617626..c777cdf5 100644 --- a/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts +++ b/packages/@expo/agent-cli/src/runtime/reload/__tests__/reloadAsync-test.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import { EventEmitter } from 'events'; import fs from 'fs'; import { vol } from 'memfs'; +import { seedIosBinding } from '../../../deviceBinding/__tests__/fakeTools'; import path from 'path'; import { detachedLogPath } from '../../../dev/logFile'; @@ -236,6 +237,7 @@ beforeEach(() => { vi.mocked(readDevServerLockAsync).mockResolvedValue(null); vi.mocked(readLastLoggedDevServerPort).mockReturnValue(null); Object.defineProperty(process, 'platform', { value: 'darwin' }); + seedIosBinding(projectRoot, 'IOS-1'); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); diff --git a/packages/@expo/agent-cli/src/runtime/reload/index.ts b/packages/@expo/agent-cli/src/runtime/reload/index.ts index e8af1f7c..cde11a23 100644 --- a/packages/@expo/agent-cli/src/runtime/reload/index.ts +++ b/packages/@expo/agent-cli/src/runtime/reload/index.ts @@ -12,7 +12,7 @@ export const runtimeReloadHelp: CommandHelp = { options: [ `--route Open this route once the app is back`, `--method auto (default), dev-server, runtime, or device`, - `--ios, --platform ios Reload the app on the booted iOS simulator`, + `--ios, --platform ios Reload the app on the iOS simulator bound to this worktree`, `--android Reload the app on the attached Android device`, `--eas Reload the app on this project's EAS Simulator session`, `--scheme URL scheme for --route, instead of the one in app.json`, diff --git a/packages/@expo/agent-cli/src/runtime/stopAsync.ts b/packages/@expo/agent-cli/src/runtime/stopAsync.ts index cb4e4920..3d1e06e2 100644 --- a/packages/@expo/agent-cli/src/runtime/stopAsync.ts +++ b/packages/@expo/agent-cli/src/runtime/stopAsync.ts @@ -210,7 +210,7 @@ export async function runtimeStopAsync( device.backend === 'cloud' ? `Check that the session is still running with "${easCommandPrefix()} simulator:list --status in-progress".` : device.platform === 'ios' - ? 'Check that the simulator is booted with "xcrun simctl list devices booted".' + ? `Check that the bound simulator is up with "${PROGRAM_PREFIX} status", or bind one again with "${PROGRAM_PREFIX} dev --ios --detach --wait-ready".` : 'Check that the device is attached with "adb devices".' }`, ].join('\n') diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts index 90626fa4..18b83ccc 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/phases-test.ts @@ -506,50 +506,50 @@ describe(runSmokePhasesAsync, () => { expect(statusOf(run, 'boot-device')).toBeUndefined(); }); - // @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app. A boot that could - // not have opened the app is worse than no boot: it costs the minute *and* answers nothing. - // Declining is the right outcome, and the report has to say that rather than describing a - // simulator that would not start. - it(`reports a declined boot as a machine with nowhere to run the app`, async () => { + // @ref llp/0035-one-device-per-worktree.rfc.md §iOS smoke — the simulator is this worktree's bound one, so + // nothing is registered to shut it down, and the phase says nothing about putting it back. + it(`keeps a bound simulator that registered no cleanup, and says so`, async () => { + const shutdownDevice = vi.fn(); const run = await runSmokePhasesAsync( deps({ ...bareMachine({ bootDevice: async () => ({ - ok: false, - deviceId: null, - backend: null, - refused: true, - choice: null, - reason: 'no iOS simulator has Expo Go installed, so booting one would open nothing', + ok: true, + deviceId: 'SIM-BOUND', + backend: 'local-ios' as const, + reason: null, + choice: 'agent-cli 0000', + installNeeded: true, }), }), + shutdownDevice, }), options({ bootstrap: true }) ); - expect(run.outcome).toBe('failed'); - expect(statusOf(run, 'boot-device')).toBe('failed'); - expect(run.phases.find((phase) => phase.id === 'boot-device')?.reason).toContain( - 'would open nothing' - ); - // Nothing was booted, so nothing is `failed` about the device: the machine is as it was. - expect(run.environment.device).toBe('absent'); + expect(run.environment.device).toBe('booted'); + expect(run.environment.deviceChoice).toBe('agent-cli 0000'); + const reason = run.phases.find((phase) => phase.id === 'boot-device')?.reason; + expect(reason).toContain('booted SIM-BOUND for this run because agent-cli 0000'); + expect(reason).not.toContain('shut it down'); + expect(shutdownDevice).not.toHaveBeenCalled(); expect(run.environment.cleanup.map((entry) => entry.resource)).toEqual(['dev-server']); + // A created simulator has no app, and its disk holds nothing to read: the boot's word decides. + expect(statusOf(run, 'install-app')).toBe('ok'); }); - it(`says why it chose the device it booted`, async () => { + it(`says why it chose the emulator it booted, and that it goes back`, async () => { const run = await runSmokePhasesAsync( deps({ ...bareMachine({ bootDevice: async (register) => { - register({ deviceId: 'SIM-BOOTED', backend: 'local-ios' }); + register({ deviceId: 'emulator-5554', backend: 'local-android' }); return { ok: true, - deviceId: 'SIM-BOOTED', - backend: 'local-ios' as const, + deviceId: 'emulator-5554', + backend: 'local-android' as const, reason: null, - refused: false, - choice: 'it has Expo Go installed', + choice: 'it is the only Android virtual device this machine has', }; }, }), @@ -557,9 +557,11 @@ describe(runSmokePhasesAsync, () => { options({ bootstrap: true }) ); - expect(run.environment.deviceChoice).toBe('it has Expo Go installed'); + expect(run.environment.deviceChoice).toBe( + 'it is the only Android virtual device this machine has' + ); expect(run.phases.find((phase) => phase.id === 'boot-device')?.reason).toContain( - 'because it has Expo Go installed' + 'because it is the only Android virtual device this machine has, and shut it down again afterwards' ); }); diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts new file mode 100644 index 00000000..9fab723a --- /dev/null +++ b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts @@ -0,0 +1,100 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §iOS smoke +// The boot dependency smoke hands its phases: iOS binds through the registry and keeps the device; +// Android still boots and shuts down an emulator of its own. +import { bootDeviceAsync } from '../../device/bootDevice'; +import { acquireDeviceAsync } from '../../deviceBinding'; +import { resolveSmokeOptions } from '../resolveOptions'; +import { buildSmokeDeps } from '../smokeAsync'; + +vi.mock('../../log'); +vi.mock('../../device/bootDevice', () => ({ + bootDeviceAsync: vi.fn(), + shutdownDeviceAsync: vi.fn(), + BOOT_DEVICE_TIMEOUT_MS: { ios: 1, android: 1 }, +})); +vi.mock('../../deviceBinding', async (importOriginal) => ({ + ...(await importOriginal()), + acquireDeviceAsync: vi.fn(), +})); + +const projectRoot = '/project'; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe('bootDevice', () => { + // smoke-acquires-without-bootDevice + it(`binds this worktree's simulator on iOS and boots nothing itself`, async () => { + vi.mocked(acquireDeviceAsync).mockResolvedValue({ + device: { + backend: 'local-ios', + platform: 'ios', + udid: 'SIM-1', + name: 'agent-cli 0000', + origin: 'created', + }, + justBooted: true, + action: 'created', + }); + const register = vi.fn(); + + const result = await buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios'])).bootDevice( + register + ); + + expect(acquireDeviceAsync).toHaveBeenCalledWith(projectRoot, 'ios'); + expect(bootDeviceAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: true, + deviceId: 'SIM-1', + backend: 'local-ios', + choice: 'agent-cli 0000', + installNeeded: true, + reason: null, + }); + // smoke-registers-no-shutdown: the device stays bound after the run (llp/0035 §Device lifecycle releases it). + expect(register).not.toHaveBeenCalled(); + }); + + it(`reports the registry's refusal as a boot that failed`, async () => { + vi.mocked(acquireDeviceAsync).mockRejectedValue( + Object.assign(new Error('No iOS runtime with an iPhone is installed.\nHow: install one.'), { + code: 'DEVICE_UNAVAILABLE', + }) + ); + + const result = await buildSmokeDeps(projectRoot, resolveSmokeOptions(['--ios'])).bootDevice( + vi.fn() + ); + + expect(result).toMatchObject({ + ok: false, + deviceId: null, + reason: 'No iOS runtime with an iPhone is installed.', + }); + }); + + it(`still boots an emulator on Android, registering it for the shutdown`, async () => { + vi.mocked(bootDeviceAsync).mockImplementation(async (_platform, { onBooting }) => { + onBooting?.({ deviceId: 'emulator-5554', backend: 'local-android' }); + return { + ok: true, + deviceId: 'emulator-5554', + backend: 'local-android', + name: 'Pixel', + reason: null, + choice: 'the only AVD', + }; + }); + const register = vi.fn(); + + const result = await buildSmokeDeps(projectRoot, resolveSmokeOptions(['--android'])).bootDevice( + register + ); + + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + expect(register).toHaveBeenCalledWith({ deviceId: 'emulator-5554', backend: 'local-android' }); + expect(result).toMatchObject({ ok: true, deviceId: 'emulator-5554', backend: 'local-android' }); + }); +}); diff --git a/packages/@expo/agent-cli/src/smoke/phases.ts b/packages/@expo/agent-cli/src/smoke/phases.ts index 553b9564..d2d37ef0 100644 --- a/packages/@expo/agent-cli/src/smoke/phases.ts +++ b/packages/@expo/agent-cli/src/smoke/phases.ts @@ -86,15 +86,6 @@ export interface SmokeBootResult { backend: DeviceBackend | null; /** Why none came up. Null exactly when {@link ok} is true. */ reason: string | null; - /** - * Nothing was booted **on purpose**: no device on this machine could have opened the app. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app - * Reported apart from an ordinary boot failure because the two need different sentences and - * different next actions: one is a simulator that would not start, and this one is a machine - * that has nowhere to run this app yet. - */ - refused?: boolean; /** Why this device rather than another, for the report. Null when none was chosen. */ choice?: string | null; /** @@ -1148,6 +1139,11 @@ async function runPhasesAsync( * the dev server still lists is one the install has already killed. */ let appReplaced = false; + /** + * The boot said its device has no app: a simulator the registry created this run, whose disk + * holds nothing to read (llp/0035 §iOS smoke). + */ + let bootInstallNeeded = false; // @ref llp/0027-everything-on-eas.rfc.md §smoke // `--eas` named the device, and the device it named is not on this machine. The run still brings @@ -1216,6 +1212,8 @@ async function runPhasesAsync( const found = await deviceAsync(); if (found.deviceId == null) { const boot = await recordBootstrap('boot-device', async () => { + // @ref llp/0035-one-device-per-worktree.rfc.md §iOS smoke — an iOS simulator is this worktree's bound + // one and stays bound after the run; only an Android emulator this run boots is put back. const result = await deps.bootDevice((booted) => { // @ref ./phases §SmokeBootRegister — before the device is touched, so a boot that // hangs halfway still leaves this run holding the thing it has to put back. @@ -1231,7 +1229,7 @@ async function runPhasesAsync( reason: [ `booted ${result.deviceId} for this run`, result.choice ? ` because ${result.choice}` : '', - `, and shut it down again afterwards`, + result.backend === 'local-android' ? `, and shut it down again afterwards` : '', ].join(''), value: result, } @@ -1245,25 +1243,17 @@ async function runPhasesAsync( environment.device = 'booted'; environment.deviceChoice = boot.choice ?? null; probed = { deviceId: boot.deviceId, backend: boot.backend, reason: null }; + bootInstallNeeded = boot.installNeeded === true; } else { - // A boot that was **declined** left the machine exactly as it was, so the report must not - // say a device failed to come up: nothing was asked to (llp/0005 §The device that can - // open the app). - environment.device = boot.refused ? 'absent' : 'failed'; + environment.device = 'failed'; skipRest( 'app', - boot.refused - ? 'no device on this machine has the app, so there was nothing to open it on' - : 'no device could be booted, so there was nothing to open the app on and nothing to read' + 'no device could be booted, so there was nothing to open the app on and nothing to read' ); return done('failed', { ...base, bundle, - screenshot: noScreenshot( - boot.refused - ? 'no device has the app, so none was booted and nothing was photographed' - : 'no device came up, so nothing was photographed' - ), + screenshot: noScreenshot('no device came up, so nothing was photographed'), }); } } @@ -1288,7 +1278,7 @@ async function runPhasesAsync( const device = await deviceAsync(); if ( device.deviceId != null && - (await deps.installNeededOnDevice(device.deviceId, device.backend)) + (bootInstallNeeded || (await deps.installNeededOnDevice(device.deviceId, device.backend))) ) { // @ref llp/0005-runtime-loop-tools.rfc.md §The gate installs the app, whichever app it is // diff --git a/packages/@expo/agent-cli/src/smoke/smokeAsync.ts b/packages/@expo/agent-cli/src/smoke/smokeAsync.ts index f18e88b3..677d5768 100644 --- a/packages/@expo/agent-cli/src/smoke/smokeAsync.ts +++ b/packages/@expo/agent-cli/src/smoke/smokeAsync.ts @@ -15,6 +15,7 @@ import { resolveDevOptions } from '../dev/resolveOptions'; import { resolveDevStopOptions } from '../dev/resolveStopOptions'; import { devStopAsync, type DevStopResultJson } from '../dev/stopAsync'; import { bootDeviceAsync, shutdownDeviceAsync } from '../device/bootDevice'; +import { acquireDeviceAsync, deviceIdOf, deviceNameOf, devicesDisabled } from '../deviceBinding'; import { probeCloudSessionAsync } from '../device/cloudSimulator'; import { checkExpoGoVersionAsync } from '../device/expoGoVersion'; import { installDevBuildAsync } from '../device/installDevBuild'; @@ -264,7 +265,7 @@ function explainOutcome(run: SmokeRun): string { * "is the bundle broken" or "which URL does this route deep-link to" is a second place for the * findings behind them to be forgotten. */ -function buildSmokeDeps(projectRoot: string, options: SmokeOptions): SmokeDeps { +export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): SmokeDeps { // Built once and shared by every phase that reads a target, so no two phases can disagree about // which app this run is about (F51). Built lazily: a run that fails at the dev-server phase never // spawns a device tool for it. @@ -508,34 +509,52 @@ function buildSmokeDeps(projectRoot: string, options: SmokeOptions): SmokeDeps { return { ok: result.ok, target: sessionId, reason: result.reason }; }, - // @ref src/device/bootDevice.ts. Local only, and that is not a gap: `--eas` names a session - // somebody else started and pays for, and the phase that calls this never runs for one. + // Local only, and that is not a gap: `--eas` names a session somebody else started and pays + // for, and the phase that calls this never runs for one. // - // The app this run is about is handed over, so the boot picks a device that **has** it and - // declines rather than booting one that could not open it (llp/0005 §The device that can open - // the app). + // @ref llp/0035-one-device-per-worktree.rfc.md §iOS smoke — iOS is this worktree's bound simulator, reused or + // created by the registry and left bound; a created one has no app, so the install phase puts + // it there. Android keeps its own boot and shutdown until llp/0035 §Android instance. bootDevice: async (register) => { - const target = await targetAsync(); - const result = await bootDeviceAsync(options.platform, { - timeoutMs: BOOT_DEVICE_TIMEOUT_MS[options.platform], + if (options.platform === 'ios') { + if (devicesDisabled()) { + return { + ok: false, + deviceId: null, + backend: null, + choice: null, + reason: 'devices are off for this run (AGENT_CLI_NO_DEVICE), so no simulator was bound', + }; + } + try { + const acquired = await acquireDeviceAsync(projectRoot, 'ios'); + return { + ok: true, + deviceId: deviceIdOf(acquired.device), + backend: 'local-ios', + choice: deviceNameOf(acquired.device), + installNeeded: acquired.action === 'created', + reason: null, + }; + } catch (error: unknown) { + return { + ok: false, + deviceId: null, + backend: null, + choice: null, + reason: error instanceof Error ? firstLine(error.message) : String(error), + }; + } + } + const result = await bootDeviceAsync('android', { + timeoutMs: BOOT_DEVICE_TIMEOUT_MS.android, onBooting: register, - appId: target.appId, - appLabel: target.appLabel, - installWith: target.installWith, - // @ref llp/0005-runtime-loop-tools.rfc.md §The gate installs the app, whichever app it is - // Always, now. This used to be the plan's `expo-go` rule alone, on the reasoning that a - // development build is a compile and compiles belong to `dev`; the boundary is real and it - // was the caller's problem, so the boot may pick a bare device for either kind of app and - // the install phase puts the app there. - mayInstall: true, }); return { ok: result.ok, deviceId: result.deviceId, backend: result.backend, - refused: result.refused, choice: result.choice, - installNeeded: result.installNeeded, reason: result.ok ? null : `${result.reason}${result.name ? ` (${result.name})` : ''}`, }; }, @@ -643,8 +662,8 @@ function buildSmokeDeps(projectRoot: string, options: SmokeOptions): SmokeDeps { }; }, - shutdownDevice: async (deviceId, backend) => { - const result = await shutdownDeviceAsync(deviceId, backend); + shutdownDevice: async (deviceId) => { + const result = await shutdownDeviceAsync(deviceId); return { ok: result.ok, target: deviceId, reason: result.reason }; }, @@ -1123,15 +1142,17 @@ async function resolveSmokeTargetAsync( let plan; try { const state = await probeProjectStateAsync(projectRoot); - plan = await resolveStartPlanAsync(projectRoot, state, { - platform: options.platform, - lastBuild: readLastBuildRecord(projectRoot), - // @ref llp/0027-everything-on-eas.rfc.md §smoke — the same plan `dev --eas` makes, so the - // build this names is the simulator profile on EAS and the session can install it. - ...(options.cloud === 'required' - ? { requestedBackend: 'eas' as const, deviceBackend: 'eas' as const } - : {}), - }); + plan = ( + await resolveStartPlanAsync(projectRoot, state, { + platform: options.platform, + lastBuild: readLastBuildRecord(projectRoot), + // @ref llp/0027-everything-on-eas.rfc.md §smoke — the same plan `dev --eas` makes, so the + // build this names is the simulator profile on EAS and the session can install it. + ...(options.cloud === 'required' + ? { requestedBackend: 'eas' as const, deviceBackend: 'eas' as const } + : {}), + }) + ).plan; } catch { // The probe is a courtesy, not a gate: a project it could not read is one this knows nothing // about, and refusing or choosing a device on an unread plan would stop runs that would have diff --git a/packages/@expo/agent-cli/src/start/followUps.ts b/packages/@expo/agent-cli/src/start/followUps.ts index 13fbb549..53b50810 100644 --- a/packages/@expo/agent-cli/src/start/followUps.ts +++ b/packages/@expo/agent-cli/src/start/followUps.ts @@ -89,7 +89,7 @@ export async function resolveStartFollowUpsAsync( const { expoGo, web, eas = false } = hint; const port = 'port' in hint ? hint.port : resolveDevServerPort(options.expoArgs); // A web run has no device in it at all, so the probe is not worth even a bounded wait. - const localDevice = web ? 'unknown' : await probeLocalDeviceWithinBudgetAsync(); + const localDevice = web ? 'unknown' : await probeLocalDeviceWithinBudgetAsync(projectRoot); // @ref llp/0005-runtime-loop-tools.rfc.md §Pointing an app at this dev server // `exp://` is the Expo Go form only. A development build opens its own scheme, so a run that // targets one gets the dev launcher's URL rather than a link for an app it is not. @@ -144,14 +144,14 @@ function resolveLanConnectUrl( } /** The probe, or `unknown` when it takes longer than a dev-server banner may wait. */ -async function probeLocalDeviceWithinBudgetAsync(): Promise { +async function probeLocalDeviceWithinBudgetAsync(projectRoot: string): Promise { let timer: NodeJS.Timeout | undefined; const expired = new Promise<'unknown'>((resolve) => { timer = setTimeout(() => resolve('unknown'), DEVICE_PROBE_BUDGET_MS); timer.unref?.(); }); try { - const probe = await Promise.race([probeLocalDeviceAsync(), expired]); + const probe = await Promise.race([probeLocalDeviceAsync({ projectRoot }), expired]); return typeof probe === 'string' ? probe : probe.state; } catch { // The probe does not reject, and a ladder must not be able to fail a start even so. diff --git a/packages/@expo/agent-cli/src/status/statusAsync.ts b/packages/@expo/agent-cli/src/status/statusAsync.ts index bfe704ad..27b62c50 100644 --- a/packages/@expo/agent-cli/src/status/statusAsync.ts +++ b/packages/@expo/agent-cli/src/status/statusAsync.ts @@ -234,7 +234,7 @@ export async function collectStatusReportAsync( const [project, devServer, device, skills, auth] = await Promise.all([ attemptAsync(() => readProjectAsync(projectRoot, options)), attemptAsync(() => probeDevServerStatusAsync(projectRoot, options)), - attemptAsync(() => readLocalDeviceStatusAsync(options)), + attemptAsync(() => readLocalDeviceStatusAsync(projectRoot, options)), attemptAsync(() => readSkillsStatusAsync(projectRoot)), attemptAsync(() => readAuthPreflightAsync(projectRoot)), ]); @@ -661,10 +661,12 @@ async function attemptPlanAsync( const { resolveStartPlanAsync } = require('../plan/resolveAsync') as typeof import('../plan/resolveAsync'); try { - return await resolveStartPlanAsync(projectRoot, state, { - platform: options.platform ?? resolveDefaultPlatform(state), - lastBuild, - }); + return ( + await resolveStartPlanAsync(projectRoot, state, { + platform: options.platform ?? resolveDefaultPlatform(state), + lastBuild, + }) + ).plan; } catch { return null; } @@ -764,9 +766,12 @@ async function probeDevServerStatusAsync( * rule the probe itself follows, because a suggestion turned off on the strength of a slow * subprocess would be the mistake this exists to fix, backwards. */ -async function readLocalDeviceStatusAsync(options: StatusOptions): Promise { +async function readLocalDeviceStatusAsync( + projectRoot: string, + options: StatusOptions +): Promise { const timeoutMs = options.deviceProbeTimeoutMs ?? DEVICE_PROBE_TIMEOUT_MS; - const probe = await raceWithTimeoutAsync(probeLocalDeviceAsync(), timeoutMs); + const probe = await raceWithTimeoutAsync(probeLocalDeviceAsync({ projectRoot }), timeoutMs); return buildLocalDeviceStatus( probe ?? { state: 'unknown', diff --git a/packages/@expo/agent-cli/src/utils/errors.ts b/packages/@expo/agent-cli/src/utils/errors.ts index 191f7cfa..e4bbfe1d 100644 --- a/packages/@expo/agent-cli/src/utils/errors.ts +++ b/packages/@expo/agent-cli/src/utils/errors.ts @@ -162,6 +162,11 @@ export function formatNeedsHumanBlock(needsHuman: NeedsHuman): string[] { return lines; } +/** `Error code exit `, the row the detached child's parent reads back. */ +export function formatErrorCodeRow(code: string, exitCode: number): string { + return `${'Error code'.padEnd(NEEDS_HUMAN_LABEL_WIDTH)}${code} exit ${exitCode}`; +} + /** * The one JSON object a failing `--json` run prints on stdout. * @@ -282,6 +287,12 @@ export function logCmdError(error: any): never { } else if (suggestedCommand) { warn(`Try: ${renderForInvoker(suggestedCommand)}`); } + const exitCode = error instanceof CommandError ? (error.exitCode ?? EXIT_ERROR) : EXIT_ERROR; + // The detached child's log is the one channel to its parent, which relays a device refusal + // unchanged (`src/dev/childVerdict.ts`); the code and the exit are on no other line. + if (process.env.__EXPO_AGENT_CLI_DETACHED === '1') { + warn(formatErrorCodeRow(code, exitCode)); + } // @ref llp/0010-agent-conventions.rfc.md §The `--json` error envelope — a run asked for // machine-readable output gets one, whether it succeeded or not. The prose above stays on // stderr, where nothing is parsing; this is the only thing on stdout, so `JSON.parse` of a @@ -299,7 +310,6 @@ export function logCmdError(error: any): never { // LOG_EVENTS), so `exitWithCodeAsync` flushes first. It never settles, which keeps the // process alive until the exit fires and gives the `.catch(logCmdError)` callers nothing // further to run. An error that names no code is a tool error, so it exits 1 (llp/0010). - const exitCode = error instanceof CommandError ? (error.exitCode ?? EXIT_ERROR) : EXIT_ERROR; return exitWithCodeAsync(exitCode) as unknown as never; } From 4ee9d3d04e246ae3b561558e2e05fe2cc1b0d532 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 12:57:32 +0200 Subject: [PATCH 03/17] =?UTF-8?q?[agent-cli]=20e2e=20seeds=20the=20binding?= =?UTF-8?q?=20where=20a=20verb=20meets=20a=20booted=20simulator,=20two=20w?= =?UTF-8?q?orktrees=20get=20two=20simulators,=20and=20the=20docs=20name=20?= =?UTF-8?q?the=20bound=20device=20(llp/0031=20=C2=A7Tests,=20=C2=A7Docs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stub xcrun binds the booted simulator it lists; the smoke boot cases assert the registry file and bootstatus -b; a two-worktree dev case pins distinct E2E-CREATED-* udids on each run:ios --device. The first-booted sentences of llp/0004, 0005, 0022, 0025 and 0026 read the bound device. Co-Authored-By: Claude Fable 5.1 --- packages/@expo/agent-cli/CHANGELOG.md | 1 + .../@expo/agent-cli/e2e/__tests__/dev-test.ts | 64 +++ .../e2e/__tests__/installed-readers-test.ts | 11 +- .../e2e/__tests__/installedAppStubs.ts | 41 +- .../e2e/__tests__/runtime-preflight-test.ts | 2 + .../agent-cli/e2e/__tests__/smoke-test.ts | 380 ++++-------------- .../0004-smart-start-and-project-state.rfc.md | 4 +- .../llp/0005-runtime-loop-tools.rfc.md | 6 +- .../agent-cli/llp/0022-live-tier.plan.md | 2 +- .../llp/0025-dev-requires-platform.rfc.md | 2 +- .../llp/0026-dev-owns-the-open.rfc.md | 4 +- 11 files changed, 193 insertions(+), 324 deletions(-) diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index 9ed19643..30c6a2a6 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -12,6 +12,7 @@ ### 🎉 New features - `navigate` and `smoke` now suppress onboarding, the floating Tools button, and automatic dev-menu opening when launching a development build. Set `EXPO_NO_DEV_MENU=0` to opt out. +- `dev --ios` binds an iOS simulator of its own to the git worktree it runs in, and every verb of that worktree uses it. The simulator is one this CLI created, named `agent-cli `, or the one the worktree bound before; it is booted with `simctl bootstatus -b`, the build and install steps carry `--device `, and the open deep-links to it. `navigate`, `runtime:reload`, `runtime:stop` and `status --explain` read the binding, extend its 60-minute lease, and never pick the first booted simulator: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --ios --detach --wait-ready`. `dev --detach` on a running server boots the worktree's own simulator and refuses `not-reusable` when it has none; a device refusal in the detached child reaches the caller with its own code, exit and `Try:` line. `smoke --ios` gets its simulator from the binding too and leaves it bound. The bindings live under `/agent-cli/bindings`, written under one machine-wide lock. Android keeps the first `adb` device until its own binding lands. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak)) - `status --explain` reports what the app installed on a device was built from. It reads the fingerprint a debug build embeds (`expo-constants`, SDK 58+) out of the app on an Android device or a booted iOS simulator, compares it with the project's fingerprint for that platform, and says `up to date`, `rebuild required` or `unknown`, with the commands that fix a stale one. On an older SDK it says the app cannot be checked and reads no device. Right about a build somebody else made, which the freshness line cannot be. Android reads a few hundred KB of the APK through `adb` instead of pulling it; `--device ` narrows the read to one device. ([#59](https://github.com/expo/expo-agent-cli/pull/59) by [@vonovak](https://github.com/vonovak)) - `status --explain --device ` reads the app on a physical iPhone. `devicectl` exposes no app container, so the app is launched with a one-time URL and posts its embedded fingerprint back over the LAN; it needs a debug build whose `expo-dev-client` answers the check. Naming the phone is the consent, because the probe launches the app and `status` otherwise starts nothing. `--device-timeout ` bounds how long the phone gets to answer. A phone that does not answer in time reads as unknown. ([#90](https://github.com/expo/expo-agent-cli/pull/90) by [@vonovak](https://github.com/vonovak)) - `smoke --eas` brings its own EAS Simulator session the way `smoke` boots a simulator: its dev server starts through `dev --eas` (tunnelled; a build the plan needs is the simulator profile on EAS), the session in progress on the platform is reused, and otherwise one is started with Expo Go or with the finished EAS build of this fingerprint — and stopped again when the run ends. A development build with no finished build on EAS is a failed `start-session` phase naming `dev --eas`; the gate never compiles. `dev:stop --eas` also ends this project's session, by id, and reports it under `session`. ([#37](https://github.com/expo/expo-agent-cli/pull/37) by [@kudo](https://github.com/kudo)) diff --git a/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts b/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts index f274c51d..050645a3 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts @@ -12,6 +12,7 @@ import { installStubBinAsync, installStubFingerprintAsync, killAsync, + pathEnvVars, readDevLockAsync, readStubExpoInvocations, setupFixtureAsync, @@ -19,6 +20,7 @@ import { waitForAsync, waitForDevLockAsync, } from '../utils'; +import { installStubXcrunAsync } from './installedAppStubs'; /** The `--port` that `dev` puts on every step that serves. Its value is whatever this machine has free. */ const PORT_ARGS = ['--port', expect.stringMatching(/^\d+$/)]; @@ -1073,4 +1075,66 @@ describe('@expo/agent-cli dev', () => { } }); }); + + // @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests + // + // Two worktrees of one app, one stub `simctl` whose simulator state both runs share, and one + // registry: each `dev --ios` creates its own simulator, binds it, boots it with `bootstatus -b`, + // and pins its build to it. The `--device` on each `run:ios` is the id in that worktree's binding, + // and the two ids differ. + describe.skipIf(process.platform !== 'darwin')('two worktrees, two simulators', () => { + /** Every binding in the shared registry, by the root it belongs to. */ + function readBindings(home: string): Record { + const dir = path.join(home, 'agent-cli', 'bindings'); + const bindings: Record = {}; + for (const name of fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('-ios-local-ios.json'))) { + const binding = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); + bindings[binding.projectRoot] = { udid: binding.device.udid, name: binding.device.name }; + } + return bindings; + } + + it('binds a simulator of its own to each worktree and builds onto it', async () => { + const [first, second] = await Promise.all([ + setupAsync('dev-client-app'), + setupAsync('dev-client-app'), + ]); + // One `simctl`, installed once: both runs create and boot against its one simulator state. + const { binDir, calls: readXcrun } = await installStubXcrunAsync(first!); + const home = `${first}.expo-home`; + const env = { + ...pathEnvVars(`${binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`), + __UNSAFE_EXPO_HOME_DIRECTORY: home, + AGENT_CLI_NO_DEVICE: '0', + }; + + const results = await Promise.all( + [first!, second!].map((root) => + executeAgentCliAsync(root, ['dev', '--ios', '--local'], { env }) + ) + ); + + expect(results.map((result) => result.exitCode)).toEqual([0, 0]); + const bindings = readBindings(home); + const udids = [first!, second!].map((root) => bindings[fs.realpathSync.native(root)]!.udid); + expect(udids).toEqual(expect.arrayContaining(['E2E-CREATED-1', 'E2E-CREATED-2'])); + expect(new Set(udids).size).toBe(2); + for (const [index, root] of [first!, second!].entries()) { + expect(invocationArgs(root)).toEqual([ + ['prebuild', '--platform', 'ios'], + ['run:ios', ...PORT_ARGS, '--device', udids[index]], + ]); + } + const booted = readXcrun() + .filter((argv) => argv[1] === 'bootstatus') + .map((argv) => argv[2]); + expect(new Set(booted)).toEqual(new Set(udids)); + expect(readXcrun().every((argv) => argv[1] !== 'bootstatus' || argv.includes('-b'))).toBe( + true + ); + expect(readXcrun().some((argv) => argv[1] === 'boot')).toBe(false); + }); + }); }); diff --git a/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts b/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts index 59bdd1e2..1b02cd6a 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts @@ -155,9 +155,12 @@ describe('the phone probe over a stub devicectl', () => { async function stubPhoneAsync(phone: { fingerprint: string | null; postDelayMs?: number }) { const xcrun = await installStubXcrunAsync(root, { phone }); - restore = withEnv( - pathEnvVars(`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`) - ); + restore = withEnv({ + ...pathEnvVars( + `${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}` + ), + __UNSAFE_EXPO_HOME_DIRECTORY: `${root}.expo-home`, + }); return xcrun; } @@ -229,6 +232,7 @@ describe('the phone probe over a stub devicectl', () => { it('names a connected phone in a hint and leaves it alone until --device names it', async () => { const xcrun = await stubPhoneAsync({ fingerprint: EMBEDDED_HASH }); const unnamed = await readInstalledFingerprintIosAsync({ + projectRoot: root, appId: APP_ID, expectedHash: EMBEDDED_HASH, scheme: 'installedapp', @@ -241,6 +245,7 @@ describe('the phone probe over a stub devicectl', () => { expect(xcrun.calls().some((args) => args[3] === 'launch')).toBe(false); const named = await readInstalledFingerprintIosAsync({ + projectRoot: root, appId: APP_ID, expectedHash: EMBEDDED_HASH, device: PHONE_NAME, diff --git a/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts b/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts index ff4906b4..39b20791 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts @@ -7,6 +7,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { digestOf } from '../../src/devLock/address'; +import { canonicalizeExistingPath } from '../../src/utils/dir'; import { installStubBinAsync } from '../utils'; export const APK_FIXTURE = path.resolve(__dirname, '../../src/__fixtures__/zip/fixture-stored.zip'); @@ -38,6 +40,35 @@ export const PHONE_UDID = '00001110-001111110110101A'; export type StubCalls = () => string[][]; +/** + * Bind `udid` to the fixture worktree in the registry the harness's Expo home holds + * (`.expo-home`, @ref ../utils §spawnAgentCli), as `dev` would have: `created`, the + * lease 60 min ahead, the canonical root. Every verb of the fixture then drives this simulator + * (llp/0035 §Readers), so a case that hands a verb a booted simulator seeds this first. + */ +export async function bindingFixture(projectRoot: string, udid: string, name = SIMULATOR_NAME) { + const root = canonicalizeExistingPath(projectRoot); + const now = Date.now(); + const file = path.join( + `${projectRoot}.expo-home`, + 'agent-cli', + 'bindings', + `${digestOf(root)}-ios-local-ios.json` + ); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile( + file, + JSON.stringify({ + version: 1, + device: { backend: 'local-ios', platform: 'ios', udid, name, origin: 'created' }, + projectRoot: root, + boundAt: new Date(now).toISOString(), + expiresAt: new Date(now + 60 * 60_000).toISOString(), + }) + ); + return file; +} + function readCalls(recordPath: string): string[][] { return fs.existsSync(recordPath) ? fs @@ -126,7 +157,8 @@ const STUB_RUNTIME = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0'; * * `simctl list devices` answers with one booted simulator when `booted` is given, whose app * container holds `booted.fingerprint` at the static-linking path; otherwise with `simulators` - * (each `Shutdown` unless it says otherwise); otherwise none. The listing is state the stub keeps: + * (each `Shutdown` unless it says otherwise); otherwise none. A booted simulator is also bound to + * the fixture through {@link bindingFixture}. The listing is state the stub keeps: * `create` adds a `Shutdown` simulator whose udid is `E2E-CREATED-`, `delete` removes one, * `boot`, `bootstatus -b` and `shutdown` change its state, as a real `simctl` does. `list runtimes -j` names one * iOS runtime. @@ -177,6 +209,13 @@ export async function installStubXcrunAsync( ) ); await fs.promises.rm(counterPath, { force: true }); + // A booted simulator the stub lists is one `dev` bound to this fixture, or no verb would drive it. + const bound = booted + ? { udid: SIMULATOR_UDID, name: SIMULATOR_NAME } + : simulators?.find((simulator) => simulator.state === 'Booted'); + if (bound) { + await bindingFixture(root, bound.udid, bound.name); + } const binDir = path.join(root, '.stub-bin'); const scriptPath = path.join(binDir, 'xcrun-stub.js'); await fs.promises.mkdir(binDir, { recursive: true }); diff --git a/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts b/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts index bbf50d88..f97834b1 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/runtime-preflight-test.ts @@ -22,6 +22,7 @@ import { stubExpoEnv, type StubDevServer, } from '../utils'; +import { bindingFixture } from './installedAppStubs'; /** A port nothing listens on, for the "no dev server" state. */ const DEAD_URL = 'http://127.0.0.1:1'; @@ -290,6 +291,7 @@ describe('a dev server whose app is re-registering', () => { * device method *starts* the app, so its runtime registers because a device tool ran. */ async function installStubXcrunAsync(root: string, startedMarker: string | null): Promise { + await bindingFixture(root, 'PREFLIGHT-SIM', 'iPhone 17 Pro'); const scriptPath = path.join(root, '.stub-bin', 'xcrun-preflight.js'); await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true }); await fs.promises.writeFile( diff --git a/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts b/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts index a2b290bd..568e0b50 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts @@ -28,6 +28,11 @@ import { stubExpoEnv, type StubDevServer, } from '../utils'; +import { + appStartedMarkerPath, + bindingFixture, + installStubXcrunAsync as installStatefulXcrunAsync, +} from './installedAppStubs'; const SIMULATOR_UDID = 'E2E-SIM-SMOKE'; @@ -78,6 +83,8 @@ async function installStubXcrunAsync( */ opensMarker: string | null = null ): Promise<() => string[][]> { + // The booted simulator the stub lists is the one this worktree bound (llp/0035 §Readers). + await bindingFixture(projectRoot, SIMULATOR_UDID, 'iPhone 17 Pro'); const logPath = path.join(projectRoot, '.stub-xcrun.jsonl'); const scriptPath = path.join(projectRoot, '.stub-bin', 'xcrun-stub.js'); await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true }); @@ -222,114 +229,6 @@ async function installStubAdbForExpoGoAsync( }; } -/** - * A stub `xcrun` for the boot path: several simulators, all shut, and every call recorded. - * - * The list is what `pickSimulator` chooses from, so the fixture is the machine the run met — a - * fresh device that was used most recently, and an older one that actually has the app on it. - */ -async function installStubXcrunForBootAsync( - projectRoot: string, - devices: { udid: string; name: string; lastBootedAt?: string }[], - /** - * A file the stub writes when it opens a URL, so the dev server can start listing a target. - * - * Without it these cases would wait out the whole cold attach budget for an app that was never - * going to arrive — two minutes to prove something about which device was booted. - */ - opensMarker: string | null = null -): Promise<() => string[][]> { - const logPath = path.join(projectRoot, '.stub-boot-xcrun.jsonl'); - const bootedPath = path.join(projectRoot, '.stub-booted-udid'); - const scriptPath = path.join(projectRoot, '.stub-bin', 'xcrun-boot-stub.js'); - const listing = { - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-26-5': devices.map((device) => ({ - udid: device.udid, - name: device.name, - state: 'Shutdown', - isAvailable: true, - ...(device.lastBootedAt ? { lastBootedAt: device.lastBootedAt } : {}), - })), - }, - }; - await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.promises.writeFile( - scriptPath, - [ - `const fs = require('fs');`, - `const args = process.argv.slice(2);`, - `fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(args) + '\\n');`, - // A boot is remembered, the way a real `simctl` does: the device probe that runs *after* - // the boot has to find the device this run just started, or every phase after it looks at a - // machine with no simulator on it. - `const bootedPath = ${JSON.stringify(bootedPath)};`, - `const booted = () => { try { return fs.readFileSync(bootedPath, 'utf8').trim(); } catch { return ''; } };`, - `if (args[1] === 'boot') { fs.writeFileSync(bootedPath, args[2]); process.exit(0); }`, - ...(opensMarker - ? [ - `if (args[1] === 'openurl') {`, - ` fs.writeFileSync(${JSON.stringify(opensMarker)}, 'opened');`, - `}`, - ] - : []), - `const listing = ${JSON.stringify(JSON.stringify(listing))};`, - // `list devices booted` finds nothing until a boot has happened, which is what makes the run - // reach the boot decision at all. - `if (args[1] === 'list' && args.includes('booted')) {`, - ` const up = booted();`, - ` const all = JSON.parse(listing).devices['com.apple.CoreSimulator.SimRuntime.iOS-26-5'];`, - ` const mine = all.filter((d) => d.udid === up).map((d) => ({ ...d, state: 'Booted' }));`, - ` process.stdout.write(JSON.stringify(mine.length ? { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-5': mine } } : { devices: {} }));`, - ` process.exit(0);`, - `}`, - `if (args[1] === 'list') {`, - ` process.stdout.write(listing);`, - ` process.exit(0);`, - `}`, - `process.exit(0);`, - ].join('\n') - ); - await installStubBinAsync(path.join(projectRoot, '.stub-bin'), 'xcrun', scriptPath); - - return () => - fs.existsSync(logPath) - ? fs - .readFileSync(logPath, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - : []; -} - -/** - * `plutil` ships with macOS. Linux CI has none, so a fixture that plants real Info.plist files - * still cannot be read unless a stub answers the same extract. - */ -async function installStubPlutilAsync(projectRoot: string): Promise { - const scriptPath = path.join(projectRoot, '.stub-bin', 'plutil-stub.js'); - await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.promises.writeFile( - scriptPath, - [ - `const fs = require('fs');`, - // The key that was asked for, not always CFBundleIdentifier. Two callers read these plists - // now — the device choice wants the bundle id and the Expo Go version check wants - // CFBundleShortVersionString — and a stub that answered the same key whichever was asked - // would hand a bundle id back as a version string. - `const key = process.argv[process.argv.indexOf('-extract') + 1];`, - `const plist = process.argv[process.argv.length - 1];`, - `const xml = fs.readFileSync(plist, 'utf8');`, - `const match = xml.match(new RegExp('' + key + '<\\\\/key>\\\\s*([^<]*)<\\\\/string>'));`, - // Real `plutil` exits non-zero for a key the plist has not got, which is the case the - // version check reads as "nothing installed to compare". - `if (!match) process.exit(1);`, - `process.stdout.write(match[1] + '\\n');`, - ].join('\n') - ); - await installStubBinAsync(path.join(projectRoot, '.stub-bin'), 'plutil', scriptPath); -} - /** Give the fixture an Expo Router `app/` directory with the named route files. */ async function writeRoutesAsync(projectRoot: string, files: string[]): Promise { for (const file of files) { @@ -385,56 +284,6 @@ async function installStubNpxAsync(projectRoot: string): Promise<() => string[][ : []; } -/** - * A `HOME` whose CoreSimulator tree says which apps each simulator has. - * - * @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app - * The device choice is made by **reading the simulator's disk**, because `simctl listapps` and - * `simctl get_app_container` both refuse on a device that is not booted — and every device the - * choice is about is shut. So the fixture is a directory tree rather than a stub command, and the - * `Info.plist` files in it are real plists that the real `plutil` reads. - * - * @param installed the application ids to install, per simulator udid. - */ -async function writeSimulatorHomeAsync( - projectRoot: string, - installed: Record -): Promise { - await installStubPlutilAsync(projectRoot); - const home = path.join(projectRoot, '.sim-home'); - for (const [udid, appIds] of Object.entries(installed)) { - // The device's own directory, whether or not it has apps in it. A real simulator that exists - // has one, and a caller that acts on "no Expo Go here" needs to tell an empty device from a - // machine that has no simulators at all (@ref src/device/installedApps §simulatorDiskExistsAsync). - await fs.promises.mkdir( - path.join(home, 'Library/Developer/CoreSimulator/Devices', udid, 'data'), - { recursive: true } - ); - for (const [index, appId] of appIds.entries()) { - const bundle = path.join( - home, - 'Library/Developer/CoreSimulator/Devices', - udid, - 'data/Containers/Bundle/Application', - `container-${index}`, - `${appId}.app` - ); - await fs.promises.mkdir(bundle, { recursive: true }); - await fs.promises.writeFile( - path.join(bundle, 'Info.plist'), - [ - ``, - ``, - ``, - `CFBundleIdentifier${appId}`, - ``, - ].join('\n') - ); - } - } - return home; -} - /** * The stub `expo` and **no device tools at all**, for the cases that are about the dev server. * @@ -886,24 +735,15 @@ describe('@expo/agent-cli smoke', () => { }); }); - // @ref llp/0005-runtime-loop-tools.rfc.md §The device that can open the app. + // @ref llp/0035-one-device-per-worktree.rfc.md §iOS smoke // - // A live run: a dev-client project booted a fresh simulator and the deep link came back `115` — - // no handler for the scheme — after a 12.4 s boot for a device that could never have opened it. - // The choice is made by reading each simulator's disk, so the fixture here is a `HOME` with a - // CoreSimulator tree in it and a stub `xcrun` that lists the devices, all shut. - describe('choosing the device to boot', () => { - const FRESH = 'E2E-FRESH-0000'; - const HAS_APP = 'E2E-HASAPP-000'; + // The device a bootstrapping run boots is this worktree's own simulator, taken from the registry + // or created there, and it stays bound after the run. The stub `xcrun` keeps simulator state the + // way `simctl` does, so what is asserted is the registry file and the calls that put the device up. + describe.skipIf(process.platform !== 'darwin')('binding the device to boot', () => { const DEV_CLIENT_ID = 'com.example.dcapp'; - /** - * A dev-client project whose `app.json` names a bundle identifier. - * - * The `fresh` fixture is the one whose build record matches its fingerprint, so the plan needs - * no build and the run reaches the device choice — which is the case under test. The bundle id - * is written here rather than into the shared fixture, because it is this test's question. - */ + /** A dev-client project whose `app.json` names a bundle identifier. */ async function devClientFixtureAsync(): Promise { const projectRoot = await setupFixtureAsync('dev-client-fresh-app'); const configPath = path.join(projectRoot, 'app.json'); @@ -913,25 +753,26 @@ describe('@expo/agent-cli smoke', () => { return projectRoot; } + /** The registry file of the fixture, as `dev` or `smoke` wrote it. */ + function readBinding(projectRoot: string): Record | null { + const dir = path.join(`${projectRoot}.expo-home`, 'agent-cli', 'bindings'); + const file = fs.existsSync(dir) + ? fs.readdirSync(dir).find((name) => name.endsWith('-ios-local-ios.json')) + : null; + return file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) : null; + } + async function runAsync( projectRoot: string, - home: string, - stub: StubDevServer + stub: StubDevServer, + args: string[] = [] ): Promise> { const release = await holdLockForAsync(projectRoot, stub); try { const result = await executeAgentCliAsync( projectRoot, - ['smoke', '--ios', '--json', '--no-screenshot', '--timeout', '4s'], - { - env: { - ...stubExpoEnv(projectRoot), - HOME: home, - // `os.homedir()` on Windows reads USERPROFILE, not HOME. - ...(process.platform === 'win32' ? { USERPROFILE: home } : {}), - }, - reject: false, - } + ['smoke', '--ios', '--json', '--no-screenshot', '--timeout', '4s', ...args], + { env: { ...stubExpoEnv(projectRoot), AGENT_CLI_NO_DEVICE: '0' }, reject: false } ); return JSON.parse(result.stdout); } finally { @@ -939,60 +780,10 @@ describe('@expo/agent-cli smoke', () => { } } - it('boots the device that has the app, not the one used most recently', async () => { - const projectRoot = await setupFixtureAsync('go-app'); - const opened = path.join(projectRoot, '.app-opened'); - const readXcrun = await installStubXcrunForBootAsync( - projectRoot, - [ - { udid: FRESH, name: 'iPhone 17 Pro Max', lastBootedAt: '2026-08-30T09:00:00Z' }, - { udid: HAS_APP, name: 'iPhone 17 Pro', lastBootedAt: '2026-08-20T09:00:00Z' }, - ], - opened - ); - // Only the older one has Expo Go, which is what a `go-app` opens. - const home = await writeSimulatorHomeAsync(projectRoot, { - [FRESH]: [], - [HAS_APP]: ['host.exp.Exponent'], - }); - const stub = await startStubDevServerAsync({ - projectRoot, - targets: [EXPO_GO_TARGET], - targetsAppearWithFile: opened, - }); - - try { - const report = await runAsync(projectRoot, home, stub); - - const booted = readXcrun().filter((argv) => argv[1] === 'boot'); - expect(booted).toEqual([['simctl', 'boot', HAS_APP]]); - expect(report.environment.deviceChoice).toContain('Expo Go'); - } finally { - await stub.close(); - } - }); - - // The refusal, and the assertion that matters most: **no boot was issued at all**. The minute - // is the thing being saved, so a run that decided correctly and booted anyway would still be - // the bug. - // @ref llp/0005-runtime-loop-tools.rfc.md §The gate installs the app, whichever app it is - // - // **This case used to assert the opposite**, and the assertion was the policy: a device without - // this project's development build was refused before the boot, and the reason named - // `dev --ios`. That is a correct instruction and a dead end for an agent, which cannot - // take it without leaving the loop this command exists to serve - // [Kudo, 2026-09-04: "smoke should be self-served without running dev first"]. So the boot goes - // ahead for either kind of app, and the install phase puts the app there. - it('boots and installs when no device has the development build', async () => { + it('creates and binds a simulator, boots it with bootstatus -b, and installs the development build onto it', async () => { const projectRoot = await devClientFixtureAsync(); - const opened = path.join(projectRoot, '.app-opened'); - const readXcrun = await installStubXcrunForBootAsync( - projectRoot, - [{ udid: FRESH, name: 'iPhone 17 Pro', lastBootedAt: '2026-08-30T09:00:00Z' }], - opened - ); - // A simulator with Expo Go on it and *not* this project's development build. - const home = await writeSimulatorHomeAsync(projectRoot, { [FRESH]: ['host.exp.Exponent'] }); + const opened = appStartedMarkerPath(projectRoot); + const { calls: readXcrun } = await installStatefulXcrunAsync(projectRoot); const readNpx = await installStubNpxAsync(projectRoot); const stub = await startStubDevServerAsync({ projectRoot, @@ -1001,48 +792,44 @@ describe('@expo/agent-cli smoke', () => { }); try { - const report = await runAsync(projectRoot, home, stub); + const report = await runAsync(projectRoot, stub); + + const binding = readBinding(projectRoot); + expect(binding).toMatchObject({ + version: 1, + device: { backend: 'local-ios', udid: 'E2E-CREATED-1', origin: 'created' }, + }); + expect(binding!.device.name).toMatch(/^agent-cli [0-9a-f]{8}$/); + expect(readXcrun()).toContainEqual(['simctl', 'bootstatus', 'E2E-CREATED-1', '-b']); + expect(readXcrun().some((argv) => argv[1] === 'boot')).toBe(false); + expect(readXcrun().some((argv) => argv[1] === 'shutdown')).toBe(false); - // It booted rather than declining, and the reason says what the boot was for. - expect(readXcrun().filter((argv) => argv[1] === 'boot')).toEqual([ - ['simctl', 'boot', FRESH], - ]); const boot = report.phases.find((phase: any) => phase.id === 'boot-device'); expect(boot).toMatchObject({ status: 'ok' }); + expect(boot.reason).not.toContain('shut it down'); + expect(report.environment.cleanup.map((entry: any) => entry.resource)).not.toContain( + 'device' + ); - // And the install is a **native build** rather than a download, because a development build - // is this project's own artefact (@ref src/device/installDevBuild.ts). + // A created simulator has no app, so the install is a native build pinned to it. const install = report.phases.find((phase: any) => phase.id === 'install-app'); expect(install).toMatchObject({ status: 'ok' }); const run = readNpx().find((argv) => argv.includes('run:ios')); - expect(run).toEqual(['expo', 'run:ios', '--no-bundler', '--device', FRESH]); - // Never a second dev server: this run already has one. + expect(run).toEqual(['expo', 'run:ios', '--no-bundler', '--device', 'E2E-CREATED-1']); expect(readNpx().some((argv) => argv.includes('start'))).toBe(false); } finally { await stub.close(); } }); - // @ref llp/0005-runtime-loop-tools.rfc.md §Putting Expo Go on a simulator that has not got it - // - // An Expo Go project on a machine with no Expo Go used to be a refusal here, on the same - // footing as a missing development build: `simctl openurl exp://…` answers `115` for both, and - // the open path installs nothing [observed — live, 2026-08-30]. It is no longer, and the - // difference is what can be *done* about it — Expo Go is a published binary to download, so the - // boot goes ahead and the app is installed onto it. - it('boots and installs for an Expo Go project on a machine with no Expo Go', async () => { + it('reuses the parked simulator the worktree already has', async () => { const projectRoot = await setupFixtureAsync('go-app'); - // The marker is what keeps this test off the cold attach budget: the stub `xcrun` writes it - // when it opens the URL, and the stub dev server starts listing a target once it exists. A - // run without it waits out the whole two minutes to prove something about the install. - const opened = path.join(projectRoot, '.app-opened'); - const readXcrun = await installStubXcrunForBootAsync( - projectRoot, - [{ udid: FRESH, name: 'iPhone 17 Pro', lastBootedAt: '2026-08-30T09:00:00Z' }], - opened - ); - const readNpx = await installStubNpxAsync(projectRoot); - const home = await writeSimulatorHomeAsync(projectRoot, { [FRESH]: [] }); + const opened = appStartedMarkerPath(projectRoot); + const { calls: readXcrun } = await installStatefulXcrunAsync(projectRoot, { + simulators: [{ udid: 'E2E-PARKED', name: 'agent-cli deadbeef', state: 'Shutdown' }], + }); + await bindingFixture(projectRoot, 'E2E-PARKED', 'agent-cli deadbeef'); + await installStubNpxAsync(projectRoot); const stub = await startStubDevServerAsync({ projectRoot, targets: [EXPO_GO_TARGET], @@ -1050,66 +837,37 @@ describe('@expo/agent-cli smoke', () => { }); try { - const report = await runAsync(projectRoot, home, stub); - - // It booted, rather than declining because no device had the app. - expect(readXcrun().filter((argv) => argv[1] === 'boot')).toEqual([ - ['simctl', 'boot', FRESH], - ]); - const boot = report.phases.find((phase: any) => phase.id === 'boot-device'); - expect(boot).toMatchObject({ status: 'ok' }); - expect(boot.reason).toContain('to install it onto'); - - // And it installed: the release was asked for, and `simctl install` was handed a path. - expect(readNpx().some((argv) => argv.includes('download'))).toBe(true); - expect(readXcrun().some((argv) => argv[1] === 'install')).toBe(true); - const install = report.phases.find((phase: any) => phase.id === 'install-app'); - expect(install).toMatchObject({ status: 'ok' }); + const report = await runAsync(projectRoot, stub); - // Then the link works without a tap, which is the half an install alone does not buy. - const approvals = readXcrun().filter((argv) => - argv.some((arg) => arg.includes('schemeapproval')) - ); - expect(approvals.length).toBeGreaterThan(0); + expect(readXcrun().some((argv) => argv[1] === 'create')).toBe(false); + expect(readXcrun()).toContainEqual(['simctl', 'bootstatus', 'E2E-PARKED', '-b']); + expect(readBinding(projectRoot)).toMatchObject({ device: { udid: 'E2E-PARKED' } }); + expect(report.phases.find((phase: any) => phase.id === 'boot-device')).toMatchObject({ + status: 'ok', + }); } finally { await stub.close(); } }); - // The boundary that remains: `--no-start` says change nothing, so a device without the app is - // reported rather than fixed — and the report names the run that would fix it. - it('reports the missing app rather than installing under --no-start', async () => { + // The boundary that remains: `--no-start` says change nothing, so nothing is bound and nothing + // is built, and the report names the run that would. + it('binds nothing and installs nothing under --no-start', async () => { const projectRoot = await devClientFixtureAsync(); - const readXcrun = await installStubXcrunForBootAsync(projectRoot, [ - { udid: FRESH, name: 'iPhone 17 Pro', lastBootedAt: '2026-08-30T09:00:00Z' }, - ]); + const { calls: readXcrun } = await installStatefulXcrunAsync(projectRoot); const readNpx = await installStubNpxAsync(projectRoot); - const home = await writeSimulatorHomeAsync(projectRoot, { [FRESH]: ['host.exp.Exponent'] }); const stub = await startStubDevServerAsync({ projectRoot, targets: [] }); - const release = await holdLockForAsync(projectRoot, stub); try { - const result = await executeAgentCliAsync( - projectRoot, - ['smoke', '--ios', '--json', '--no-screenshot', '--no-start', '--timeout', '4s'], - { - env: { - ...stubExpoEnv(projectRoot), - HOME: home, - ...(process.platform === 'win32' ? { USERPROFILE: home } : {}), - PATH: `${path.join(projectRoot, '.stub-bin')}${path.delimiter}${process.env.PATH}`, - }, - reject: false, - } - ); - const report = JSON.parse(result.stdout); + const report = await runAsync(projectRoot, stub, ['--no-start']); - // Nothing was booted and nothing was built: the flag forbade both. - expect(readXcrun().filter((argv) => argv[1] === 'boot')).toEqual([]); + expect(readBinding(projectRoot)).toBeNull(); + expect(readXcrun().some((argv) => argv[1] === 'create' || argv[1] === 'bootstatus')).toBe( + false + ); expect(readNpx().some((argv) => argv.includes('run:ios'))).toBe(false); expect(report.phases.some((phase: any) => phase.id === 'install-app')).toBe(false); } finally { - await release(); await stub.close(); } }); diff --git a/packages/@expo/agent-cli/llp/0004-smart-start-and-project-state.rfc.md b/packages/@expo/agent-cli/llp/0004-smart-start-and-project-state.rfc.md index 5242a87d..240709f8 100644 --- a/packages/@expo/agent-cli/llp/0004-smart-start-and-project-state.rfc.md +++ b/packages/@expo/agent-cli/llp/0004-smart-start-and-project-state.rfc.md @@ -246,7 +246,7 @@ Two rules keep the plan honest about itself (`src/plan/decide.ts`): argv. `@expo/agent-cli dev --ios` has always forwarded `--ios` to `expo start`. The plan used to print `expo start --go` regardless. - The reason distinguishes the two forms. With a flag, `expo start --go --ios` really - does open the app. It uses a booted simulator or boots one, installs Expo Go if it is + does open the app. It uses the simulator bound to this worktree, booting it when it is down, installs Expo Go if it is missing, and sends the `exp://` URL. Without a flag, the reason says so and names `@expo/agent-cli navigate /`. @@ -324,7 +324,7 @@ Sections: nothing was discovered. The section stays in `--json` and in the `cli:status` event. It returns to the text the moment either half has something to say, including when the section could not be read at all. -- Device. A booted simulator or an attached device, reported as `present`, `absent`, or +- Device. The bound simulator when it is up, or an attached device, reported as `present`, `absent`, or `unknown`. `unknown` is never rounded down to "none". - Next. The smart-start rule that would fire, as one line. The exception is a dev server this project can use already answering: then the line is `@expo/agent-cli smoke`. A diff --git a/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md b/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md index b618e007..473545fe 100644 --- a/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md +++ b/packages/@expo/agent-cli/llp/0005-runtime-loop-tools.rfc.md @@ -601,7 +601,7 @@ The bootstrap is not charged to `--timeout`. A cold simulator takes about a minu ### The device that can open the app -Which device gets booted: the one that has the app installed. `src/device/installedApps.ts` reads the simulator filesystem (`Devices//data/Containers/Bundle/Application//.app`, `plutil` for `CFBundleIdentifier`). `simctl get_app_container` and `simctl listapps` refuse on a shut device. `lastBootedAt` breaks the tie among the devices that have it. When no device has it, nothing is booted. `BootDeviceResult.refused` carries that difference. Expo Go is not exempt: `simctl openurl` does not install Expo Go. `expo start --ios` is what does, and `smoke` opens through `simctl openurl` to avoid the AppleScript grant. Android makes no such choice: there is normally one AVD, and the package list needs `adb` on a running emulator. `--cloud` boots nothing. A run whose dev server already has an app attached boots nothing. +Which device gets booted: on iOS, the simulator bound to this worktree, reused or created by the device registry ([[0035-one-device-per-worktree]]); a created one has no app, so the install phase puts it there. Android makes no such choice: there is normally one AVD, and the package list needs `adb` on a running emulator. `--eas` boots nothing. A run whose dev server already has an app attached boots nothing. An attached app is not yet a readable app. Expo Go registers a target, downloads the bundle, and the target goes away and comes back when the JS runtime is created. A single read landing in that gap answers `No target found` about an app that is running. The `runtime` phase asks `Runtime.evaluate('1')` until it answers. A runtime with no debugger is asked exactly once. Polling it would turn the refusal into a hang. A wait that ends with no answer reports the runtime's own reason. @@ -750,7 +750,7 @@ a reason to pull. **iOS simulator.** `xcrun simctl get_app_container ` names the app bundle, and the file is read off disk at one of two paths: `EXConstants.bundle/app.fingerprint` for static linking, `Frameworks/EXConstants.framework/EXConstants.bundle/app.fingerprint` for `use_frameworks!`. Only -booted simulators are read; `simctl` cannot look inside a shut-down one. +the bound simulator is read, and only while it is up; `simctl` cannot look inside a shut-down one. **iOS device.** `devicectl` exposes no app container, so the app has to answer for itself. The reader starts a one-shot HTTP server on the LAN, launches the app through `devicectl device process @@ -764,7 +764,7 @@ constant time, the body is capped at 4 KB, and the server closes as soon as it h response clock starts once the trigger was delivered, so a slow launch does not eat the phone's time; a timeout is `no-response`, never `up-to-date`. -**Which iOS device.** Booted simulators first. A phone is probed only when `--device` names it: +**Which iOS device.** The simulator bound to this worktree ([[0035-one-device-per-worktree]] §Readers); `--device` lists the booted simulators to filter instead. A phone is probed only when `--device` names it: the probe launches the app, and naming the phone is the consent. A phone that is connected but not named is mentioned in a hint. A phone that is unreachable, or has Developer Mode off, is named with what to do about it and not probed. diff --git a/packages/@expo/agent-cli/llp/0022-live-tier.plan.md b/packages/@expo/agent-cli/llp/0022-live-tier.plan.md index 8da96b57..d91f4ca2 100644 --- a/packages/@expo/agent-cli/llp/0022-live-tier.plan.md +++ b/packages/@expo/agent-cli/llp/0022-live-tier.plan.md @@ -218,7 +218,7 @@ AppleScript and macOS refuses that without an Automation grant. The plan is The iOS twin of `live-devclient`, same shape: it does not build, it names an already-built project (`AGENT_CLI_LIVE_IOS_DEVCLIENT_PROJECT`), the gate checks the app -is installed on the booted simulator and its build is recorded, and it drives the project +is installed on the simulator `dev` bound to the worktree and its build is recorded, and it drives the project in place. The lab screen is served over the wire, so the interact commands have testIDs without a rebuild. It is the suite that turns the iOS runtime column from `by hand` into `filled`. diff --git a/packages/@expo/agent-cli/llp/0025-dev-requires-platform.rfc.md b/packages/@expo/agent-cli/llp/0025-dev-requires-platform.rfc.md index 070c368a..56d0a2ec 100644 --- a/packages/@expo/agent-cli/llp/0025-dev-requires-platform.rfc.md +++ b/packages/@expo/agent-cli/llp/0025-dev-requires-platform.rfc.md @@ -14,7 +14,7 @@ Two changes get it there. 1. **The platform flag is required**: `--ios`, `--android`, or `--web`. Same rule, same one-line refusal, and same reasoning as `smoke`'s ([[0005-runtime-loop-tools]] §Which platform is the caller's to say). The old host-based default had a worse cost here than in `smoke`: the guessed platform never reached `expo start`, so a bare `dev` served a bundle and put nothing on any device. -2. **The plan always acts on the platform.** A fresh plan runs `expo start --`, which boots a simulator or an emulator when none is up, installs Expo Go when it has to, and opens the app — the Expo CLI's own openers do all of this (`AppleDeviceManager`, `AndroidDeviceManager.startAsync` [observed — `@expo/cli` source, 2026-09-04]). A stale plan prebuilds and builds through `expo run:`, which does the same and installs the build. So `dev --ios` ends with the app running, whichever route the plan took, and no separate boot step was needed. +2. **The plan always acts on the platform.** A fresh plan runs `expo start`, and `dev` opens the app itself on the simulator bound to the worktree or on an emulator, installing Expo Go when it has to ([[0026-dev-owns-the-open]], [[0035-one-device-per-worktree]]). A stale plan prebuilds and builds through `expo run:`, which does the same and installs the build. So `dev --ios` ends with the app running, whichever route the plan took, and no separate boot step was needed. ## What required breaks diff --git a/packages/@expo/agent-cli/llp/0026-dev-owns-the-open.rfc.md b/packages/@expo/agent-cli/llp/0026-dev-owns-the-open.rfc.md index c1a604f9..4220d22e 100644 --- a/packages/@expo/agent-cli/llp/0026-dev-owns-the-open.rfc.md +++ b/packages/@expo/agent-cli/llp/0026-dev-owns-the-open.rfc.md @@ -11,7 +11,7 @@ [[0025-dev-requires-platform]] made `dev --ios` put the app on a device by forwarding the flag to `expo start`. That worked through the Expo CLI's opener, which checks Simulator.app with an osascript that a Mac without the Automation grant refuses — and the refusal is uncaught, so the dev server dies with it [observed live — 2026-08-24]. `smoke` had already routed around this; Kudo asked whether `dev` and `smoke` could share that code [2026-09-05]. -Now they do. `dev` opens the app itself, through the same modules `smoke` and `navigate` drive: probe or boot the device (`bootDevice`), put the right Expo Go on it (`installExpoGo`, version-checked like `@expo/cli` does), and deep-link to `/` (`openRouteAsync`, which handles `adb reverse` and the dev-client launcher URL). No AppleScript anywhere, so it works headless and needs no grant. +Now they do. `dev` opens the app itself, through the same modules `smoke` and `navigate` drive: take the simulator the registry bound to this worktree ([[0035-one-device-per-worktree]]), or probe or boot an emulator (`bootDevice`), put the right Expo Go on it (`installExpoGo`, version-checked like `@expo/cli` does), and deep-link to `/` (`openRouteAsync`, which handles `adb reverse` and the dev-client launcher URL). No AppleScript anywhere, so it works headless and needs no grant. ## How it hangs together @@ -36,4 +36,4 @@ Tier 0 doubles the dev server, never a device ([[0002-testing-and-evals]]). With - Unit: 3964 pass, including the new `openApp` table and the hook wiring. - E2E: 629 pass — plan argvs without the flag, the forwarded options intact, the F140 grace on a `run:ios` stub that dies after its bundler answered. -- Live: `dev --ios --detach` on the booted iPhone 17 Pro opens Expo Go on the started dev server (live-local tier). +- Live: `dev --ios --detach` on the simulator bound to the worktree opens Expo Go on the started dev server (live-local tier). From 28b50e0ae161abf096d7715d8cbd8a01a55fdfcf Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 13:05:04 +0200 Subject: [PATCH 04/17] [agent-cli] changelog link for #134 Co-Authored-By: Claude Fable 5.1 --- packages/@expo/agent-cli/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index 30c6a2a6..03ed5ca9 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -12,7 +12,7 @@ ### 🎉 New features - `navigate` and `smoke` now suppress onboarding, the floating Tools button, and automatic dev-menu opening when launching a development build. Set `EXPO_NO_DEV_MENU=0` to opt out. -- `dev --ios` binds an iOS simulator of its own to the git worktree it runs in, and every verb of that worktree uses it. The simulator is one this CLI created, named `agent-cli `, or the one the worktree bound before; it is booted with `simctl bootstatus -b`, the build and install steps carry `--device `, and the open deep-links to it. `navigate`, `runtime:reload`, `runtime:stop` and `status --explain` read the binding, extend its 60-minute lease, and never pick the first booted simulator: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --ios --detach --wait-ready`. `dev --detach` on a running server boots the worktree's own simulator and refuses `not-reusable` when it has none; a device refusal in the detached child reaches the caller with its own code, exit and `Try:` line. `smoke --ios` gets its simulator from the binding too and leaves it bound. The bindings live under `/agent-cli/bindings`, written under one machine-wide lock. Android keeps the first `adb` device until its own binding lands. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak)) +- `dev --ios` binds an iOS simulator of its own to the git worktree it runs in, and every verb of that worktree uses it. The simulator is one this CLI created, named `agent-cli `, or the one the worktree bound before; it is booted with `simctl bootstatus -b`, the build and install steps carry `--device `, and the open deep-links to it. `navigate`, `runtime:reload`, `runtime:stop` and `status --explain` read the binding, extend its 60-minute lease, and never pick the first booted simulator: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --ios --detach --wait-ready`. `dev --detach` on a running server boots the worktree's own simulator and refuses `not-reusable` when it has none; a device refusal in the detached child reaches the caller with its own code, exit and `Try:` line. `smoke --ios` gets its simulator from the binding too and leaves it bound. The bindings live under `/agent-cli/bindings`, written under one machine-wide lock. Android keeps the first `adb` device until its own binding lands. ([#134](https://github.com/expo/expo-agent-cli/pull/134) by [@vonovak](https://github.com/vonovak)) - `status --explain` reports what the app installed on a device was built from. It reads the fingerprint a debug build embeds (`expo-constants`, SDK 58+) out of the app on an Android device or a booted iOS simulator, compares it with the project's fingerprint for that platform, and says `up to date`, `rebuild required` or `unknown`, with the commands that fix a stale one. On an older SDK it says the app cannot be checked and reads no device. Right about a build somebody else made, which the freshness line cannot be. Android reads a few hundred KB of the APK through `adb` instead of pulling it; `--device ` narrows the read to one device. ([#59](https://github.com/expo/expo-agent-cli/pull/59) by [@vonovak](https://github.com/vonovak)) - `status --explain --device ` reads the app on a physical iPhone. `devicectl` exposes no app container, so the app is launched with a one-time URL and posts its embedded fingerprint back over the LAN; it needs a debug build whose `expo-dev-client` answers the check. Naming the phone is the consent, because the probe launches the app and `status` otherwise starts nothing. `--device-timeout ` bounds how long the phone gets to answer. A phone that does not answer in time reads as unknown. ([#90](https://github.com/expo/expo-agent-cli/pull/90) by [@vonovak](https://github.com/vonovak)) - `smoke --eas` brings its own EAS Simulator session the way `smoke` boots a simulator: its dev server starts through `dev --eas` (tunnelled; a build the plan needs is the simulator profile on EAS), the session in progress on the platform is reused, and otherwise one is started with Expo Go or with the finished EAS build of this fingerprint — and stopped again when the run ends. A development build with no finished build on EAS is a failed `start-session` phase naming `dev --eas`; the gate never compiles. `dev:stop --eas` also ends this project's session, by id, and reports it under `session`. ([#37](https://github.com/expo/expo-agent-cli/pull/37) by [@kudo](https://github.com/kudo)) From 1ac06ff3e0706bf3c414490f03e7cc0ded7d2855 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 13:30:18 +0200 Subject: [PATCH 05/17] =?UTF-8?q?[agent-cli]=20the=20review=20of=20#134:?= =?UTF-8?q?=20absent=20survives=20a=20missing=20adb,=20the=20Try=20line=20?= =?UTF-8?q?stays=20the=20dev=20command,=20the=20relayed=20refusal=20keeps?= =?UTF-8?q?=20its=20data,=20and=20the=20old=20simulator=20reader=20goes=20?= =?UTF-8?q?(llp/0031=20=C2=A7How=20`dev`=20uses=20it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mac without an Android SDK reaches `absent` again, because the iOS inspection answered. A NO_BOUND_DEVICE refusal keeps `dev` as its Try line; --print-url is an Or: door. The detached child's code row carries `data`, and only the Try line next to it counts. smoke keeps the How line in its boot reason and prints the acquire line. The EAS fallback asks the service only when the dotenv names a session. The booted-only simulator reader is deleted. The named tests produce the child's log through logCmdError, forward a --device the pin replaces, and pin the acquire line before the report. 0031 says the order built and the decisions taken; 0030 gains the boot-failed row. Co-Authored-By: Claude Fable 5.1 --- .../e2e/__tests__/installed-readers-test.ts | 40 ++++-- .../llp/0035-one-device-per-worktree.rfc.md | 10 +- .../src/dev/__tests__/childVerdict-test.ts | 64 ++++++++-- .../src/dev/__tests__/detach-test.ts | 2 +- .../src/dev/__tests__/detachAsync-test.ts | 86 ++++++++++--- .../src/dev/__tests__/devAsync-test.ts | 15 ++- .../@expo/agent-cli/src/dev/childVerdict.ts | 30 ++++- .../@expo/agent-cli/src/dev/detachAsync.ts | 3 + .../src/device/__tests__/localDevice-test.ts | 15 ++- .../@expo/agent-cli/src/device/localDevice.ts | 7 +- .../__tests__/iosSimulator-test.ts | 120 +++++------------- .../src/installedApp/iosSimulator.ts | 26 +--- .../src/navigate/__tests__/device-test.ts | 23 +++- .../navigate/__tests__/navigateAsync-test.ts | 2 +- .../@expo/agent-cli/src/navigate/device.ts | 15 ++- .../src/smoke/__tests__/smokeDeps-test.ts | 2 +- .../@expo/agent-cli/src/smoke/smokeAsync.ts | 18 ++- packages/@expo/agent-cli/src/utils/errors.ts | 17 ++- 18 files changed, 308 insertions(+), 187 deletions(-) diff --git a/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts b/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts index 1b02cd6a..3293bf93 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts @@ -11,7 +11,6 @@ import { listConnectedIosDevicesAsync } from '../../src/device/devicectl'; import { readInstalledFingerprintAndroidAsync } from '../../src/installedApp/android'; import { readInstalledFingerprintIosAsync } from '../../src/installedApp/ios'; import { readInstalledFingerprintIosDeviceAsync } from '../../src/installedApp/iosDevice'; -import { readInstalledFingerprintIosSimulatorAsync } from '../../src/installedApp/iosSimulator'; import { getTemporaryPath, pathEnvVars } from '../utils'; import { EMBEDDED_HASH, @@ -106,15 +105,23 @@ describe('the simulator reader over a stub xcrun', () => { }); afterEach(() => restore()); - it('asks simctl for the app container and reads the file inside it', async () => { + /** The stubs on `PATH`, and the registry in the fixture's own Expo home. */ + function useStubs(binDir: string): void { + restore = withEnv({ + ...pathEnvVars(`${binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`), + __UNSAFE_EXPO_HOME_DIRECTORY: `${root}.expo-home`, + }); + } + + it('reads the bound simulator: one listing, then the app container and the file inside it', async () => { const xcrun = await installStubXcrunAsync(root, { booted: { fingerprint: EMBEDDED_HASH } }); - restore = withEnv( - pathEnvVars(`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`) - ); + useStubs(xcrun.binDir); - const result = await readInstalledFingerprintIosSimulatorAsync({ + const result = await readInstalledFingerprintIosAsync({ + projectRoot: root, appId: APP_ID, expectedHash: EMBEDDED_HASH, + scheme: null, }); expect(result).toMatchObject({ @@ -123,20 +130,27 @@ describe('the simulator reader over a stub xcrun', () => { device: { name: SIMULATOR_NAME, identifier: SIMULATOR_UDID }, }); expect(xcrun.calls()).toEqual([ - ['simctl', 'list', 'devices', 'booted', '-j'], + ['simctl', 'list', 'devices', '-j'], ['simctl', 'get_app_container', SIMULATOR_UDID, APP_ID], ]); }); - it('answers no-device when no simulator is booted', async () => { + it('answers no-device with the dev command when nothing is bound', async () => { const xcrun = await installStubXcrunAsync(root); - restore = withEnv( - pathEnvVars(`${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`) - ); + useStubs(xcrun.binDir); await expect( - readInstalledFingerprintIosSimulatorAsync({ appId: APP_ID, expectedHash: EMBEDDED_HASH }) - ).resolves.toEqual({ status: 'no-device' }); + readInstalledFingerprintIosAsync({ + projectRoot: root, + appId: APP_ID, + expectedHash: EMBEDDED_HASH, + scheme: null, + }) + ).resolves.toEqual({ + status: 'no-device', + hint: expect.stringContaining('dev --ios --detach --wait-ready'), + }); + expect(xcrun.calls().some((args) => args[0] === 'simctl')).toBe(false); }); }); diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index e9746c04..f42c34c1 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -290,6 +290,7 @@ Each reaped device is reported on stderr and in JSON with reason `expired`, `del | `DEVICE_UNAVAILABLE` | `not-reusable` | 20 | "a dev server is running and this worktree has no device to reuse" | ` dev:stop` | | `DEVICE_UNAVAILABLE` | `create-timeout` | 22 | "the device tool did not answer in time" | run the command again | | `DEVICE_UNAVAILABLE` | `spawn-failed` | 22 | the spawn error | run the command again | +| `DEVICE_UNAVAILABLE` | `boot-failed` | 20 | the boot's reason; the binding is let go of first | run the command again | | `DEVICE_REGISTRY_LOCKED` | `locked` | 22 | "pid N () has held the device registry for " | run the command again, or `kill ` when it repeats | - The agent guide (`src/agents/content.ts`) gains a `## Devices` heading with two sentences. One: run `dev` before `navigate`, `runtime:reload` or `runtime:stop`, or they refuse with `NO_BOUND_DEVICE` and name the command. Two: never boot, shut down or delete a simulator named `agent-cli …` yourself; it may be bound to another worktree. @@ -398,7 +399,14 @@ Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` a - `dev`'s callback first runs main's `--port` bindability check (`resolvePlannedPortAsync`, then `portDemandedError`, outside `--plan`) when the draft serves, and only refuses; main's later port block stays and still sets the port. Then it calls `acquireDeviceAsync`. Both skip under `AGENT_CLI_NO_DEVICE`, read through one helper in `src/deviceBinding/index.ts` that `smoke` uses too. - The probe becomes `probeAppPresenceAsync(root, platform, { device, action }, injections)`: the device context is a new third parameter and main's injections move to the fourth; `ResolveStartPlanOptions.probeAppPresence` gains the parameter. With a device it answers `missing` without asking when `action` is `created`, even when no app id resolves, because a fresh device never has the app and the install step needs no id. On iOS a `Shutdown` simulator's apps are read off its disk. With null on iOS it answers main's `UNPROBED`. With null on Android it keeps main's first-device lookup until 3b; the `probeDevice` injection takes the root. -- Building draft: the resolver decides fully with no probe. `devAsync`'s order after `withForwardedExpoArgs` becomes: the refusals (`implicitEasRouteError` and the EAS project check move up from after the port block), the `--port` check, the `--plan` return, `acquireDeviceAsync`, `withDevice`, then the port block, which sets the port. So no simulator is created for a run that then refuses, and `--plan` never acquires. +- Building draft: the resolver decides fully with no probe. `devAsync`'s order after `withForwardedExpoArgs` is: the port block, which sets the port and stays before the `--plan` return because the plan prints the port; the `--plan` return; the refusals (`implicitEasRouteError` and the EAS project check); `acquireDeviceAsync`; `withDevice`. So no simulator is created for a run that then refuses, and `--plan` never acquires. The named `--port` refusal runs in the callback before `acquireDeviceAsync`, for a serving draft and a building one alike. +- Decided while building, where the RFC left it open: + - The detached child's log carries one row, `Error code exit data `, written by `logCmdError` only under `__EXPO_AGENT_CLI_DETACHED=1`, which `devDetachAsync` sets on the spawn. The parent reads the row, the `Try:` line right above it, and nothing else, and rebuilds the error with its code, exit, `data` and `suggestedCommand`. + - A boot that fails lets go of the binding and refuses `DEVICE_UNAVAILABLE` with reason `boot-failed` (§Output and errors). An inventory read that times out refuses `create-timeout`. + - `src/deviceBinding/` has `ios.ts` (the iOS `simctl` calls), `errors.ts`, `events.ts` and `tools.ts` (`defaultTools`) beside the RFC's files; `DeviceTools` holds `simctl`, `now` and `isPidAlive` until §Android instance adds the Android members, and `inspectBindingAsync` takes `local-ios` and `cloud` only. + - `findBoundDeviceAsync` takes no `eas` option; `resolveDeviceAsync` keeps its `cloud: 'required'` short-circuit. Main's Android rung is injected as `android?: AndroidRung`, because Contract 10 keeps `navigate` out of the module. The EAS fallback asks the service only when the dotenv names a session. + - The `--plan` reason "will reuse or create a simulator" is added only for `--ios` without `--eas` and with devices on, so a stubbed harness's plans are unchanged. + - `smoke`'s iOS boot skips under `AGENT_CLI_NO_DEVICE` and reports a failed boot phase; its install phase honours the boot's `installNeeded`, because a created simulator's disk holds nothing to read. - `withDevice(plan, device)` runs after `withForwardedExpoArgs` and before the `--port` block. It sets `--device ` on every `run` and `install` step and replaces any `--device` there; on a plan with neither it is a no-op. `AppPresenceProbe.installDevice` goes for iOS. `devAsync` passes `acquired.device` through `executePlanAsync` to `openAppForRunAsync`'s local branch, which hands it to `openAppOnDeviceAsync`; its iOS probe and boot go, and it passes the device to `openRouteAsync` through a new `device?: BoundDevice` option, which skips `resolveDeviceAsync`, so the open inspects nothing twice. `report.booted` is `acquired.justBooted`. - The callback runs for iOS only. Android keeps main's probe-and-boot path, its first-adb rung, its `probeLocalDeviceAsync` branch and its `installDevice` until 3b. `acquire` is skipped for `--eas`. - `dev --plan` passes a callback that inspects the own binding and returns it as `reused` with `justBooted: false` when the state is `up` or `not-up`; otherwise null. For a building draft `devAsync` calls that same callback itself and pins the plan, with no probe. When the callback returned null, `devAsync` leaves the step unpinned and adds the reason "will reuse or create a simulator" to the plan, building or not. diff --git a/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts index 1568411f..906cf836 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/childVerdict-test.ts @@ -3,7 +3,14 @@ import { stripVTControlCharacters } from 'util'; import { needsHumanError } from '../../needsHuman/error'; import { formatStartPlan } from '../../plan/format'; import type { StartPlan } from '../../project/types'; -import { formatErrorCodeRow, formatNeedsHumanBlock } from '../../utils/errors'; +import { noBoundDeviceError } from '../../deviceBinding/errors'; +import * as Log from '../../log'; +import { + formatErrorCodeRow, + formatNeedsHumanBlock, + logCmdError, + type CommandError, +} from '../../utils/errors'; import { parseDetachedChildPhase, parseDetachedChildPort, @@ -11,6 +18,32 @@ import { stepOpensPlatform, } from '../childVerdict'; +vi.mock('../../log'); +vi.mock('../../events', () => ({ event: vi.fn() })); +vi.mock('../../exitCodes', async (importOriginal) => ({ + ...(await importOriginal()), + exitWithCodeAsync: vi.fn(() => new Promise(() => {})), +})); + +/** + * The log a detached child writes for a failure, produced by `logCmdError` itself under + * `__EXPO_AGENT_CLI_DETACHED=1` and captured off the log module, line by line. + */ +function childLogOf(error: CommandError): string[] { + vi.mocked(Log.exception).mockClear(); + vi.mocked(Log.warn).mockClear(); + process.env.__EXPO_AGENT_CLI_DETACHED = '1'; + try { + void logCmdError(error); + } finally { + delete process.env.__EXPO_AGENT_CLI_DETACHED; + } + return [ + ...vi.mocked(Log.exception).mock.calls.map(([printed]) => String(printed)), + ...vi.mocked(Log.warn).mock.calls.map((args) => args.join(' ')), + ]; +} + /** * The log a child writes when it fails, built out of the functions that write it. * @@ -58,6 +91,7 @@ describe(parseDetachedChildVerdict, () => { message: 'CommandError: The plan stopped at "start".\nWhy: the bundler could not start.', code: null, exitCode: null, + data: null, suggestedCommand: null, }); }); @@ -65,24 +99,30 @@ describe(parseDetachedChildVerdict, () => { // @ref llp/0035-one-device-per-worktree.rfc.md §Output and errors — the row `logCmdError` // writes for a detached child alone, read back with the `Try:` line, so a device refusal // reaches the parent's caller as the child raised it. - it(`should read the code, the exit and the Try line of a device refusal`, () => { - const verdict = parseDetachedChildVerdict([ - 'CommandError: No ios device is bound to this worktree.', - 'Why: every verb drives the bound device.', - 'How: run "npx @expo/agent-cli dev --ios --detach --wait-ready", then this command again.', - 'Try: npx @expo/agent-cli dev --ios --detach --wait-ready', - formatErrorCodeRow('NO_BOUND_DEVICE', 20), - ]); + it(`should read the code, the exit, the data and the Try line of a device refusal`, () => { + const refusal = noBoundDeviceError('none', { platform: 'ios' }); + const verdict = parseDetachedChildVerdict(childLogOf(refusal)); expect(verdict).toMatchObject({ scenario: null, code: 'NO_BOUND_DEVICE', exitCode: 20, + data: { reason: 'none' }, suggestedCommand: 'npx @expo/agent-cli dev --ios --detach --wait-ready', }); - expect(verdict?.message).not.toContain('Try:'); - expect(verdict?.message).not.toContain('Error code'); - expect(verdict?.message).toContain('How: run'); + expect(verdict?.message).toBe(`CommandError: ${refusal.message}`); + }); + + it(`should take only the Try line next to the code row`, () => { + const verdict = parseDetachedChildVerdict([ + 'Try: npx something-earlier', + 'Waiting on http://127.0.0.1:8081', + 'CommandError: No ios device is bound to this worktree.', + 'How: run the dev command.', + formatErrorCodeRow('NO_BOUND_DEVICE', 20), + ]); + + expect(verdict).toMatchObject({ code: 'NO_BOUND_DEVICE', suggestedCommand: null }); }); it(`should keep the last error, which is the one the child stopped on`, () => { diff --git a/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts index 591119ad..0f0a344b 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/detach-test.ts @@ -80,7 +80,7 @@ describe(buildDetachSpawn, () => { // 7's F61 and the 2026-08-26 live run's S4. Three facts in, one verdict out. describe(resolveDetachFailure, () => { const healthy = { exited: false, verdict: null, statusAnswering: true }; - const noCode = { code: null, exitCode: null, suggestedCommand: null }; + const noCode = { code: null, exitCode: null, data: null, suggestedCommand: null }; it(`should let a live child with a live bundler through`, () => { expect(resolveDetachFailure(healthy)).toBeNull(); diff --git a/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts index 645e3016..90912f2e 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts @@ -5,10 +5,12 @@ import { vol } from 'memfs'; import path from 'path'; import { readDevServerLockAsync } from '../../devLock'; -import { acquireDeviceAsync, devicesDisabled } from '../../deviceBinding'; +import { acquireDeviceAsync, acquireLine, devicesDisabled } from '../../deviceBinding'; +import { deviceUnavailableError } from '../../deviceBinding/errors'; import { event as cliEvent } from '../../events'; +import * as Log from '../../log'; import { waitForBundlerReadyAsync, type BundlerReadyResult } from '../../runtime/waitReady'; -import { formatErrorCodeRow } from '../../utils/errors'; +import { logCmdError, type CommandError } from '../../utils/errors'; import { fetchAdvertisedUrlAsync } from '../advertisedUrl'; import { devDetachAsync, OPEN_PLATFORM_GRACE_MS } from '../detachAsync'; import { detachedLogPath } from '../logFile'; @@ -16,6 +18,11 @@ import { isProcessAlive } from '../processLiveness'; import { resolveDevOptions } from '../resolveOptions'; vi.mock('../../devLock', () => ({ readDevServerLockAsync: vi.fn() })); +vi.mock('../../log'); +vi.mock('../../exitCodes', async (importOriginal) => ({ + ...(await importOriginal()), + exitWithCodeAsync: vi.fn(() => new Promise(() => {})), +})); vi.mock('../../deviceBinding', async (importOriginal) => ({ ...(await importOriginal()), devicesDisabled: vi.fn(() => true), @@ -586,21 +593,16 @@ describe('the bound device', () => { }); // detached-device-error-relayed - it("rethrows the child's device refusal unchanged: code, exit and Try line", async () => { + // detached-device-error-relayed: the child's log is what `logCmdError` writes under + // `__EXPO_AGENT_CLI_DETACHED=1`, captured off the log module, so the round trip is the real one. + it("rethrows the child's device refusal unchanged: code, exit, data and Try line", async () => { vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(null); + const refusal = deviceUnavailableError('no-ios-runtime', { platform: 'ios' }); + refusal.suggestedCommand = 'xcodebuild -downloadPlatform iOS'; let child: EventEmitter; vi.mocked(spawn).mockImplementation(() => { child = Object.assign(new EventEmitter(), { pid: 4242, unref: vi.fn() }); - fs.writeFileSync( - logFile, - [ - 'CommandError: No iOS runtime with an iPhone is installed, so no simulator can be created.', - 'How: run "xcodebuild -downloadPlatform iOS", then this command again.', - 'Try: xcodebuild -downloadPlatform iOS', - formatErrorCodeRow('DEVICE_UNAVAILABLE', 7), - '', - ].join('\n') - ); + fs.writeFileSync(logFile, `${childLogOf(refusal).join('\n')}\n`); setTimeout(() => child.emit('exit', 7, null), 50); return child as unknown as ChildProcess; }); @@ -616,10 +618,62 @@ describe('the bound device', () => { expect(error).toMatchObject({ code: 'DEVICE_UNAVAILABLE', exitCode: 7, + data: { reason: 'no-ios-runtime' }, suggestedCommand: 'xcodebuild -downloadPlatform iOS', }); - expect(error.message).toBe( - 'No iOS runtime with an iPhone is installed, so no simulator can be created.\nHow: run "xcodebuild -downloadPlatform iOS", then this command again.' - ); + expect(error.message).toBe(refusal.message); + }); +}); + +/** The log a detached child writes for `error`, line by line, as `logCmdError` prints it. */ +function childLogOf(error: CommandError): string[] { + vi.mocked(Log.exception).mockClear(); + vi.mocked(Log.warn).mockClear(); + process.env.__EXPO_AGENT_CLI_DETACHED = '1'; + try { + void logCmdError(error); + } finally { + delete process.env.__EXPO_AGENT_CLI_DETACHED; + } + return [ + ...vi.mocked(Log.exception).mock.calls.map(([printed]) => String(printed)), + ...vi.mocked(Log.warn).mock.calls.map((args) => args.join(' ')), + ]; +} + +describe('the acquire line', () => { + it('is printed before the already-running report', async () => { + const running = { + url: 'http://127.0.0.1:8393', + port: 8393, + pid: 4242, + projectRoot, + startedAt: new Date(0).toISOString(), + }; + const reused = { + device: { + backend: 'local-ios' as const, + platform: 'ios' as const, + udid: 'SIM-1', + name: 'agent-cli 0000', + origin: 'created' as const, + }, + justBooted: true, + action: 'reused' as const, + }; + vi.mocked(devicesDisabled).mockReturnValue(false); + vi.mocked(acquireDeviceAsync).mockResolvedValue(reused); + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(running); + const { event: cliEvent } = await import('../../events'); + + await devDetachAsync(projectRoot, resolveDevOptions(['--ios', '--detach', '--local']), { + print: false, + reuseBoundDevice: true, + }); + + expect(Log.progress).toHaveBeenCalledWith(acquireLine(reused)); + const printedAt = vi.mocked(Log.progress).mock.invocationCallOrder[0]!; + const reportedAt = vi.mocked(cliEvent).mock.invocationCallOrder.at(-1)!; + expect(printedAt).toBeLessThan(reportedAt); }); }); diff --git a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts index 1b027f8f..748de743 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts @@ -1409,17 +1409,18 @@ describe(devAsync, () => { }); // withDevice-after-forwarded-args - it(`binds after the refusals and pins the build step to the simulator`, async () => { + it(`binds after the refusals and pins the build step to the simulator, over a forwarded --device`, async () => { mockStaleDevClientState(); - await expect(devAsync(projectRoot, resolveDevOptions(['--ios', '--local']))).resolves.toBe(0); + await expect( + devAsync(projectRoot, resolveDevOptions(['--ios', '--local', '--', '--device', 'X'])) + ).resolves.toBe(0); expect(acquireDeviceAsync).toHaveBeenCalledWith(projectRoot, 'ios'); - expect(runDevServerAsync).toHaveBeenCalledWith( - projectRoot, - ['run:ios', '--port', '8081', '--device', 'SIM-1'], - expect.anything() - ); + const [, args] = vi.mocked(runDevServerAsync).mock.calls[0]!; + expect(args.filter((arg) => arg === '--device')).toHaveLength(1); + expect(args).toEqual(expect.arrayContaining(['run:ios', '--device', 'SIM-1'])); + expect(args).not.toContain('X'); // The plan an agent reads carries the pin too: the plan approved is the plan run. expect(emitStartPlan).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/@expo/agent-cli/src/dev/childVerdict.ts b/packages/@expo/agent-cli/src/dev/childVerdict.ts index 85bdaca6..46db6eec 100644 --- a/packages/@expo/agent-cli/src/dev/childVerdict.ts +++ b/packages/@expo/agent-cli/src/dev/childVerdict.ts @@ -36,6 +36,8 @@ export interface DetachedChildVerdict { */ code: string | null; exitCode: number | null; + /** The failure's `data`, carried on the same row, so a `--json` envelope keeps its `reason`. */ + data: Record | null; /** The child's `Try:` line, which is its How line as a command. */ suggestedCommand: string | null; } @@ -47,7 +49,7 @@ const ERROR_LINE = /^([A-Za-z]*Error): (.*)$/; const NEEDS_HUMAN_ROW = /^Needs a human\s{2,}(\S+)\s*$/; /** `Error code exit ` — {@link formatErrorCodeRow}'s row. */ -const ERROR_CODE_ROW = /^Error code\s{2,}(\S+) exit (\d+)\s*$/; +const ERROR_CODE_ROW = /^Error code\s{2,}(\S+) exit (\d+)(?: data (\{.*\}))?\s*$/; /** `Try: ` — the last line of every failure that names a next command. */ const TRY_LINE = /^Try: (.+)$/; @@ -74,8 +76,11 @@ export function parseDetachedChildVerdict( let scenario: string | null = null; let code: string | null = null; let exitCode: number | null = null; + let data: Record | null = null; let suggestedCommand: string | null = null; let errorAt = -1; + let tryAt = -1; + let lastTry: string | null = null; for (let index = 0; index < lines.length; index++) { const line = lines[index]!; @@ -88,11 +93,16 @@ export function parseDetachedChildVerdict( if (coded) { code = coded[1]!; exitCode = Number(coded[2]); + data = parseData(coded[3]); + // `logCmdError` writes the `Try:` line right above the code row; one elsewhere is output of + // another program, or of an earlier failure. + suggestedCommand = tryAt === index - 1 ? lastTry : null; continue; } const tried = TRY_LINE.exec(line); if (tried) { - suggestedCommand = tried[1]!.trim(); + tryAt = index; + lastTry = tried[1]!.trim(); continue; } if (ERROR_LINE.test(line)) { @@ -112,7 +122,21 @@ export function parseDetachedChildVerdict( .join('\n') .trim(); - return { scenario, message, code, exitCode, suggestedCommand }; + return { scenario, message, code, exitCode, data, suggestedCommand }; +} + +function parseData(json: string | undefined): Record | null { + if (json == null) { + return null; + } + try { + const parsed: unknown = JSON.parse(json); + return parsed != null && typeof parsed === 'object' + ? (parsed as Record) + : null; + } catch { + return null; + } } /** The codes the parent of a detached run rethrows unchanged (llp/0035 §Output and errors). */ diff --git a/packages/@expo/agent-cli/src/dev/detachAsync.ts b/packages/@expo/agent-cli/src/dev/detachAsync.ts index 4c00a0ed..99f1219a 100644 --- a/packages/@expo/agent-cli/src/dev/detachAsync.ts +++ b/packages/@expo/agent-cli/src/dev/detachAsync.ts @@ -635,6 +635,9 @@ function relayedDeviceError(verdict: DetachedChildVerdict | null): CommandError } const error = new CommandError(verdict.code, verdict.message.replace(/^[A-Za-z]*Error: /, '')); error.exitCode = verdict.exitCode ?? EXIT_OUTCOME_FAILED; + if (verdict.data) { + error.data = verdict.data; + } if (verdict.suggestedCommand) { error.suggestedCommand = verdict.suggestedCommand; } diff --git a/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts b/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts index 4eb13f9d..0286339c 100644 --- a/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts +++ b/packages/@expo/agent-cli/src/device/__tests__/localDevice-test.ts @@ -104,8 +104,19 @@ describe(readLocalDeviceProbe, () => { expect(readLocalDeviceProbe(ios, noAndroid).state).toBe('unknown'); }); - it(`reports unknown when adb could not run and nothing is bound`, () => { - const probe = readLocalDeviceProbe(inspection('none'), adbUnrunnable); + // A Mac without an Android SDK: the binding answered, so the ladders still reach `absent`. + it.each([ + ['none', inspection('none')], + ['gone', inspection('gone', { cause: 'device-gone' })], + ] as const)(`reports absent on macOS for a %s binding when adb could not run`, (_state, ios) => { + const probe = readLocalDeviceProbe(ios, adbUnrunnable); + + expect(probe.state).toBe('absent'); + expect(probe.reason).toContain('could not run'); + }); + + it(`reports unknown when adb could not run and it was the only rung`, () => { + const probe = readLocalDeviceProbe(null, adbUnrunnable); expect(probe.state).toBe('unknown'); expect(probe.reason).toContain('could not run'); diff --git a/packages/@expo/agent-cli/src/device/localDevice.ts b/packages/@expo/agent-cli/src/device/localDevice.ts index f34707cf..0df75889 100644 --- a/packages/@expo/agent-cli/src/device/localDevice.ts +++ b/packages/@expo/agent-cli/src/device/localDevice.ts @@ -133,8 +133,11 @@ export function readLocalDeviceProbe( ios ? `iOS: ${iosReason(ios)}` : null, android.reason ? `Android: ${android.reason}` : null, ].filter((reason): reason is string => reason != null); - const unknown = - ios?.state === 'unreadable' || ios?.state === 'unknown' || android.toolError != null; + // An `adb` that cannot run says nothing only where it is the one rung: on a Mac the binding + // answered, and a missing Android SDK must not keep the ladders from `absent`. + const unknown = ios + ? ios.state === 'unreadable' || ios.state === 'unknown' + : android.toolError != null; return { state: unknown ? 'unknown' : 'absent', device: null, diff --git a/packages/@expo/agent-cli/src/installedApp/__tests__/iosSimulator-test.ts b/packages/@expo/agent-cli/src/installedApp/__tests__/iosSimulator-test.ts index 2749afea..700bb02d 100644 --- a/packages/@expo/agent-cli/src/installedApp/__tests__/iosSimulator-test.ts +++ b/packages/@expo/agent-cli/src/installedApp/__tests__/iosSimulator-test.ts @@ -1,9 +1,6 @@ // @ref llp/0005-runtime-loop-tools.rfc.md §Proof import type { SpawnCaptureResult } from '../../utils/spawnCapture'; -import { - fingerprintCandidatePaths, - readInstalledFingerprintIosSimulatorAsync, -} from '../iosSimulator'; +import { fingerprintCandidatePaths, readSimulatorsAsync } from '../iosSimulator'; /** The file expo-constants embeds: the hash plus the version that produced it. */ const embed = (hash: string, fingerprintVersion: string | null = '0.21.0') => @@ -24,20 +21,6 @@ function fakeSimctl(simulators: FakeSimulator[], files: Record) _command: string, args: string[] ): Promise => { - if (args[1] === 'list') { - return ok( - JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': simulators.map(({ udid, name }) => ({ - udid, - name, - state: 'Booted', - })), - 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [{ udid: 'WATCH', name: 'Watch' }], - }, - }) - ); - } const simulator = simulators.find((s) => s.udid === args[2]); if (!simulator?.container) { return { stdout: '', stderr: 'No such file or directory', exitCode: 2 }; @@ -54,98 +37,57 @@ function fakeSimctl(simulators: FakeSimulator[], files: Record) return { spawnCaptureAsync, readFile }; } -describe(readInstalledFingerprintIosSimulatorAsync, () => { +const device = (simulator: FakeSimulator) => ({ + identifier: simulator.udid, + name: simulator.name, +}); + +describe(readSimulatorsAsync, () => { const phone = { udid: 'UDID-1', name: 'iPhone 17 Pro', container: '/sims/1/App.app' }; it.each(fingerprintCandidatePaths('/sims/1/App.app').map((p) => [p]))( - `reads the hash at %s`, + `reads the embedded file at %s`, async (filePath) => { - const fake = fakeSimctl([phone], { [filePath]: embed('abc123') }); + const fake = fakeSimctl([phone], { [filePath]: embed('abc') }); + await expect( - readInstalledFingerprintIosSimulatorAsync({ - expectedHash: 'abc123', - appId: APP_ID, - ...fake, - }) - ).resolves.toEqual({ + readSimulatorsAsync([device(phone)], APP_ID, 'abc', fake) + ).resolves.toMatchObject({ status: 'ok', - hash: 'abc123', + hash: 'abc', fingerprintVersion: '0.21.0', - sources: [], appId: APP_ID, - device: { name: 'iPhone 17 Pro', identifier: 'UDID-1' }, + device: device(phone), }); } ); - it(`answers no-embedded-fingerprint when the container holds no file`, async () => { - const fake = fakeSimctl([phone], {}); - await expect( - readInstalledFingerprintIosSimulatorAsync({ expectedHash: 'x', appId: APP_ID, ...fake }) - ).resolves.toMatchObject({ status: 'no-embedded-fingerprint' }); - }); - - it(`answers app-not-installed when get_app_container fails`, async () => { + it(`answers app-not-installed when the container is refused`, async () => { const fake = fakeSimctl([{ ...phone, container: null }], {}); - await expect( - readInstalledFingerprintIosSimulatorAsync({ expectedHash: 'x', appId: APP_ID, ...fake }) - ).resolves.toMatchObject({ status: 'app-not-installed' }); - }); - it(`prefers the simulator whose app matches`, async () => { - const old = { udid: 'UDID-2', name: 'iPad', container: '/sims/2/App.app' }; - const fake = fakeSimctl([old, phone], { - [fingerprintCandidatePaths(old.container)[0]!]: embed('old'), - [fingerprintCandidatePaths(phone.container)[0]!]: embed('current'), + await expect(readSimulatorsAsync([device(phone)], APP_ID, 'x', fake)).resolves.toMatchObject({ + status: 'app-not-installed', + appId: APP_ID, }); - await expect( - readInstalledFingerprintIosSimulatorAsync({ - expectedHash: 'current', - appId: APP_ID, - ...fake, - }) - ).resolves.toMatchObject({ status: 'ok', hash: 'current', device: { identifier: 'UDID-1' } }); }); - it(`only reads the simulator --device names, and answers no-device for a name nothing has`, async () => { + it(`answers no-embedded-fingerprint when neither candidate file exists`, async () => { const fake = fakeSimctl([phone], {}); - await expect( - readInstalledFingerprintIosSimulatorAsync({ - expectedHash: 'x', - appId: APP_ID, - device: 'iphone 17 pro', - ...fake, - }) - ).resolves.toMatchObject({ status: 'no-embedded-fingerprint' }); - await expect( - readInstalledFingerprintIosSimulatorAsync({ - expectedHash: 'x', - appId: APP_ID, - device: 'Watch', - ...fake, - }) - ).resolves.toEqual({ status: 'no-device' }); - }); - it(`answers no-device when nothing is booted`, async () => { - const fake = fakeSimctl([], {}); - await expect( - readInstalledFingerprintIosSimulatorAsync({ expectedHash: 'x', appId: APP_ID, ...fake }) - ).resolves.toEqual({ status: 'no-device' }); + await expect(readSimulatorsAsync([device(phone)], APP_ID, 'x', fake)).resolves.toMatchObject({ + status: 'no-embedded-fingerprint', + }); }); - it(`throws XCRUN_NOT_RUNNABLE when xcrun cannot run`, async () => { + it(`keeps the answer that matches the expected hash across simulators`, async () => { + const stale = { udid: 'UDID-2', name: 'iPad', container: '/sims/2/App.app' }; + const fake = fakeSimctl([phone, stale], { + [fingerprintCandidatePaths(phone.container)[0]!]: embed('old'), + [fingerprintCandidatePaths(stale.container)[0]!]: embed('current'), + }); + await expect( - readInstalledFingerprintIosSimulatorAsync({ - expectedHash: 'x', - appId: APP_ID, - spawnCaptureAsync: async () => ({ - stdout: '', - stderr: '', - exitCode: null, - spawnError: Object.assign(new Error('spawn xcrun ENOENT'), { code: 'ENOENT' }), - }), - }) - ).rejects.toMatchObject({ code: 'XCRUN_NOT_RUNNABLE' }); + readSimulatorsAsync([device(phone), device(stale)], APP_ID, 'current', fake) + ).resolves.toMatchObject({ status: 'ok', hash: 'current', device: device(stale) }); }); }); diff --git a/packages/@expo/agent-cli/src/installedApp/iosSimulator.ts b/packages/@expo/agent-cli/src/installedApp/iosSimulator.ts index 6f11e7fe..83a9f8a6 100644 --- a/packages/@expo/agent-cli/src/installedApp/iosSimulator.ts +++ b/packages/@expo/agent-cli/src/installedApp/iosSimulator.ts @@ -4,12 +4,11 @@ import fs from 'fs'; import path from 'path'; -import { listBootedIosSimulatorsAsync, SIMCTL_TIMEOUT_MS } from '../device/simulators'; +import { SIMCTL_TIMEOUT_MS } from '../device/simulators'; import { spawnCaptureAsync } from '../utils/spawnCapture'; import { FINGERPRINT_FILE_NAME, parseEmbeddedFingerprint, - matchesDeviceFilter, pickBestResult, type InstalledAppDevice, type InstalledFingerprintResult, @@ -21,12 +20,6 @@ export interface IosSimulatorReaderDependencies { readFile?: (filePath: string) => string; } -export interface IosSimulatorReaderOptions extends IosSimulatorReaderDependencies { - expectedHash: string; - device?: string; - appId: string; -} - /** The two places the resource bundle lives: static linking, and `use_frameworks!`. */ export function fingerprintCandidatePaths(containerPath: string): string[] { return [ @@ -72,23 +65,6 @@ export async function readSimulatorsAsync( return pickBestResult(results, expectedHash); } -/** Read the fingerprint out of the app on every booted iOS simulator, or the one `--device` names. */ -export async function readInstalledFingerprintIosSimulatorAsync({ - expectedHash, - device: deviceFilter, - appId, - ...deps -}: IosSimulatorReaderOptions): Promise { - let simulators = await listBootedIosSimulatorsAsync(deps); - if (deviceFilter) { - simulators = simulators.filter((simulator) => matchesDeviceFilter(deviceFilter, simulator)); - } - if (!simulators.length) { - return { status: 'no-device' }; - } - return readSimulatorsAsync(simulators, appId, expectedHash, deps); -} - async function readSimulatorAsync( device: InstalledAppDevice, appId: string, diff --git a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts index 61cf834f..3d32d6c0 100644 --- a/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts +++ b/packages/@expo/agent-cli/src/navigate/__tests__/device-test.ts @@ -380,7 +380,7 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { // fallback-eas-rung-dotenv-only: a worktree with no binding must not drive another worktree's // session, so the newest session is never taken until llp/0035 §EAS session binding binds sessions. - it(`refuses a session the project's dotenv does not name`, async () => { + it(`asks the service nothing when the project's dotenv names no session`, async () => { mockPlatform('linux'); cloudProject(null); mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, { stdout: liveSession }]); @@ -389,6 +389,21 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { (e) => e ); + expect(error.code).toBe('NO_BOUND_DEVICE'); + // The Android rung alone: no `eas` was spawned for a session this worktree may not drive. + expect(spawn).toHaveBeenCalledTimes(1); + expect(error.message.split('\n').at(-1)).toMatch(/^How: /); + }); + + it(`refuses a session the dotenv names when the service lists another`, async () => { + mockPlatform('linux'); + cloudProject('sess-2'); + mockSpawnQueue([{ stdout: NO_ADB_DEVICES }, { stdout: liveSession }]); + + const error = await resolveDeviceAsync(undefined, { cloud: 'fallback', projectRoot }).catch( + (e) => e + ); + expect(error.code).toBe('NO_BOUND_DEVICE'); expect(error.message).toContain('running EAS Simulator session'); expect(error.message.split('\n').at(-1)).toMatch(/^How: /); @@ -436,7 +451,11 @@ describe(`${resolveDeviceAsync.name} with the cloud backend`, () => { 'exp://127.0.0.1:8081/--/settings' ); expect(lines.at(-1)).toMatch(/^How: /); - expect(error.suggestedCommand).toBe('npx @expo/agent-cli navigate / --print-url'); + // The next action stays the `dev` command; `--print-url` is a door, not the Try line. + expect(error.suggestedCommand).toBe('npx @expo/agent-cli dev --android --detach --wait-ready'); + expect(lines.find((line: string) => line.startsWith('Or: this is the URL'))).toContain( + 'navigate --print-url' + ); }); // `--eas` names the device, so no local tool is asked at all. diff --git a/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts b/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts index 3a3a0c72..b516f602 100644 --- a/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts +++ b/packages/@expo/agent-cli/src/navigate/__tests__/navigateAsync-test.ts @@ -1043,7 +1043,7 @@ describe(navigateAsync, () => { await expect(navigateAsync(projectRoot, options())).rejects.toMatchObject({ code: 'NO_BOUND_DEVICE', message: expect.stringContaining('exp://127.0.0.1:8081/--/profile/42'), - suggestedCommand: 'npx @expo/agent-cli navigate / --print-url', + suggestedCommand: 'npx @expo/agent-cli dev --ios --detach --wait-ready', }); }); }); diff --git a/packages/@expo/agent-cli/src/navigate/device.ts b/packages/@expo/agent-cli/src/navigate/device.ts index 3dfc30ef..48c55f58 100644 --- a/packages/@expo/agent-cli/src/navigate/device.ts +++ b/packages/@expo/agent-cli/src/navigate/device.ts @@ -304,10 +304,15 @@ async function cloudFallbackAsync( if (context.cloud !== 'fallback') { return { device: null, probe: null }; } + // No dotenv id, no session this worktree may drive: nothing to ask the service about. + const sessionId = readCloudSessionIdSync(context.projectRoot); + if (sessionId == null) { + return { device: null, probe: null }; + } const { device, probe } = await probeCloudDeviceAsync(context.projectRoot, { platform }); const usable = device != null && - device.deviceId === readCloudSessionIdSync(context.projectRoot) && + device.deviceId === sessionId && (platform == null || device.platform === platform); return { device: usable ? device : null, probe }; } @@ -388,11 +393,9 @@ function withOtherDoors( const error = new CommandError(refusal.code, [...lines, how].join('\n')); error.exitCode = refusal.exitCode; error.data = refusal.data; - // A caller that has no device does not get one by running the same command again, so the `Try:` - // is the mode that answers without one when there is a URL. - error.suggestedCommand = context.url - ? `${PROGRAM_PREFIX} navigate / --print-url` - : refusal.suggestedCommand; + // The `Try:` stays the `dev` command (llp/0035 §Output and errors); `--print-url` is a door + // named above it, not the next action. + error.suggestedCommand = refusal.suggestedCommand; return error; } diff --git a/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts index 9fab723a..624dbb0c 100644 --- a/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts +++ b/packages/@expo/agent-cli/src/smoke/__tests__/smokeDeps-test.ts @@ -71,7 +71,7 @@ describe('bootDevice', () => { expect(result).toMatchObject({ ok: false, deviceId: null, - reason: 'No iOS runtime with an iPhone is installed.', + reason: 'No iOS runtime with an iPhone is installed. How: install one.', }); }); diff --git a/packages/@expo/agent-cli/src/smoke/smokeAsync.ts b/packages/@expo/agent-cli/src/smoke/smokeAsync.ts index 677d5768..90cb38c4 100644 --- a/packages/@expo/agent-cli/src/smoke/smokeAsync.ts +++ b/packages/@expo/agent-cli/src/smoke/smokeAsync.ts @@ -15,7 +15,13 @@ import { resolveDevOptions } from '../dev/resolveOptions'; import { resolveDevStopOptions } from '../dev/resolveStopOptions'; import { devStopAsync, type DevStopResultJson } from '../dev/stopAsync'; import { bootDeviceAsync, shutdownDeviceAsync } from '../device/bootDevice'; -import { acquireDeviceAsync, deviceIdOf, deviceNameOf, devicesDisabled } from '../deviceBinding'; +import { + acquireDeviceAsync, + acquireLine, + deviceIdOf, + deviceNameOf, + devicesDisabled, +} from '../deviceBinding'; import { probeCloudSessionAsync } from '../device/cloudSimulator'; import { checkExpoGoVersionAsync } from '../device/expoGoVersion'; import { installDevBuildAsync } from '../device/installDevBuild'; @@ -528,6 +534,7 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok } try { const acquired = await acquireDeviceAsync(projectRoot, 'ios'); + Log.progress(acquireLine(acquired)); return { ok: true, deviceId: deviceIdOf(acquired.device), @@ -542,7 +549,7 @@ export function buildSmokeDeps(projectRoot: string, options: SmokeOptions): Smok deviceId: null, backend: null, choice: null, - reason: error instanceof Error ? firstLine(error.message) : String(error), + reason: error instanceof Error ? whatAndHow(error.message) : String(error), }; } } @@ -1182,6 +1189,13 @@ async function resolveSmokeTargetAsync( }; } +/** A refusal's What and its How line, as one phase reason; the Why in between is dropped. */ +function whatAndHow(message: string): string { + const lines = message.split('\n').filter((line) => line.trim()); + const how = lines.find((line) => line.startsWith('How:')); + return how && how !== lines[0] ? `${lines[0]} ${how}` : (lines[0] ?? message); +} + function firstLine(text: string): string { return text.split('\n')[0]!; } diff --git a/packages/@expo/agent-cli/src/utils/errors.ts b/packages/@expo/agent-cli/src/utils/errors.ts index e4bbfe1d..c99c392a 100644 --- a/packages/@expo/agent-cli/src/utils/errors.ts +++ b/packages/@expo/agent-cli/src/utils/errors.ts @@ -162,9 +162,18 @@ export function formatNeedsHumanBlock(needsHuman: NeedsHuman): string[] { return lines; } -/** `Error code exit `, the row the detached child's parent reads back. */ -export function formatErrorCodeRow(code: string, exitCode: number): string { - return `${'Error code'.padEnd(NEEDS_HUMAN_LABEL_WIDTH)}${code} exit ${exitCode}`; +/** + * `Error code exit data `, the row the detached child's parent reads back, + * so the child's refusal reaches the parent's caller with its code, exit and `data` unchanged. + */ +export function formatErrorCodeRow( + code: string, + exitCode: number, + data: Record | null = null +): string { + return `${'Error code'.padEnd(NEEDS_HUMAN_LABEL_WIDTH)}${code} exit ${exitCode}${ + data ? ` data ${JSON.stringify(data)}` : '' + }`; } /** @@ -291,7 +300,7 @@ export function logCmdError(error: any): never { // The detached child's log is the one channel to its parent, which relays a device refusal // unchanged (`src/dev/childVerdict.ts`); the code and the exit are on no other line. if (process.env.__EXPO_AGENT_CLI_DETACHED === '1') { - warn(formatErrorCodeRow(code, exitCode)); + warn(formatErrorCodeRow(code, exitCode, data ?? null)); } // @ref llp/0010-agent-conventions.rfc.md §The `--json` error envelope — a run asked for // machine-readable output gets one, whether it succeeded or not. The prose above stays on From fc4bd363ac8af4973a27c3cc618ab55c52ceb049 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 14:28:48 +0200 Subject: [PATCH 06/17] =?UTF-8?q?[agent-cli]=20a=20created=20simulator=20i?= =?UTF-8?q?s=20a=20mainstream=20iPhone=20on=20the=20newest=20runtime=20tha?= =?UTF-8?q?t=20lists=20one,=20not=20the=20newest=20runtime's=20only=20iPho?= =?UTF-8?q?ne=20(llp/0030=20=C2=A7Choice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 27.1 lists iPhone Duo alone, and no app rendered on it. The choice now walks the available runtimes newest first and takes the first with an `iPhone Pro`, else an `iPhone `; only when no runtime has one does the newest runtime's first iPhone apply. The RFC's §Choice sentence says so, and §Risks gains the observation. Co-Authored-By: Claude Fable 5.1 --- .../llp/0035-one-device-per-worktree.rfc.md | 3 +- .../src/deviceBinding/__tests__/ios-test.ts | 72 +++++++++++++++++++ .../@expo/agent-cli/src/deviceBinding/ios.ts | 39 +++++++--- 3 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index f42c34c1..9d3a115e 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -195,7 +195,7 @@ export type ReleasedDevice = { ## Choice (`choose.ts`, pure) -Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`, the highest available iOS runtime that lists an iPhone and its first iPhone device type. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs: +Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`: the newest available iOS runtime that lists a mainstream iPhone, with its first `iPhone Pro`, else its first `iPhone `, because the newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone); only when no runtime has one does the newest runtime's first iPhone type apply. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs: ```ts | { kind: 'reuse'; binding } // own binding, live or expired, device present (iOS: listed; Android spawned: emulatorPid alive; Android explicit: serial listed); also --device naming the own device @@ -333,6 +333,7 @@ Each accepted race costs one rerun of `dev`, never a lost build or a touched hum 12. Shutdowns run after the section, so a release or a reap can shut down a simulator that a later section reused in the same seconds; that `dev` fails its boot or install and is rerun. A sleep over 60 min with a server that `start` runs loses the device, and the next `dev --detach` rebinds it. 13. Without a cap, one parked simulator per living worktree stays on disk, each with its installed build. `git worktree remove` reclaims it on the next reap. 14. A worktree created at a deleted worktree's path has the same digest and inherits its bindings, devices and cloud session, with the old build installed; the fingerprint decides whether that build is current, as after a branch switch. The bindings of an unmounted volume are stale, so its simulator is deleted and its emulator killed; a rebuild on the next `dev` is the cost. +15. The newest installed iOS runtime may list only a device no app renders on; the choice prefers a mainstream iPhone across runtimes [observed — iOS 27.1 lists only iPhone Duo, 2026-10-08]. ## Open questions diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts new file mode 100644 index 00000000..06e6a4e4 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts @@ -0,0 +1,72 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §Choice +// Which runtime and device type a created simulator gets, out of `simctl list runtimes -j`. +import { parseNewestIosRuntime } from '../ios'; + +const type = (name: string) => ({ + identifier: `com.apple.CoreSimulator.SimDeviceType.${name.replace(/ /g, '-')}`, + name, + productFamily: 'iPhone', +}); + +const runtime = (version: string, names: string[], available = true) => ({ + identifier: `com.apple.CoreSimulator.SimRuntime.iOS-${version.replace('.', '-')}`, + name: `iOS ${version}`, + version, + platform: 'iOS', + isAvailable: available, + supportedDeviceTypes: names.map(type), +}); + +const listing = (...runtimes: unknown[]) => JSON.stringify({ runtimes }); + +describe(parseNewestIosRuntime, () => { + // iOS 27.1 lists iPhone Duo alone, and no app renders on it [observed — 2026-10-08]. + it(`skips a newer runtime with no mainstream iPhone for an older one that has one`, () => { + expect( + parseNewestIosRuntime( + listing(runtime('26.4', ['iPhone 17 Pro', 'iPhone 17']), runtime('27.1', ['iPhone Duo'])) + ) + ).toEqual({ + identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro', + }); + }); + + it(`takes the Pro over an e model listed first, and the plain model when there is no Pro`, () => { + expect( + parseNewestIosRuntime(listing(runtime('26.4', ['iPhone 17e', 'iPhone 17 Pro', 'iPhone 17']))) + ).toMatchObject({ deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro' }); + expect( + parseNewestIosRuntime(listing(runtime('26.4', ['iPhone 17e', 'iPhone Air', 'iPhone 17']))) + ).toMatchObject({ deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17' }); + }); + + it(`falls back to the newest runtime's first iPhone when no runtime has a mainstream one`, () => { + expect( + parseNewestIosRuntime( + listing(runtime('26.4', ['iPhone Air']), runtime('27.1', ['iPhone Duo'])) + ) + ).toEqual({ + identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-27-1', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-Duo', + }); + }); + + it(`ignores unavailable runtimes, other platforms, and runtimes with no iPhone`, () => { + expect( + parseNewestIosRuntime( + listing( + runtime('27.1', ['iPhone 18 Pro'], false), + { ...runtime('26.4', ['iPhone 17 Pro']), platform: 'watchOS' }, + { + ...runtime('26.0', []), + supportedDeviceTypes: [{ ...type('iPad'), productFamily: 'iPad' }], + }, + runtime('25.0', ['iPhone 16']) + ) + ) + ).toMatchObject({ identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-25-0' }); + expect(parseNewestIosRuntime(listing())).toBeNull(); + expect(parseNewestIosRuntime('not json')).toBeNull(); + }); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/ios.ts b/packages/@expo/agent-cli/src/deviceBinding/ios.ts index 3d73e8e1..58c37e15 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/ios.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/ios.ts @@ -32,7 +32,7 @@ export function parseNewestIosRuntime(stdout: string): IosInventory['newestIosRu platform?: string; version?: string; isAvailable?: boolean; - supportedDeviceTypes?: { identifier?: string; productFamily?: string }[]; + supportedDeviceTypes?: { identifier?: string; name?: string; productFamily?: string }[]; }[]; }; try { @@ -40,19 +40,42 @@ export function parseNewestIosRuntime(stdout: string): IosInventory['newestIosRu } catch { return null; } - const candidates = (parsed.runtimes ?? []) + const runtimes = (parsed.runtimes ?? []) .filter((runtime) => runtime.platform === 'iOS' && runtime.isAvailable !== false) .map((runtime) => ({ identifier: runtime.identifier ?? '', version: (runtime.version ?? '').split('.').map(Number), - deviceType: - runtime.supportedDeviceTypes?.find((type) => type.productFamily === 'iPhone')?.identifier ?? - null, + iphones: (runtime.supportedDeviceTypes ?? []).filter( + (type): type is { identifier: string; name?: string; productFamily?: string } => + type.productFamily === 'iPhone' && typeof type.identifier === 'string' + ), })) - .filter((runtime) => runtime.identifier && runtime.deviceType != null) + .filter((runtime) => runtime.identifier && runtime.iphones.length > 0) .sort((left, right) => compareVersions(right.version, left.version)); - const newest = candidates[0]; - return newest ? { identifier: newest.identifier, deviceType: newest.deviceType! } : null; + // The newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone), + // so a mainstream iPhone on an older runtime beats it; the newest's first iPhone is the last + // resort. + for (const runtime of runtimes) { + const mainstream = mainstreamIphone(runtime.iphones); + if (mainstream) { + return { identifier: runtime.identifier, deviceType: mainstream }; + } + } + const newest = runtimes[0]; + return newest + ? { identifier: newest.identifier, deviceType: newest.iphones[0]!.identifier } + : null; +} + +/** An `iPhone Pro`, else an `iPhone `, by the name `simctl` lists the type under. */ +function mainstreamIphone(types: { identifier: string; name?: string }[]): string | null { + for (const pattern of [/^iPhone \d+ Pro$/, /^iPhone \d+$/]) { + const match = types.find((type) => pattern.test(type.name ?? '')); + if (match) { + return match.identifier; + } + } + return null; } function compareVersions(left: number[], right: number[]): number { From b361ac0d685102716f1531fa019e7038b5408688 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 15:14:23 +0200 Subject: [PATCH 07/17] =?UTF-8?q?[agent-cli]=20dev=20--ios=20binds=20nothi?= =?UTF-8?q?ng=20off=20macOS,=20and=20the=20module's=20tests=20pass=20on=20?= =?UTF-8?q?Windows=20(llp/0031=20=C2=A7How=20`dev`=20uses=20it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-cloud suite runs dev --ios --no-open on a Linux worker to serve for an EAS session it opens itself; the acquire ran xcrun there and failed. iOS simulators exist on macOS only, so the callback binds nothing elsewhere, as on main. On Windows the function-length guard split on LF and never saw a lone closing brace in a CRLF checkout, and the binding's project root is the canonical path, which is a drive path there. Co-Authored-By: Claude Fable 5.1 --- .../llp/0035-one-device-per-worktree.rfc.md | 4 ++-- .../agent-cli/src/dev/__tests__/devAsync-test.ts | 15 +++++++++++++++ packages/@expo/agent-cli/src/dev/devAsync.ts | 13 ++++++++++--- .../src/deviceBinding/__tests__/acquire-test.ts | 5 ++++- .../src/deviceBinding/__tests__/boundary-test.ts | 4 ++-- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 9d3a115e..6756a328 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -211,7 +211,7 @@ Inputs: the own binding and the other worktrees' bindings, each marked stale or ## Entry points -**`acquireDeviceAsync(root, platform, { explicit?, reuseOnly?, tools? }) → AcquireResult`**, throws `DEVICE_UNAVAILABLE` with `data.reason`. For `dev` and `smoke` in bootstrap mode; both skip it under `AGENT_CLI_NO_DEVICE`. Read the platform's inventory; a device tool that cannot run is main's `toolError`. Under the lock, in one section: read the bindings, choose, write, reap; a refusal is thrown after the reap, so a deleted worktree's device does not block a rerun, and the device actions of the section's let-go rules run in a `finally` before the throw, so a reaped emulator is never left running without a file. After the section: boot and verify on every choice, whatever the inventory said (iOS: `simctl bootstatus -b`, which boots only when the simulator is not `Booted` and then waits, because `simctl boot` on a `Booted` simulator exits non-zero; Android: `sys.boot_completed`), with `BOOT_DEVICE_TIMEOUT_MS` per platform. A failed boot lets go of the binding; a spawn whose binding was never written kills its child through its handle. The caller prints `action` as one line on stderr. +**`acquireDeviceAsync(root, platform, { explicit?, reuseOnly?, tools? }) → AcquireResult`**, throws `DEVICE_UNAVAILABLE` with `data.reason`. For `dev` and `smoke` in bootstrap mode; both skip it under `AGENT_CLI_NO_DEVICE`, and `dev` skips it on a host that cannot run the platform's local device (iOS off macOS), where the run serves for a device elsewhere and binds nothing, as on main. Read the platform's inventory; a device tool that cannot run is main's `toolError`. Under the lock, in one section: read the bindings, choose, write, reap; a refusal is thrown after the reap, so a deleted worktree's device does not block a rerun, and the device actions of the section's let-go rules run in a `finally` before the throw, so a reaped emulator is never left running without a file. After the section: boot and verify on every choice, whatever the inventory said (iOS: `simctl bootstatus -b`, which boots only when the simulator is not `Booted` and then waits, because `simctl boot` on a `Booted` simulator exits non-zero; Android: `sys.boot_completed`), with `BOOT_DEVICE_TIMEOUT_MS` per platform. A failed boot lets go of the binding; a spawn whose binding was never written kills its child through its handle. The caller prints `action` as one line on stderr. | Choice | Write under the lock | | --------------- | ---------------------------------------------------------------------------------------- | @@ -409,7 +409,7 @@ Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` a - The `--plan` reason "will reuse or create a simulator" is added only for `--ios` without `--eas` and with devices on, so a stubbed harness's plans are unchanged. - `smoke`'s iOS boot skips under `AGENT_CLI_NO_DEVICE` and reports a failed boot phase; its install phase honours the boot's `installNeeded`, because a created simulator's disk holds nothing to read. - `withDevice(plan, device)` runs after `withForwardedExpoArgs` and before the `--port` block. It sets `--device ` on every `run` and `install` step and replaces any `--device` there; on a plan with neither it is a no-op. `AppPresenceProbe.installDevice` goes for iOS. `devAsync` passes `acquired.device` through `executePlanAsync` to `openAppForRunAsync`'s local branch, which hands it to `openAppOnDeviceAsync`; its iOS probe and boot go, and it passes the device to `openRouteAsync` through a new `device?: BoundDevice` option, which skips `resolveDeviceAsync`, so the open inspects nothing twice. `report.booted` is `acquired.justBooted`. -- The callback runs for iOS only. Android keeps main's probe-and-boot path, its first-adb rung, its `probeLocalDeviceAsync` branch and its `installDevice` until 3b. `acquire` is skipped for `--eas`. +- The callback runs for iOS only, and on macOS only; on another host `dev --ios` binds nothing and the plan is unprobed, which is what the live-cloud suite's Linux worker relies on. Android keeps main's probe-and-boot path, its first-adb rung, its `probeLocalDeviceAsync` branch and its `installDevice` until 3b. `acquire` is skipped for `--eas`. - `dev --plan` passes a callback that inspects the own binding and returns it as `reused` with `justBooted: false` when the state is `up` or `not-up`; otherwise null. For a building draft `devAsync` calls that same callback itself and pins the plan, with no probe. When the callback returned null, `devAsync` leaves the step unpinned and adds the reason "will reuse or create a simulator" to the plan, building or not. - `dev --detach`: the child acquires and never releases on exit. `devDetachAsync` gains an option `reuseBoundDevice`, default false, that `devAsync` passes as true and `smoke` leaves unset. With it, when the server is already running, `devDetachAsync` calls `acquireDeviceAsync` with `reuseOnly` before `reportDetached` prints, for `--ios` without `--eas` and without `AGENT_CLI_NO_DEVICE`. A parked own simulator is booted, the boot is waited for, and the `action` line is printed on stderr before the report. Any other choice refuses `not-reusable` (exit 20), because a device created here would carry no app. The child's `DEVICE_*` and `NO_BOUND_DEVICE` errors are written to the child verdict and the parent rethrows them unchanged, per §Output and errors; `detachFailureError` keeps `DEV_DETACH_DIED` for everything else. diff --git a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts index 748de743..7bb2653d 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts @@ -1408,6 +1408,21 @@ describe(devAsync, () => { vi.mocked(devicesDisabled).mockReturnValue(true); }); + // non-darwin-host-binds-nothing + it(`acquires nothing for --ios on a host with no simulators, and pins no step`, async () => { + mockStaleDevClientState(); + mockPlatform('linux'); + try { + await devAsync(projectRoot, resolveDevOptions(['--ios', '--local'])); + } finally { + mockPlatform(realPlatform); + } + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + for (const [, args] of vi.mocked(runDevServerAsync).mock.calls) { + expect(args).not.toContain('--device'); + } + }); + // withDevice-after-forwarded-args it(`binds after the refusals and pins the build step to the simulator, over a forwarded --device`, async () => { mockStaleDevClientState(); diff --git a/packages/@expo/agent-cli/src/dev/devAsync.ts b/packages/@expo/agent-cli/src/dev/devAsync.ts index f700cd2c..e6f8905c 100644 --- a/packages/@expo/agent-cli/src/dev/devAsync.ts +++ b/packages/@expo/agent-cli/src/dev/devAsync.ts @@ -275,8 +275,8 @@ export async function devAsync(projectRoot: string, options: DevOptions): Promis * The callback the resolver runs for a native draft, and `devAsync` runs for a draft that builds. * * @ref llp/0035-one-device-per-worktree.rfc.md §How `dev` uses it - * iOS only, until llp/0035 §Android instance binds Android; nothing for `--eas`, whose device is a session; nothing - * under `AGENT_CLI_NO_DEVICE`. A run first refuses a named `--port` that is taken, when the draft + * iOS only, until llp/0035 §Android instance binds Android, and on macOS only; nothing for `--eas`, whose device is + * a session; nothing under `AGENT_CLI_NO_DEVICE`. A run first refuses a named `--port` that is taken, when the draft * serves, so no simulator is created for a run that then stops on the port. `--plan` inspects the * own binding and acquires nothing. */ @@ -304,7 +304,14 @@ function deviceCallbackFor( } function bindsDevice(options: DevOptions): boolean { - return options.platform === 'ios' && options.deviceBackend !== 'eas' && !devicesDisabled(); + // iOS simulators exist on macOS only; elsewhere `dev --ios` serves for a device somewhere else, + // an EAS session the caller opens itself, and binds nothing, as on main. + return ( + options.platform === 'ios' && + process.platform === 'darwin' && + options.deviceBackend !== 'eas' && + !devicesDisabled() + ); } /** `--plan`'s read of the own binding: the device as `reused` when it is up or down, else null. */ diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts index 47509994..dc375476 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts @@ -1,6 +1,7 @@ // @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests import { vol } from 'memfs'; +import { canonicalizeExistingPath } from '../../utils/dir'; import { acquireDeviceAsync, releaseWorktreeDevicesAsync } from '..'; import { bindingPathFor, readBindingFile } from '../registry'; import { bindingFor, fakeTools, writeBinding } from './fakeTools'; @@ -30,7 +31,9 @@ describe(acquireDeviceAsync, () => { ]); const read = readBindingFile(file()); expect(read.kind === 'binding' && read.binding.device).toMatchObject({ udid: 'CREATED-1' }); - expect(read.kind === 'binding' && read.binding.projectRoot).toBe(ROOT); + expect(read.kind === 'binding' && read.binding.projectRoot).toBe( + canonicalizeExistingPath(ROOT) + ); }); // bootstatus-b-not-boot diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts index e0a42029..c332e9d8 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/boundary-test.ts @@ -32,7 +32,7 @@ describe('src/deviceBinding/', () => { it(`keeps every file under ${MAX_FILE_LINES} lines`, () => { const over = sources - .map(({ name, text }) => ({ name, lines: text.split('\n').length })) + .map(({ name, text }) => ({ name, lines: text.split(/\r?\n/).length })) .filter(({ lines }) => lines >= MAX_FILE_LINES); expect(over).toEqual([]); }); @@ -40,7 +40,7 @@ describe('src/deviceBinding/', () => { it(`keeps every function under ${MAX_FUNCTION_LINES} lines`, () => { const over: string[] = []; for (const { name, text } of sources) { - const lines = text.split('\n'); + const lines = text.split(/\r?\n/); lines.forEach((line, index) => { const match = /^(?:export )?(?:async )?function (\w+)/.exec(line); if (!match) { From b91d87d5e197013dd7915246dac81bdadab95e23 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 8 Oct 2026 15:40:43 +0200 Subject: [PATCH 08/17] =?UTF-8?q?[agent-cli]=20a=20bound=20simulator=20who?= =?UTF-8?q?se=20runtime=20is=20gone=20counts=20as=20gone,=20so=20dev=20cre?= =?UTF-8?q?ates=20on=20a=20runtime=20that=20works=20(llp/0030=20=C2=A7Choi?= =?UTF-8?q?ce)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simctl keeps listing such a simulator with isAvailable false. The reuse took it, bootstatus failed, the failed boot kept the binding, and every retry picked it again. The choice and the listing now read an unavailable simulator as missing: the binding is removed and a new simulator is created, and read verbs report gone. Co-Authored-By: Claude Fable 5.1 --- .../llp/0035-one-device-per-worktree.rfc.md | 4 ++-- .../src/deviceBinding/__tests__/acquire-test.ts | 14 ++++++++++++++ .../src/deviceBinding/__tests__/fakeTools.ts | 2 ++ .../src/deviceBinding/__tests__/inspect-test.ts | 17 ++++++++++++++++- .../@expo/agent-cli/src/deviceBinding/choose.ts | 9 ++++++--- .../@expo/agent-cli/src/deviceBinding/ios.ts | 5 ++++- 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 6756a328..594e5820 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -166,7 +166,7 @@ export type ReleasedDevice = { - Origins. `created`: the CLI may shut down, park and delete. `spawned`: the CLI may kill the emulator it spawned. `explicit`: boot at most. `cloud` `started`: this CLI started the session or found it by tag, and may stop it; `dotenv`: adopted from `.env.eas-simulator`; a reap never stops it, `dev:stop --eas` does, as today. - Let go, one rule per origin, first match. The file action runs under the lock, the device action after it. - - A local binding whose device the inventory does not list, or a `spawned` one whose `emulatorPid` is dead: remove the file. + - A local binding whose device the inventory does not list or lists as unavailable (its runtime was removed), or a `spawned` one whose `emulatorPid` is dead: remove the file. - `created` of a deleted root: remove the file, then `simctl delete`, where "not found" counts as success. - `created`: park, then `simctl shutdown`, where an already shut-down simulator counts as success. A reap skips the shutdown when its inventory listed `Shutdown`. - `spawned`: remove the file, then kill `emulatorPid` while it is alive and, where `ps` exists, its arguments name `-avd ` and `-ports ,`. Never the binary name: the `emulator` launcher execs `qemu-system-`, so the pid stays and the name does not. @@ -198,7 +198,7 @@ export type ReleasedDevice = { Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`: the newest available iOS runtime that lists a mainstream iPhone, with its first `iPhone Pro`, else its first `iPhone `, because the newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone); only when no runtime has one does the newest runtime's first iPhone type apply. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs: ```ts -| { kind: 'reuse'; binding } // own binding, live or expired, device present (iOS: listed; Android spawned: emulatorPid alive; Android explicit: serial listed); also --device naming the own device +| { kind: 'reuse'; binding } // own binding, live or expired, device present (iOS: listed and available; Android spawned: emulatorPid alive; Android explicit: serial listed); also --device naming the own device | { kind: 'ios-create'; runtime; deviceType; name } | { kind: 'android-spawn'; port; avd } | { kind: 'explicit'; device } diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts index dc375476..d9cd36a3 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts @@ -108,6 +108,20 @@ describe(acquireDeviceAsync, () => { expect(result.device).toMatchObject({ udid: 'CREATED-1' }); }); + // unavailable-simulator-is-gone + it('creates again when the own simulator lost its runtime, instead of retrying it forever', async () => { + const tools = fakeTools({ + simulators: [{ udid: 'OLD', name: 'agent-cli 0000', state: 'Shutdown', isAvailable: false }], + }); + writeBinding(file(), bindingFor(ROOT, 'OLD')); + + const result = await acquireDeviceAsync(ROOT, 'ios', { tools }); + + expect(result).toMatchObject({ action: 'created' }); + expect(result.device).toMatchObject({ udid: 'CREATED-1' }); + expect(tools.calls).not.toContainEqual(['bootstatus', 'OLD', '-b']); + }); + it('removes an own binding whose simulator is gone and creates again', async () => { const tools = fakeTools(); writeBinding(file(), bindingFor(ROOT, 'GONE')); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts index bed8687a..84bcb24a 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/fakeTools.ts @@ -15,6 +15,8 @@ export interface FakeSimulator { udid: string; name: string; state: 'Booted' | 'Shutdown'; + /** False when the runtime is gone, as `simctl` lists it then. */ + isAvailable?: boolean; } export interface FakeSimctlOptions { diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts index dcd4cf35..5cc9c76a 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts @@ -36,9 +36,24 @@ describe(inspectBindingAsync, () => { ['up', { bind: 'SIM-1', listed: 'Booted' }, { state: 'up' }], ['not-up', { bind: 'SIM-1', listed: 'Shutdown' }, { state: 'not-up' }], ['gone when not listed', { bind: 'SIM-1' }, { state: 'gone', cause: 'device-gone' }], + [ + 'gone when listed unavailable', + { bind: 'SIM-1', listed: 'Shutdown', unavailable: true }, + { state: 'gone', cause: 'device-gone' }, + ], ] as const)('reports %s', async (_name, setup, expected) => { const tools = fakeTools({ - simulators: 'listed' in setup ? [{ udid: 'SIM-1', name: 'x', state: setup.listed }] : [], + simulators: + 'listed' in setup + ? [ + { + udid: 'SIM-1', + name: 'x', + state: setup.listed, + isAvailable: !('unavailable' in setup), + }, + ] + : [], spawnError: 'spawnError' in setup, listTimesOut: 'listTimesOut' in setup, }); diff --git a/packages/@expo/agent-cli/src/deviceBinding/choose.ts b/packages/@expo/agent-cli/src/deviceBinding/choose.ts index d704877a..9ea5405c 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/choose.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/choose.ts @@ -23,8 +23,9 @@ export function createdSimulatorName(digest: string): string { /** * Choose the simulator of a worktree. * - * The own binding is reused, live or expired, when the inventory lists its simulator. An own - * binding whose simulator is gone is for the caller to remove; the choice goes on to a create. + * The own binding is reused, live or expired, when the inventory lists its simulator as available. + * An own binding whose simulator is gone or unavailable is for the caller to remove; the choice goes + * on to a create. */ export function chooseIosDevice({ own, @@ -38,7 +39,9 @@ export function chooseIosDevice({ digest: string; }): IosChoice { const ownUdid = own?.device.backend === 'local-ios' ? own.device.udid : null; - const listed = inventory.simulators.find((entry) => entry.udid === ownUdid); + // A simulator whose runtime was removed is still listed, unavailable; reusing it fails every + // boot, and a failed boot keeps the binding, so it counts as gone. + const listed = inventory.simulators.find((entry) => entry.udid === ownUdid && entry.isAvailable); if (own != null && listed != null) { return { kind: 'reuse', binding: own, listed }; } diff --git a/packages/@expo/agent-cli/src/deviceBinding/ios.ts b/packages/@expo/agent-cli/src/deviceBinding/ios.ts index 58c37e15..46973730 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/ios.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/ios.ts @@ -174,7 +174,10 @@ export async function listSimulatorAsync( }; } const entry = parseSimulators(listed.stdout).find((simulator) => simulator.udid === udid); - return entry ? { kind: 'listed', state: entry.state, name: entry.name } : { kind: 'missing' }; + // Unavailable (its runtime is gone) reads as missing: nothing can boot it. + return entry?.isAvailable + ? { kind: 'listed', state: entry.state, name: entry.name } + : { kind: 'missing' }; } /** From b048411a90b04a694408a5ab7d3fdc1fc8b88ef0 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Fri, 9 Oct 2026 09:58:26 +0200 Subject: [PATCH 09/17] test(agent-cli): model the iOS host in device binding cases --- .../src/dev/__tests__/devAsync-test.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts index 7bb2653d..436a2bb2 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/devAsync-test.ts @@ -1391,6 +1391,8 @@ describe(devAsync, () => { }; beforeEach(() => { + // These cases model an iOS simulator host, regardless of the machine running the suite. + mockPlatform('darwin'); vi.mocked(devicesDisabled).mockReturnValue(false); vi.mocked(acquireDeviceAsync).mockResolvedValue({ device, @@ -1409,19 +1411,22 @@ describe(devAsync, () => { }); // non-darwin-host-binds-nothing - it(`acquires nothing for --ios on a host with no simulators, and pins no step`, async () => { - mockStaleDevClientState(); - mockPlatform('linux'); - try { - await devAsync(projectRoot, resolveDevOptions(['--ios', '--local'])); - } finally { - mockPlatform(realPlatform); - } - expect(acquireDeviceAsync).not.toHaveBeenCalled(); - for (const [, args] of vi.mocked(runDevServerAsync).mock.calls) { - expect(args).not.toContain('--device'); + it.each(['linux', 'win32'] as const)( + `acquires nothing for --ios on %s, and pins no step`, + async (platform) => { + mockStaleDevClientState(); + mockPlatform(platform); + try { + await devAsync(projectRoot, resolveDevOptions(['--ios', '--local'])); + } finally { + mockPlatform(realPlatform); + } + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + for (const [, args] of vi.mocked(runDevServerAsync).mock.calls) { + expect(args).not.toContain('--device'); + } } - }); + ); // withDevice-after-forwarded-args it(`binds after the refusals and pins the build step to the simulator, over a forwarded --device`, async () => { From d03a59ecf841ad4e74f5d5ac1d88adec0b512d5a Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Mon, 12 Oct 2026 20:18:48 +0200 Subject: [PATCH 10/17] fix(agent-cli): revalidate iOS inventory after concurrent binding changes --- .../llp/0035-one-device-per-worktree.rfc.md | 7 +- .../__tests__/acquireConcurrency-test.ts | 198 ++++++++++++++++++ .../deviceBinding/__tests__/errors-test.ts | 6 + .../agent-cli/src/deviceBinding/errors.ts | 8 +- .../agent-cli/src/deviceBinding/index.ts | 42 +++- 5 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 packages/@expo/agent-cli/src/deviceBinding/__tests__/acquireConcurrency-test.ts diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 594e5820..5d7848cc 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -75,7 +75,7 @@ The claimants of a device decide where its ownership record must live. A local d ## Invariant -A created simulator boots, shuts down or is deleted, and a spawned emulator is killed, only after a section that wrote or removed its binding for that action. A child whose binding was never written is killed through its own process handle. The inventory is read before the lock and may be seconds stale; that is safe because every state change of a CLI device goes through a binding, and bindings are read under the lock. +A created simulator boots, shuts down or is deleted, and a spawned emulator is killed, only after a section that wrote or removed its binding for that action. A child whose binding was never written is killed through its own process handle. The inventory is read before the lock and may be seconds stale; acquisition records the binding identity before reading it, then revalidates that identity under the lock before choosing a device. A changed identity leaves the registry untouched and retries with a new inventory outside the lock. Identity is absent or `(backend, device id)`; lease timestamps and names do not invalidate a snapshot. There are three attempts total (the initial snapshot and two refreshes), each retaining the existing inventory and lock timeouts. Exhaustion refuses `DEVICE_UNAVAILABLE` reason `binding-changed`, exit 22, and asks for a rerun. ## Contracts every verb honors @@ -289,6 +289,7 @@ Each reaped device is reported on stderr and in JSON with reason `expired`, `del | `DEVICE_UNAVAILABLE` | `own-explicit-over-owned` | 1 | "this worktree runs the emulator instance " | ` dev:stop --release` | | `DEVICE_UNAVAILABLE` | `not-reusable` | 20 | "a dev server is running and this worktree has no device to reuse" | ` dev:stop` | | `DEVICE_UNAVAILABLE` | `create-timeout` | 22 | "the device tool did not answer in time" | run the command again | +| `DEVICE_UNAVAILABLE` | `binding-changed` | 22 | "the binding changed during all three inventory attempts" | run the command again | | `DEVICE_UNAVAILABLE` | `spawn-failed` | 22 | the spawn error | run the command again | | `DEVICE_UNAVAILABLE` | `boot-failed` | 20 | the boot's reason; the binding is let go of first | run the command again | | `DEVICE_REGISTRY_LOCKED` | `locked` | 22 | "pid N () has held the device registry for " | run the command again, or `kill ` when it repeats | @@ -381,7 +382,7 @@ Module first (Decision 8). The first commits hold `src/deviceBinding/` and its u ### The module -Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` and `ownCloudIdsAsync`. The let-go rule and `releaseWorktreeDevicesAsync` ship here, because a failed boot needs them; their only caller in 3a is the failed boot. The reap step of `acquireDeviceAsync` is a no-op until §Device lifecycle. `createSimulatorAsync` runs `simctl create` under the lock per §Lock. Two tests guard the shape: the boundary test (Contract 10) and a subprocess-count test (one subprocess per binding a read verb inspects). +Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` and `ownCloudIdsAsync`. The let-go rule and `releaseWorktreeDevicesAsync` ship here, because a failed boot needs them; their only caller in 3a is the failed boot. The reap step of `acquireDeviceAsync` is a no-op until §Device lifecycle. `createSimulatorAsync` runs `simctl create` under the lock per §Lock. Acquisition snapshots the binding identity before inventory and checks it again under the lock. A mismatch retries without writes, outside the lock, for at most three attempts; lease and name changes are ignored. Exhaustion is `DEVICE_UNAVAILABLE` reason `binding-changed`, exit 22. Two tests guard the shape: the boundary test (Contract 10) and a subprocess-count test (one subprocess per binding a read verb inspects). ### How `dev` uses it @@ -433,6 +434,8 @@ The agent guide per §Output and errors. The RFC's §Readers and §Entry points ### iOS tests +- Acquisition concurrency uses real temporary storage (memfs does not model directory-lock rename semantics) and barriers before either initial snapshot advances. Empty, missing and unavailable bindings create exactly one replacement; `reuseOnly` refreshes without creating; lease/name changes do not refresh; inventory failure and three changed snapshots preserve the newer binding. Device reads and boots stay outside the lock. + - Unit, named: `boot-always-runs`, `failed-boot-lets-go`, `only-create-and-spawn-under-lock`, `agent-cli-name-needs-binding`, `expo-go-and-bare-drafts-acquire`, `created-device-missing-without-app-id`, `withDevice-after-forwarded-args`, `already-running-reuses-only`, `smoke-never-reaches-already-running-reuse`, `smoke-acquires-without-bootDevice`, `smoke-registers-no-shutdown`, `created-device-plans-install`, `null-device-ios-unprobed`, `inspect-state-order`, `any-up-wins-across-platforms`, `bootstatus-b-not-boot`, `detached-device-error-relayed`, `fallback-eas-rung-dotenv-only`, `expired-is-gone`, `platform-flag-throws-tool-error-first`, `read-verb-one-subprocess-per-binding`, `rung-refusal-reports-first-state`, `probe-local-combines-platforms`, `deviceBinding-imports-nothing-from-verbs`, `exit-codes-per-reason`. - E2E: `bindingFixture` (`created`, `expiresAt` 60 min ahead, canonical root, macOS only) seeded in the cases that hand a verb a booted simulator. A two-worktree case asserts each `run:ios --device` equals its registry id and the two ids are distinct `E2E-CREATED-*` udids, and set equality of `simctl bootstatus -b` calls. - Live: `navigate` wall time before and after on a machine with ten simulators, warm and cold, with `simctl list devices -j` and the booted-only list timed side by side. A cold `simctl create` is timed to confirm the 20 s timeout (Risk 5). diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquireConcurrency-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquireConcurrency-test.ts new file mode 100644 index 00000000..f51d49e1 --- /dev/null +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquireConcurrency-test.ts @@ -0,0 +1,198 @@ +// @ref llp/0035-one-device-per-worktree.rfc.md §iOS tests +import fs from 'fs'; +import path from 'path'; + +import { acquireDeviceAsync } from '..'; +import { + bindingPathFor, + readBindingFile, + withRegistryLockAsync, + writeBindingFile, +} from '../registry'; +import { bindingFor, fakeTools, type FakeTools } from './fakeTools'; + +// memfs permits replacing a non-empty directory; the registry lock needs real rename semantics. +vi.unmock('fs'); +vi.unmock('node:fs'); +const os = await vi.importActual('os'); +let directory: string; +let root: string; +const file = () => bindingPathFor(root, 'ios', 'local-ios'); + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cli-acquire-')); + root = path.join(directory, 'app'); + fs.mkdirSync(root); + vi.stubEnv('__UNSAFE_EXPO_HOME_DIRECTORY', path.join(directory, 'expo-home')); +}); +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(directory, { recursive: true, force: true }); +}); + +function signal() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** Hold the first n device snapshots until the test lets both callers advance. */ +function pauseSnapshots(tools: FakeTools, count: number) { + const ready = signal(); + const proceed = signal(); + const simctl = tools.simctl; + let snapshots = 0; + tools.simctl = async (args, options) => { + const result = await simctl(args, options); + if (args[0] === 'list' && args[1] === 'devices' && ++snapshots <= count) { + if (snapshots === count) ready.resolve(); + await proceed.promise; + } + return result; + }; + return { ready: ready.promise, resume: proceed.resolve }; +} + +async function replaceBinding(tools: FakeTools, udid: string) { + await withRegistryLockAsync( + async () => { + writeBindingFile(file(), bindingFor(root, udid)); + }, + { tools, waitMs: 1_000 } + ); +} + +function expectDeviceCallsOutsideLock(tools: FakeTools) { + tools.calls.forEach((call, index) => { + expect(tools.lockedAt[index], call.join(' ')).toBe(call[0] === 'create'); + }); +} + +it.each(['unbound', 'missing', 'unavailable'] as const)( + 'creates only one simulator for concurrent acquisitions of an %s worktree', + async (initial) => { + const tools = fakeTools({ + simulators: + initial === 'unavailable' + ? [{ udid: 'OLD', name: 'old', state: 'Shutdown', isAvailable: false }] + : [], + }); + if (initial !== 'unbound') await replaceBinding(tools, 'OLD'); + const gate = pauseSnapshots(tools, 2); + const results = Promise.all([ + acquireDeviceAsync(root, 'ios', { tools }), + acquireDeviceAsync(root, 'ios', { tools }), + ]); + await gate.ready; + gate.resume(); + + const acquired = await results; + expect(acquired.map(({ action }) => action).sort()).toEqual(['created', 'reused']); + expect(acquired.map(({ device }) => device)).toEqual([ + expect.objectContaining({ udid: 'CREATED-1' }), + expect.objectContaining({ udid: 'CREATED-1' }), + ]); + expect(tools.calls.filter(([command]) => command === 'create')).toHaveLength(1); + expect(tools.calls.filter(([command]) => command === 'bootstatus')).toEqual([ + ['bootstatus', 'CREATED-1', '-b'], + ['bootstatus', 'CREATED-1', '-b'], + ]); + expect(readBindingFile(file())).toMatchObject({ binding: { device: { udid: 'CREATED-1' } } }); + expectDeviceCallsOutsideLock(tools); + } +); + +it('refreshes a stale snapshot for reuseOnly without creating another simulator', async () => { + const tools = fakeTools(); + const gate = pauseSnapshots(tools, 1); + const waiting = acquireDeviceAsync(root, 'ios', { tools, reuseOnly: true }).catch( + (error) => error + ); + await gate.ready; + await acquireDeviceAsync(root, 'ios', { tools }); + gate.resume(); + + expect(await waiting).toMatchObject({ action: 'reused', device: { udid: 'CREATED-1' } }); + expect(tools.calls.filter(([command]) => command === 'create')).toHaveLength(1); + expect(readBindingFile(file())).toMatchObject({ binding: { device: { udid: 'CREATED-1' } } }); + expectDeviceCallsOutsideLock(tools); +}); + +it('does not refresh for lease or name changes and uses the current binding', async () => { + const tools = fakeTools({ simulators: [{ udid: 'SIM', name: 'old', state: 'Booted' }] }); + await replaceBinding(tools, 'SIM'); + const gate = pauseSnapshots(tools, 1); + const waiting = acquireDeviceAsync(root, 'ios', { tools }); + await gate.ready; + await withRegistryLockAsync( + async () => { + writeBindingFile( + file(), + bindingFor(root, 'SIM', { + name: 'renamed', + boundAt: '2026-10-08T09:30:00.000Z', + expiresAt: '2026-10-08T12:00:00.000Z', + }) + ); + }, + { tools, waitMs: 1_000 } + ); + gate.resume(); + + expect(await waiting).toMatchObject({ + action: 'reused', + device: { udid: 'SIM', name: 'renamed' }, + }); + expect(tools.calls.filter(([command]) => command === 'list')).toHaveLength(2); +}); + +it('leaves the newer binding intact when refreshing inventory times out', async () => { + const tools = fakeTools(); + const gate = pauseSnapshots(tools, 1); + const waiting = acquireDeviceAsync(root, 'ios', { tools }).catch((error) => error); + await gate.ready; + await replaceBinding(tools, 'NEW'); + const newer = fs.readFileSync(file(), 'utf8'); + const simctl = tools.simctl; + tools.simctl = async (args, options) => + args[0] === 'list' && args[1] === 'devices' + ? { stdout: '', stderr: '', exitCode: null } + : simctl(args, options); + gate.resume(); + + expect(await waiting).toMatchObject({ + code: 'DEVICE_UNAVAILABLE', + data: { reason: 'create-timeout' }, + }); + expect(fs.readFileSync(file(), 'utf8')).toBe(newer); + expect(tools.calls.some(([command]) => command === 'create')).toBe(false); +}); + +it('stops after three changed snapshots without replacing the last binding', async () => { + const tools = fakeTools(); + const simctl = tools.simctl; + let snapshots = 0; + tools.simctl = async (args, options) => { + const result = await simctl(args, options); + if (args[0] === 'list' && args[1] === 'runtimes') { + await replaceBinding(tools, `NEW-${++snapshots}`); + } + return result; + }; + + const error = await acquireDeviceAsync(root, 'ios', { tools }).catch((error) => error); + expect(error).toMatchObject({ + code: 'DEVICE_UNAVAILABLE', + exitCode: 22, + data: { reason: 'binding-changed' }, + }); + expect(error.message).toContain('How: run the command again.'); + expect(snapshots).toBe(3); + expect(readBindingFile(file())).toEqual({ kind: 'binding', binding: bindingFor(root, 'NEW-3') }); + expect(tools.calls.some(([command]) => command === 'create' || command === 'bootstatus')).toBe( + false + ); + expectDeviceCallsOutsideLock(tools); +}); diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts index 8a4035c3..8e8cf3ef 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/errors-test.ts @@ -49,6 +49,12 @@ describe('exit codes per reason', () => { 20, deviceUnavailableError('boot-failed', { platform: 'ios' }), ], + [ + 'DEVICE_UNAVAILABLE', + 'binding-changed', + 22, + deviceUnavailableError('binding-changed', { platform: 'ios' }), + ], [ 'DEVICE_REGISTRY_LOCKED', 'locked', diff --git a/packages/@expo/agent-cli/src/deviceBinding/errors.ts b/packages/@expo/agent-cli/src/deviceBinding/errors.ts index b06e79d3..5a75d7ce 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/errors.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/errors.ts @@ -16,7 +16,8 @@ export type UnavailableReason = | 'no-ios-runtime' | 'not-reusable' | 'create-timeout' - | 'boot-failed'; + | 'boot-failed' + | 'binding-changed'; const RERUN = 'run the command again'; @@ -85,6 +86,11 @@ export function deviceUnavailableError( 'The device tool did not answer in time.', `How: ${RERUN}.`, ]); + case 'binding-changed': + return refusal('DEVICE_UNAVAILABLE', reason, EXIT_OUTCOME_TIMEOUT, [ + `This worktree's ${platform} binding changed during all three device inventory attempts.`, + `How: ${RERUN}.`, + ]); case 'boot-failed': return refusal('DEVICE_UNAVAILABLE', reason, EXIT_OUTCOME_FAILED, [ `The bound ${platform} device did not boot${detail ? `: ${detail}` : '.'}`, diff --git a/packages/@expo/agent-cli/src/deviceBinding/index.ts b/packages/@expo/agent-cli/src/deviceBinding/index.ts index ea64ad6a..631c768e 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/index.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/index.ts @@ -21,6 +21,7 @@ import { removeBindingFile, withRegistryLockAsync, writeBindingFile, + type BindingRead, } from './registry'; import { defaultTools } from './tools'; import { @@ -71,7 +72,8 @@ export interface AcquireDeviceOptions { * Bind this worktree's simulator and boot it. * * The inventory is read before the lock. Under the lock, in one section: the own binding is - * read, the choice made and written, with `simctl create` inside the section. After it, the boot + * revalidated against that snapshot, the choice made and written, with `simctl create` inside + * the section. A changed identity retries outside the lock, at most twice. After it, the boot * runs on every choice, whatever the inventory said. A failed boot lets go of the binding. * * @throws `DEVICE_UNAVAILABLE` with `data.reason`, `DEVICE_REGISTRY_LOCKED`, or the tool error. @@ -81,11 +83,7 @@ export async function acquireDeviceAsync( platform: 'ios', { reuseOnly = false, tools = defaultTools() }: AcquireDeviceOptions = {} ): Promise { - const inventory = await readIosInventoryAsync(tools); - const { binding, wasBooted, action } = await withRegistryLockAsync( - () => bindIosSectionAsync(projectRoot, { inventory, reuseOnly, tools }), - { waitMs: WRITE_LOCK_WAIT_MS, tools } - ); + const { binding, wasBooted, action } = await bindIosAsync(projectRoot, reuseOnly, tools); clearInspectCache(); const device = binding.device as Extract; @@ -99,20 +97,48 @@ export async function acquireDeviceAsync( return { device, justBooted: !wasBooted, action }; } +/** Each inventory belongs to the binding identity observed before the device list was read. */ +async function bindIosAsync(projectRoot: string, reuseOnly: boolean, tools: DeviceTools) { + const file = bindingPathFor(projectRoot, 'ios', 'local-ios'); + for (let attempt = 0; attempt < 3; attempt += 1) { + const before = readBindingFile(file); + const inventory = await readIosInventoryAsync(tools); + const bound = await withRegistryLockAsync( + () => bindIosSectionAsync(projectRoot, { inventory, before, reuseOnly, tools }), + { waitMs: WRITE_LOCK_WAIT_MS, tools } + ); + if (bound != null) { + return bound; + } + } + throw deviceUnavailableError('binding-changed', { platform: 'ios' }); +} + +/** Lease renewals and renames do not invalidate a device inventory. */ +function bindingIdentity(read: BindingRead): string { + return read.kind === 'binding' + ? `${read.binding.device.backend}:${deviceIdOf(read.binding.device)}` + : read.kind; +} + /** The section: read the own binding, choose, write. `simctl create` runs inside it. */ async function bindIosSectionAsync( projectRoot: string, { inventory, + before, reuseOnly, tools, - }: { inventory: IosInventory; reuseOnly: boolean; tools: DeviceTools } -): Promise<{ binding: Binding; wasBooted: boolean; action: AcquireAction }> { + }: { inventory: IosInventory; before: BindingRead; reuseOnly: boolean; tools: DeviceTools } +): Promise<{ binding: Binding; wasBooted: boolean; action: AcquireAction } | null> { const file = bindingPathFor(projectRoot, 'ios', 'local-ios'); const read = readBindingFile(file); if (read.kind === 'unreadable') { throw deviceUnavailableError('unreadable', { platform: 'ios', path: file }); } + if (bindingIdentity(read) !== bindingIdentity(before)) { + return null; + } const own = read.kind === 'binding' ? read.binding : null; const choice = chooseIosDevice({ own, inventory, reuseOnly, digest: digestForRoot(projectRoot) }); if (own != null && choice.kind !== 'reuse') { From 6fc83b1f744c9ac05e62d99df38c5d7ff68f0e42 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Mon, 12 Oct 2026 20:18:59 +0200 Subject: [PATCH 11/17] fix(agent-cli): guard detached iOS reuse on macOS --- .../llp/0035-one-device-per-worktree.rfc.md | 3 +- .../src/dev/__tests__/detachAsync-test.ts | 43 +++++++++++++++++++ .../@expo/agent-cli/src/dev/detachAsync.ts | 1 + 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 5d7848cc..3d15f4eb 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -412,7 +412,7 @@ Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` a - `withDevice(plan, device)` runs after `withForwardedExpoArgs` and before the `--port` block. It sets `--device ` on every `run` and `install` step and replaces any `--device` there; on a plan with neither it is a no-op. `AppPresenceProbe.installDevice` goes for iOS. `devAsync` passes `acquired.device` through `executePlanAsync` to `openAppForRunAsync`'s local branch, which hands it to `openAppOnDeviceAsync`; its iOS probe and boot go, and it passes the device to `openRouteAsync` through a new `device?: BoundDevice` option, which skips `resolveDeviceAsync`, so the open inspects nothing twice. `report.booted` is `acquired.justBooted`. - The callback runs for iOS only, and on macOS only; on another host `dev --ios` binds nothing and the plan is unprobed, which is what the live-cloud suite's Linux worker relies on. Android keeps main's probe-and-boot path, its first-adb rung, its `probeLocalDeviceAsync` branch and its `installDevice` until 3b. `acquire` is skipped for `--eas`. - `dev --plan` passes a callback that inspects the own binding and returns it as `reused` with `justBooted: false` when the state is `up` or `not-up`; otherwise null. For a building draft `devAsync` calls that same callback itself and pins the plan, with no probe. When the callback returned null, `devAsync` leaves the step unpinned and adds the reason "will reuse or create a simulator" to the plan, building or not. -- `dev --detach`: the child acquires and never releases on exit. `devDetachAsync` gains an option `reuseBoundDevice`, default false, that `devAsync` passes as true and `smoke` leaves unset. With it, when the server is already running, `devDetachAsync` calls `acquireDeviceAsync` with `reuseOnly` before `reportDetached` prints, for `--ios` without `--eas` and without `AGENT_CLI_NO_DEVICE`. A parked own simulator is booted, the boot is waited for, and the `action` line is printed on stderr before the report. Any other choice refuses `not-reusable` (exit 20), because a device created here would carry no app. The child's `DEVICE_*` and `NO_BOUND_DEVICE` errors are written to the child verdict and the parent rethrows them unchanged, per §Output and errors; `detachFailureError` keeps `DEV_DETACH_DIED` for everything else. +- `dev --detach`: the child acquires and never releases on exit. `devDetachAsync` gains an option `reuseBoundDevice`, default false, that `devAsync` passes as true and `smoke` leaves unset. With it, when the server is already running, `devDetachAsync` calls `acquireDeviceAsync` with `reuseOnly` before `reportDetached` prints, on macOS for `--ios` without `--eas` and without `AGENT_CLI_NO_DEVICE`. Linux and Windows reuse the server without acquiring an iOS simulator. A parked own simulator is booted, the boot is waited for, and the `action` line is printed on stderr before the report. Any other choice refuses `not-reusable` (exit 20), because a device created here would carry no app. The child's `DEVICE_*` and `NO_BOUND_DEVICE` errors are written to the child verdict and the parent rethrows them unchanged, per §Output and errors; `detachFailureError` keeps `DEV_DETACH_DIED` for everything else. ### iOS readers @@ -435,6 +435,7 @@ The agent guide per §Output and errors. The RFC's §Readers and §Entry points ### iOS tests - Acquisition concurrency uses real temporary storage (memfs does not model directory-lock rename semantics) and barriers before either initial snapshot advances. Empty, missing and unavailable bindings create exactly one replacement; `reuseOnly` refreshes without creating; lease/name changes do not refresh; inventory failure and three changed snapshots preserve the newer binding. Device reads and boots stay outside the lock. +- Detached reuse explicitly models macOS for acquisition tests and Linux/Windows for existing-server reuse without device acquisition. - Unit, named: `boot-always-runs`, `failed-boot-lets-go`, `only-create-and-spawn-under-lock`, `agent-cli-name-needs-binding`, `expo-go-and-bare-drafts-acquire`, `created-device-missing-without-app-id`, `withDevice-after-forwarded-args`, `already-running-reuses-only`, `smoke-never-reaches-already-running-reuse`, `smoke-acquires-without-bootDevice`, `smoke-registers-no-shutdown`, `created-device-plans-install`, `null-device-ios-unprobed`, `inspect-state-order`, `any-up-wins-across-platforms`, `bootstatus-b-not-boot`, `detached-device-error-relayed`, `fallback-eas-rung-dotenv-only`, `expired-is-gone`, `platform-flag-throws-tool-error-first`, `read-verb-one-subprocess-per-binding`, `rung-refusal-reports-first-state`, `probe-local-combines-platforms`, `deviceBinding-imports-nothing-from-verbs`, `exit-codes-per-reason`. - E2E: `bindingFixture` (`created`, `expiresAt` 60 min ahead, canonical root, macOS only) seeded in the cases that hand a verb a booted simulator. A two-worktree case asserts each `run:ios --device` equals its registry id and the two ids are distinct `E2E-CREATED-*` udids, and set equality of `simctl bootstatus -b` calls. diff --git a/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts b/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts index 90912f2e..2ca2095d 100644 --- a/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts +++ b/packages/@expo/agent-cli/src/dev/__tests__/detachAsync-test.ts @@ -37,6 +37,11 @@ vi.mock('../advertisedUrl', async (importOriginal) => ({ fetchAdvertisedUrlAsync: vi.fn(), })); +const realPlatform = process.platform; +function mockPlatform(value: NodeJS.Platform) { + Object.defineProperty(process, 'platform', { value }); +} + const projectRoot = '/project'; const logFile = detachedLogPath(projectRoot); const startLog = path.join(projectRoot, '.expo', 'dev', 'logs', 'start.log'); @@ -82,6 +87,7 @@ beforeEach(() => { }); afterEach(() => { + mockPlatform(realPlatform); vi.useRealTimers(); vi.resetAllMocks(); vol.reset(); @@ -539,6 +545,7 @@ describe('the bound device', () => { }; beforeEach(() => { + mockPlatform('darwin'); vi.mocked(devicesDisabled).mockReturnValue(false); vi.mocked(acquireDeviceAsync).mockResolvedValue(reused); }); @@ -577,6 +584,41 @@ describe('the bound device', () => { ).rejects.toMatchObject({ code: 'DEVICE_UNAVAILABLE', exitCode: 20 }); }); + it.each(['linux', 'win32'] as const)( + 'reuses the server without acquiring iOS on %s', + async (platform) => { + mockPlatform(platform); + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(running); + const { event: cliEvent } = await import('../../events'); + + await expect( + devDetachAsync(projectRoot, resolveDevOptions(['--ios', '--detach', '--local']), { + print: false, + reuseBoundDevice: true, + }) + ).resolves.toBe(0); + + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(cliEvent).toHaveBeenCalledWith( + 'dev_detach', + expect.objectContaining({ alreadyRunning: true }) + ); + } + ); + + it('binds nothing when devices are disabled', async () => { + vi.mocked(devicesDisabled).mockReturnValue(true); + vi.mocked(readDevServerLockAsync).mockReset().mockResolvedValue(running); + await expect( + devDetachAsync(projectRoot, resolveDevOptions(['--ios', '--detach', '--local']), { + print: false, + reuseBoundDevice: true, + }) + ).resolves.toBe(0); + expect(acquireDeviceAsync).not.toHaveBeenCalled(); + }); + // smoke-never-reaches-already-running-reuse it.each([ ['without reuseBoundDevice', ['--ios', '--detach', '--local'], {}], @@ -643,6 +685,7 @@ function childLogOf(error: CommandError): string[] { describe('the acquire line', () => { it('is printed before the already-running report', async () => { + mockPlatform('darwin'); const running = { url: 'http://127.0.0.1:8393', port: 8393, diff --git a/packages/@expo/agent-cli/src/dev/detachAsync.ts b/packages/@expo/agent-cli/src/dev/detachAsync.ts index 99f1219a..651f32e5 100644 --- a/packages/@expo/agent-cli/src/dev/detachAsync.ts +++ b/packages/@expo/agent-cli/src/dev/detachAsync.ts @@ -217,6 +217,7 @@ export async function devDetachAsync( if (running) { if ( reuseBoundDevice && + process.platform === 'darwin' && options.platform === 'ios' && options.deviceBackend !== 'eas' && !devicesDisabled() From 5f0880bd44c4c2820e50ac96ee6def64c4822cce Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Mon, 12 Oct 2026 20:24:18 +0200 Subject: [PATCH 12/17] test(agent-cli): restore bound iOS Expo Go smoke coverage --- .github/workflows/agent-cli.yml | 8 +- .../e2e/__tests__/installedAppStubs.ts | 18 +++++ .../agent-cli/e2e/__tests__/smoke-test.ts | 76 +++++++++++++++++++ .../llp/0035-one-device-per-worktree.rfc.md | 1 + 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-cli.yml b/.github/workflows/agent-cli.yml index 604e32e1..eae9000b 100644 --- a/.github/workflows/agent-cli.yml +++ b/.github/workflows/agent-cli.yml @@ -98,9 +98,8 @@ jobs: working-directory: packages/@expo/agent-cli # The iOS half of the installed-app check only runs where `hostPlatforms()` asks for iOS: macOS. - # One runner, two files — the stub `xcrun` (simctl and devicectl) sits first on PATH, so no real - # simulator or phone is touched. Kept apart from the shards because macOS minutes cost ten times - # a Linux minute and these two files are the only ones that need the platform. + # The stub `xcrun` (simctl and devicectl) sits first on PATH, so no real simulator or phone + # is touched. Also run the macOS-only device-binding cases that Linux/Windows shards skip. tier0-e2e-macos: if: github.event_name != 'schedule' runs-on: macos-15 @@ -124,6 +123,9 @@ jobs: - name: 🧪 E2E installed-app check on macOS run: bun run test:e2e -- status-installed-test installed-readers-test working-directory: packages/@expo/agent-cli + - name: 🧪 E2E iOS device binding and Expo Go install + run: bun run test:e2e -- dev-test smoke-test -t "two worktrees, two simulators|binding the device to boot" + working-directory: packages/@expo/agent-cli # Windows unit coverage: `resolveSpawnTarget` and the win32 branches of the path helpers. # The win32 e2e run — including the skills linker's directory-junction path, which nothing diff --git a/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts b/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts index 39b20791..09282843 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts @@ -176,11 +176,14 @@ export async function installStubXcrunAsync( booted, simulators, runningAppIds, + expoGoMissing = false, phone, }: { booted?: { fingerprint: string }; simulators?: { udid: string; name: string; state?: 'Booted' | 'Shutdown' }[]; runningAppIds?: string[]; + /** Model a fresh Expo Go install; opening requires installation on that booted simulator. */ + expoGoMissing?: boolean; /** `postDelayMs` holds the POST back, the way a slow cold launch does. */ phone?: { fingerprint: string | null; postDelayMs?: number }; } = {} @@ -263,7 +266,21 @@ export async function installStubXcrunAsync( ` if (sim) { sim.state = 'Booted'; save(); }`, ` process.exit(0);`, `}`, + `if (${expoGoMissing} && args[0] === 'simctl' && args[1] === 'install') {`, + ` const sim = find(args[2]);`, + ` if (!sim || sim.state !== 'Booted' || !fs.existsSync(args[3])) process.exit(1);`, + ` sim.expoGoInstalled = true;`, + ` save();`, + ` process.exit(0);`, + `}`, + `if (${expoGoMissing} && args[0] === 'simctl' && args[1] === 'spawn') {`, + ` const sim = find(args[2]);`, + ` const approval = args[5] === 'com.apple.launchservices.schemeapproval' && ['com.apple.CoreSimulator.CoreSimulatorBridge-->exp', 'com.apple.CoreSimulator.CoreSimulatorBridge-->exps'].includes(args[6]) && args[7] === '-string' && args[8] === 'host.exp.Exponent';`, + ` const onboarding = args[5] === 'host.exp.Exponent' && args[6] === 'EXDevMenuIsOnboardingFinished' && args[7] === '-bool' && args[8] === 'true';`, + ` if (sim?.state === 'Booted' && sim.expoGoInstalled && args[3] === 'defaults' && args[4] === 'write' && (approval || onboarding)) process.exit(0);`, + `}`, `if (args[0] === 'simctl' && args[1] === 'openurl') {`, + ` if (${expoGoMissing} && !find(args[2])?.expoGoInstalled) process.exit(115);`, ` fs.writeFileSync(marker, '');`, ` process.exit(0);`, `}`, @@ -277,6 +294,7 @@ export async function installStubXcrunAsync( ` process.exit(0);`, `}`, `if (args[1] === 'get_app_container') {`, + ` if (${expoGoMissing} && !find(args[2])?.expoGoInstalled) process.exit(1);`, ` process.stdout.write(${JSON.stringify(container)} + '\\n');`, ` process.exit(0);`, `}`, diff --git a/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts b/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts index 568e0b50..f56b6e2b 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts @@ -822,6 +822,82 @@ describe('@expo/agent-cli smoke', () => { } }); + it('downloads and installs missing Expo Go onto the bound simulator before opening and attaching', async () => { + const projectRoot = await setupFixtureAsync('go-app'); + const opened = appStartedMarkerPath(projectRoot); + const { calls: readXcrun } = await installStatefulXcrunAsync(projectRoot, { + expoGoMissing: true, + }); + const readNpx = await installStubNpxAsync(projectRoot); + const stub = await startStubDevServerAsync({ + projectRoot, + targets: [EXPO_GO_TARGET], + targetsAppearWithFile: opened, + }); + + try { + expect(fs.existsSync(opened)).toBe(false); + const report = await runAsync(projectRoot, stub); + const binding = readBinding(projectRoot); + expect(binding).toMatchObject({ device: { udid: 'E2E-CREATED-1', origin: 'created' } }); + const udid = binding!.device.udid; + expect(readNpx()).toContainEqual([ + '--yes', + 'expo-go', + 'download', + 'ios', + '54.0.0', + '--json', + ]); + const calls = readXcrun(); + const installAt = calls.findIndex((argv) => argv[1] === 'install'); + expect(installAt).toBeGreaterThan(-1); + expect(calls[installAt]).toEqual([ + 'simctl', + 'install', + udid, + expect.stringMatching(/Expo-Go-57\.0\.9\.tar\.app$/), + ]); + const openAt = calls.findIndex((argv) => argv[1] === 'openurl'); + expect(openAt).toBeGreaterThan(installAt); + expect(calls[openAt]).toEqual([ + 'simctl', + 'openurl', + udid, + expect.stringMatching(/^exp:\/\//), + ]); + for (const scheme of ['exp', 'exps']) { + const approval = [ + 'simctl', + 'spawn', + udid, + 'defaults', + 'write', + 'com.apple.launchservices.schemeapproval', + `com.apple.CoreSimulator.CoreSimulatorBridge-->${scheme}`, + '-string', + 'host.exp.Exponent', + ]; + expect(calls.slice(installAt + 1, openAt)).toContainEqual(approval); + } + expect(report.phases.find((phase: any) => phase.id === 'install-app')).toMatchObject({ + status: 'ok', + }); + expect(report.phases.find((phase: any) => phase.id === 'app')).toMatchObject({ + status: 'ok', + }); + expect(report.appsConnected).toBe(1); + expect(fs.existsSync(opened)).toBe(true); + expect(calls.some((argv) => argv[1] === 'shutdown')).toBe(false); + expect(report.environment.cleanup.map((entry: any) => entry.resource)).not.toContain( + 'device' + ); + expect(readBinding(projectRoot)).toEqual(binding); + } finally { + await stub.close(); + } + }); + it('reuses the parked simulator the worktree already has', async () => { const projectRoot = await setupFixtureAsync('go-app'); const opened = appStartedMarkerPath(projectRoot); diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 3d15f4eb..7305e0d8 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -436,6 +436,7 @@ The agent guide per §Output and errors. The RFC's §Readers and §Entry points - Acquisition concurrency uses real temporary storage (memfs does not model directory-lock rename semantics) and barriers before either initial snapshot advances. Empty, missing and unavailable bindings create exactly one replacement; `reuseOnly` refreshes without creating; lease/name changes do not refresh; inventory failure and three changed snapshots preserve the newer binding. Device reads and boots stay outside the lock. - Detached reuse explicitly models macOS for acquisition tests and Linux/Windows for existing-server reuse without device acquisition. +- The binding-based missing-Expo-Go smoke case asserts the SDK-specific download, install onto the bound UDID, scheme approvals before opening, successful install/app phases, attachment after opening, and retained binding without shutdown. - Unit, named: `boot-always-runs`, `failed-boot-lets-go`, `only-create-and-spawn-under-lock`, `agent-cli-name-needs-binding`, `expo-go-and-bare-drafts-acquire`, `created-device-missing-without-app-id`, `withDevice-after-forwarded-args`, `already-running-reuses-only`, `smoke-never-reaches-already-running-reuse`, `smoke-acquires-without-bootDevice`, `smoke-registers-no-shutdown`, `created-device-plans-install`, `null-device-ios-unprobed`, `inspect-state-order`, `any-up-wins-across-platforms`, `bootstatus-b-not-boot`, `detached-device-error-relayed`, `fallback-eas-rung-dotenv-only`, `expired-is-gone`, `platform-flag-throws-tool-error-first`, `read-verb-one-subprocess-per-binding`, `rung-refusal-reports-first-state`, `probe-local-combines-platforms`, `deviceBinding-imports-nothing-from-verbs`, `exit-codes-per-reason`. - E2E: `bindingFixture` (`created`, `expiresAt` 60 min ahead, canonical root, macOS only) seeded in the cases that hand a verb a booted simulator. A two-worktree case asserts each `run:ios --device` equals its registry id and the two ids are distinct `E2E-CREATED-*` udids, and set equality of `simctl bootstatus -b` calls. From 97c7ad45426cc0a7fc0dabdba3f88e5606145d2d Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Tue, 13 Oct 2026 17:45:12 +0200 Subject: [PATCH 13/17] =?UTF-8?q?[agent-cli]=20e2e=20builds=20each=20proje?= =?UTF-8?q?ct's=20Expo=20home=20with=20path.join=20(llp/0031=20=C2=A7Tests?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: expo/expo-agent-cli#134 (review 5505059377). Every e2e reader of the per-project Expo home rebuilt `${projectRoot}.expo-home` by string concatenation. One helper, `expoHomeOf(projectRoot)` in e2e/utils.ts, now returns `path.join(projectRoot, '.expo-home')`; `spawnAgentCli` and every reader use it, so the home sits inside the fixture project. Co-Authored-By: Claude Opus 5.5 --- packages/@expo/agent-cli/e2e/__tests__/dev-test.ts | 3 ++- .../agent-cli/e2e/__tests__/installed-readers-test.ts | 6 +++--- .../@expo/agent-cli/e2e/__tests__/installedAppStubs.ts | 6 +++--- packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts | 3 ++- packages/@expo/agent-cli/e2e/utils.ts | 7 ++++++- .../agent-cli/llp/0035-one-device-per-worktree.rfc.md | 2 +- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts b/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts index 050645a3..f5ca98c4 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/dev-test.ts @@ -19,6 +19,7 @@ import { spawnAgentCli, waitForAsync, waitForDevLockAsync, + expoHomeOf, } from '../utils'; import { installStubXcrunAsync } from './installedAppStubs'; @@ -1103,7 +1104,7 @@ describe('@expo/agent-cli dev', () => { ]); // One `simctl`, installed once: both runs create and boot against its one simulator state. const { binDir, calls: readXcrun } = await installStubXcrunAsync(first!); - const home = `${first}.expo-home`; + const home = expoHomeOf(first); const env = { ...pathEnvVars(`${binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`), __UNSAFE_EXPO_HOME_DIRECTORY: home, diff --git a/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts b/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts index 3293bf93..9c838468 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/installed-readers-test.ts @@ -11,7 +11,7 @@ import { listConnectedIosDevicesAsync } from '../../src/device/devicectl'; import { readInstalledFingerprintAndroidAsync } from '../../src/installedApp/android'; import { readInstalledFingerprintIosAsync } from '../../src/installedApp/ios'; import { readInstalledFingerprintIosDeviceAsync } from '../../src/installedApp/iosDevice'; -import { getTemporaryPath, pathEnvVars } from '../utils'; +import { getTemporaryPath, pathEnvVars, expoHomeOf } from '../utils'; import { EMBEDDED_HASH, EMULATOR_NAME, @@ -109,7 +109,7 @@ describe('the simulator reader over a stub xcrun', () => { function useStubs(binDir: string): void { restore = withEnv({ ...pathEnvVars(`${binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`), - __UNSAFE_EXPO_HOME_DIRECTORY: `${root}.expo-home`, + __UNSAFE_EXPO_HOME_DIRECTORY: expoHomeOf(root), }); } @@ -173,7 +173,7 @@ describe('the phone probe over a stub devicectl', () => { ...pathEnvVars( `${xcrun.binDir}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}` ), - __UNSAFE_EXPO_HOME_DIRECTORY: `${root}.expo-home`, + __UNSAFE_EXPO_HOME_DIRECTORY: expoHomeOf(root), }); return xcrun; } diff --git a/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts b/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts index 09282843..6dbe3ceb 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/installedAppStubs.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { digestOf } from '../../src/devLock/address'; import { canonicalizeExistingPath } from '../../src/utils/dir'; -import { installStubBinAsync } from '../utils'; +import { installStubBinAsync, expoHomeOf } from '../utils'; export const APK_FIXTURE = path.resolve(__dirname, '../../src/__fixtures__/zip/fixture-stored.zip'); /** An archive whose `app.fingerprint` is not the JSON `expo-constants` writes: a pre-JSON build. */ @@ -42,7 +42,7 @@ export type StubCalls = () => string[][]; /** * Bind `udid` to the fixture worktree in the registry the harness's Expo home holds - * (`.expo-home`, @ref ../utils §spawnAgentCli), as `dev` would have: `created`, the + * (`/.expo-home`, @ref ../utils §spawnAgentCli), as `dev` would have: `created`, the * lease 60 min ahead, the canonical root. Every verb of the fixture then drives this simulator * (llp/0035 §Readers), so a case that hands a verb a booted simulator seeds this first. */ @@ -50,7 +50,7 @@ export async function bindingFixture(projectRoot: string, udid: string, name = S const root = canonicalizeExistingPath(projectRoot); const now = Date.now(); const file = path.join( - `${projectRoot}.expo-home`, + expoHomeOf(projectRoot), 'agent-cli', 'bindings', `${digestOf(root)}-ios-local-ios.json` diff --git a/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts b/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts index f56b6e2b..465e003e 100644 --- a/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts +++ b/packages/@expo/agent-cli/e2e/__tests__/smoke-test.ts @@ -27,6 +27,7 @@ import { readStubExpoInvocations, stubExpoEnv, type StubDevServer, + expoHomeOf, } from '../utils'; import { appStartedMarkerPath, @@ -755,7 +756,7 @@ describe('@expo/agent-cli smoke', () => { /** The registry file of the fixture, as `dev` or `smoke` wrote it. */ function readBinding(projectRoot: string): Record | null { - const dir = path.join(`${projectRoot}.expo-home`, 'agent-cli', 'bindings'); + const dir = path.join(expoHomeOf(projectRoot), 'agent-cli', 'bindings'); const file = fs.existsSync(dir) ? fs.readdirSync(dir).find((name) => name.endsWith('-ios-local-ios.json')) : null; diff --git a/packages/@expo/agent-cli/e2e/utils.ts b/packages/@expo/agent-cli/e2e/utils.ts index 56f46642..1f9a7fb6 100644 --- a/packages/@expo/agent-cli/e2e/utils.ts +++ b/packages/@expo/agent-cli/e2e/utils.ts @@ -314,6 +314,11 @@ export async function executeAgentCliAsync( return result; } +/** The Expo home every `@expo/agent-cli` run in `projectRoot` gets from `spawnAgentCli`. */ +export function expoHomeOf(projectRoot: string): string { + return path.join(projectRoot, '.expo-home'); +} + /** * Spawn `node bin/cli.js ` without waiting for it to exit, for long-running commands * like `@expo/agent-cli start`. Callers are responsible for killing the process. @@ -356,7 +361,7 @@ export function spawnAgentCli( // `dev` run on a developer's Mac would boot a real simulator against a stub dev server. AGENT_CLI_NO_DEVICE: '1', // Per project, so one test's Expo home state never reaches another test or the real home. - __UNSAFE_EXPO_HOME_DIRECTORY: `${cwd}.expo-home`, + __UNSAFE_EXPO_HOME_DIRECTORY: expoHomeOf(cwd), // Setup, `new`, install and a stale search would otherwise download docs from the network. AGENT_CLI_NO_DOCS_SYNC: '1', ...stubExpoEnv(cwd), diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 7305e0d8..0b7b83df 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -357,7 +357,7 @@ Seven PRs, stacked with `gh stack`, rebased never merged, one question per PR; 3 **PR 1.** Cherry-pick 94a6890, f6262e1, 80ccfb1 and 7e88e97 from the rejected branch. One conflict: the branch's `llp/0028-one-device-per-agent` file, which main lacks; delete it. One project-root predicate over the `/status` header's reported root serves the port scan, the dev lock's step 0 and the dev-lock port check; `/status` is retried only for the logged and default ports; the Discovery section goes into [[0004-smart-start-and-project-state]]. About 350 source lines. Leaves: a foreign Metro on 8081 is skipped and named in `status`. Independently mergeable. -**PR 2.** Export `digestOf` from `src/devLock/address.ts`; add `Runner`; move `parseBootedIosSimulators`, `listBootedIosSimulatorsAsync`, `parseSimulators` and `SimulatorEntry` to `src/device/simulators.ts`; `parseAndroidDevices` and `AndroidDeviceLine` to `src/device/adb.ts`; `stopEasSessionAsync` with `buildSessionStopArgs` and its timeout to `src/device/eas.ts`; `openAppEas.ts`'s `firstLine` to `src/utils/text.ts`; retarget the lazy requires in `dev/devAsync.ts`, `dev/stopAsync.ts` and `smoke/smokeAsync.ts`. The e2e `installStubXcrunAsync` of `installedAppStubs.ts` replaces the private copies in `navigate-test.ts`, `stop-test.ts` and `runtime-reload-test.ts`, and gains a `simulators` option, `list runtimes -j`, `create` (udids `E2E-CREATED-` from a counter file, listed `Shutdown`), `delete`, `boot`, `bootstatus` (`-b` sets `Booted`), `openurl` and `shutdown`; `spawnAgentCli` gains a per-project `__UNSAFE_EXPO_HOME_DIRECTORY` default at `.expo-home`. About 200 source lines. Leaves: the unit and e2e suites pass as before. Independently mergeable. +**PR 2.** Export `digestOf` from `src/devLock/address.ts`; add `Runner`; move `parseBootedIosSimulators`, `listBootedIosSimulatorsAsync`, `parseSimulators` and `SimulatorEntry` to `src/device/simulators.ts`; `parseAndroidDevices` and `AndroidDeviceLine` to `src/device/adb.ts`; `stopEasSessionAsync` with `buildSessionStopArgs` and its timeout to `src/device/eas.ts`; `openAppEas.ts`'s `firstLine` to `src/utils/text.ts`; retarget the lazy requires in `dev/devAsync.ts`, `dev/stopAsync.ts` and `smoke/smokeAsync.ts`. The e2e `installStubXcrunAsync` of `installedAppStubs.ts` replaces the private copies in `navigate-test.ts`, `stop-test.ts` and `runtime-reload-test.ts`, and gains a `simulators` option, `list runtimes -j`, `create` (udids `E2E-CREATED-` from a counter file, listed `Shutdown`), `delete`, `boot`, `bootstatus` (`-b` sets `Booted`), `openurl` and `shutdown`; `spawnAgentCli` gains a per-project `__UNSAFE_EXPO_HOME_DIRECTORY` default at `/.expo-home`. About 200 source lines. Leaves: the unit and e2e suites pass as before. Independently mergeable. Verification shared by every PR: typecheck, lint, the module's unit tests, and the e2e files it touches (e2e after `bun run build` with `EXPO_E2E_TEMP_DIR` set; device tests with `AGENT_CLI_NO_DEVICE=0`; iOS cases `skipIf` off macOS, Android device cases skipped on win32). E2E fixtures: two fixture worktrees, one stub `xcrun`/`adb`/`emulator`/`eas`, one shared Expo home. Tests that pin a decision are named in each plan; the rest live in the PR descriptions. From 86dd3f7d979747c33ea5e86bf831ee6a069306e7 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Wed, 14 Oct 2026 23:49:19 +0200 Subject: [PATCH 14/17] [agent-cli] llp/0035 matches the iOS binding as built: no eas rung option, withDevice after the port block, session cap per account, EAS section marked not built Co-Authored-By: Claude Fable 5.1 --- .../llp/0035-one-device-per-worktree.rfc.md | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 0b7b83df..4186c6cc 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -2,7 +2,7 @@ **Type:** RFC **Status:** Draft -**Systems:** device binding (new `src/deviceBinding/`); local device selection and boot (`src/device/bootDevice.ts`, `src/navigate/device.ts`, `src/installedApp/`); the `dev` resolver and run (`src/dev/`); `dev:stop` (`src/dev/stopAsync.ts`); `smoke` (`src/smoke/smokeAsync.ts`); EAS session selection (`src/device/cloudSimulator.ts`); the dev-server lock (`src/devLock/`); `src/deviceBinding/` (new); the `dev` resolver and run (`src/dev/resolveOptions.ts`, `src/dev/devAsync.ts`, `src/dev/detachAsync.ts`, `src/dev/index.ts`); the app-presence probe (`src/device/appPresence.ts`); the local open (`src/dev/openApp.ts`); `navigate`'s device resolution (`src/navigate/device.ts`); the installed readers (`src/installedApp/`); `smoke`'s device phases (`src/smoke/smokeAsync.ts`); `src/deviceBinding/android.ts` (new); the Android boot (`src/device/bootDevice.ts`); the Android open (`src/dev/openApp.ts`); `installDevBuild`, `askDeviceAsync`, `src/installedApp/android`; `smoke`'s Android phases; the e2e stub `adb` and a new stub `emulator`; `src/deviceBinding/reap.ts` (new); `releaseWorktreeDevicesAsync`; `dev:stop` and its `--release` flag (`src/dev/stopAsync.ts`, `src/dev/resolveStopOptions.ts`); the Metro runner (`src/start/startAsync.ts`); `status --json`; `smoke`'s cleanup; the live tier (`e2e-live/`); `src/deviceBinding/cloud.ts` (new); the EAS open (`src/dev/openAppEas.ts`); dev-wait; `smoke`'s EAS branches; `dev:stop --eas`; the e2e stub `eas`; the live suite `live-cloud` +**Systems:** device binding (new `src/deviceBinding/`); local device selection and boot (`src/device/bootDevice.ts`, `src/navigate/device.ts`, `src/installedApp/`); the `dev` resolver and run (`src/dev/resolveOptions.ts`, `src/dev/devAsync.ts`, `src/dev/detachAsync.ts`, `src/dev/index.ts`); the app-presence probe (`src/device/appPresence.ts`); the local open (`src/dev/openApp.ts`); `installDevBuild` and `askDeviceAsync`; `dev:stop` and its `--release` flag (`src/dev/stopAsync.ts`, `src/dev/resolveStopOptions.ts`); `smoke`'s device phases and cleanup (`src/smoke/smokeAsync.ts`); the Metro runner (`src/start/startAsync.ts`); `status --json`; EAS session selection (`src/device/cloudSimulator.ts`), the EAS open (`src/dev/openAppEas.ts`), dev-wait and `dev:stop --eas`; the dev-server lock (`src/devLock/`); the e2e stubs `adb`, `emulator` and `eas`; the live tier (`e2e-live/`, `live-cloud`) **Author:** Vojtech Novak (drafted with Claude) **Date:** 2026-10-08 **Related:** [[0004-smart-start-and-project-state]], [[0005-runtime-loop-tools]], [[0010-agent-conventions]] (exit codes), [[0021-honest-reports]], [[0026-dev-owns-the-open]], [[0027-everything-on-eas]] §Testing, [[0025-dev-requires-platform]], [[0022-live-tier]] @@ -43,7 +43,7 @@ The claimants of a device decide where its ownership record must live. A local d - **Section**: one hold of the registry lock, from taking it to giving it up. The lock is **given up**, never released. - **Let go**: what happens to a binding's device when its worktree, or a reap, is done with it; one rule per origin, in §Records. - **Reap**: let go of other worktrees' stale bindings. **Release**: let go of the own bindings; done by `dev:stop --release` and smoke's cleanup. A plain `dev:stop` keeps the devices, because the agent's common next step is `dev` again (Decision 9). -- **Session cap**: an EAS session lives at most 115 min on the service (§Answers from the EAS code, item 2). +- **Session cap**: an EAS session lives at most 40 min on the service for a normal-priority account and 115 min for a high-priority one (§Answers from the EAS code, item 2). - **Dev lock**: main's per-project socket lock that the process running Metro holds (`src/devLock/`). "The lock" alone means the registry lock. - **Metro runner**: `runDevServerAsync` in `start/startAsync.ts`. **Step runner**: `runStepAsync` in `dev/devAsync.ts`, which runs prebuild, the `--no-bundler` install step and `eas build` with no dev lock. - **Draft**: the first start plan the resolver decides, before it knows about devices; **building** when it has a build location. Named by `ProjectTarget`: `expo-go`, `dev-client`, `bare`, `web`, `none`. @@ -59,7 +59,7 @@ The claimants of a device decide where its ownership record must live. A local d **Why.** The human's AVD image is never written by an agent, and the first and the fifth worktree behave the same. The cost is a cold boot and a reinstall per instance start. 3. **Liveness is a 60 min lease, renewed by use. Nothing else.** No agent pid in the binding, no socket probe, no mtime. The `emulatorPid` of a spawned instance identifies the emulator, not the agent. **Why.** One source of truth. Metro renews the lease on a timer, so a Fast Refresh session never expires while Metro runs, and a crashed agent's binding is stale within 60 min. A pid in the binding would add a second source and a pid-reuse risk. The one case a pid would catch, `dev:stop --release` during a build step of the same worktree, costs one rerun of `dev` (Risk 9). -4. **Only `dev` and `smoke` allocate. Every other verb reads the binding**, with one subprocess per binding and no network call before the EAS rung. +4. **Only `dev`, with `openAppEas` and dev-wait, and `smoke` allocate. Every other verb reads the binding**, with one subprocess per binding and no network call before the EAS rung. **Why.** The agent's hottest verb must not pay for the rarest decision. `dev` is where the agent's loop starts ([[0026-dev-owns-the-open]]); `smoke` brings its own device ([[0005-runtime-loop-tools]]). 5. **A parked simulator is an expired `created` binding, not a second record.** A living worktree keeps its parked simulator and gets it back on the next `dev`. A deleted worktree's simulator is deleted by the next reap. **Why.** One record type cannot disagree with itself. The worktree that paid for the native build keeps it. Disk is reclaimed when the worktree goes, with no count and no cap. Nothing takes another worktree's simulator: a `simctl create` is cheap, and the app on a foreign simulator is a foreign build anyway. @@ -90,7 +90,7 @@ A created simulator boots, shuts down or is deleted, and a spawned emulator is k 9. The EAS probe considers only sessions this worktree bound, its dotenv id (from PR 5; until then the fallback without `--eas` accepts the dotenv id only and `--eas` keeps main's choice), and sessions tagged with its digest (PR 6). 10. `src/deviceBinding/` imports nothing from `navigate`, `smoke`, `dev` or `installedApp`; a unit test enforces it. -Files and budgets: `types.ts`, `registry.ts` (read, write, lock), `lease.ts`, `choose.ts` (pure), `reap.ts`, `inspect.ts` (the inspect table, the cache, `useBoundDeviceAsync`, `ownCloudIdsAsync`), `rungs.ts` (`findBoundDeviceAsync`, the rung loop of §Readers), `android.ts`, `cloud.ts`, `index.ts` (`acquireDeviceAsync`, `releaseWorktreeDevicesAsync`, `defaultTools`, re-exports). Every file under 250 lines, no function over 60, the iOS and Android boots in their own functions. +Files and budgets: `types.ts`, `registry.ts` (read, write, lock), `lease.ts`, `choose.ts` (pure), `inspect.ts` (the inspect table, the cache, `useBoundDeviceAsync`, `ownCloudIdsAsync`), `rungs.ts` (`findBoundDeviceAsync`, the rung loop of §Readers), `ios.ts` (the `simctl` calls), `errors.ts`, `events.ts`, `tools.ts` (`defaultTools`), `index.ts` (`acquireDeviceAsync`, `releaseWorktreeDevicesAsync`, re-exports); `reap.ts`, `android.ts`, `cloud.ts` and the other files are named by the section that adds them. Every file under 250 lines, no function over 60, the iOS and Android boots in their own functions. ## Records @@ -211,7 +211,7 @@ Inputs: the own binding and the other worktrees' bindings, each marked stale or ## Entry points -**`acquireDeviceAsync(root, platform, { explicit?, reuseOnly?, tools? }) → AcquireResult`**, throws `DEVICE_UNAVAILABLE` with `data.reason`. For `dev` and `smoke` in bootstrap mode; both skip it under `AGENT_CLI_NO_DEVICE`, and `dev` skips it on a host that cannot run the platform's local device (iOS off macOS), where the run serves for a device elsewhere and binds nothing, as on main. Read the platform's inventory; a device tool that cannot run is main's `toolError`. Under the lock, in one section: read the bindings, choose, write, reap; a refusal is thrown after the reap, so a deleted worktree's device does not block a rerun, and the device actions of the section's let-go rules run in a `finally` before the throw, so a reaped emulator is never left running without a file. After the section: boot and verify on every choice, whatever the inventory said (iOS: `simctl bootstatus -b`, which boots only when the simulator is not `Booted` and then waits, because `simctl boot` on a `Booted` simulator exits non-zero; Android: `sys.boot_completed`), with `BOOT_DEVICE_TIMEOUT_MS` per platform. A failed boot lets go of the binding; a spawn whose binding was never written kills its child through its handle. The caller prints `action` as one line on stderr. +**`acquireDeviceAsync(root, platform, { explicit?, reuseOnly?, tools? }) → AcquireResult`**, throws `DEVICE_UNAVAILABLE` with `data.reason`. For `dev` and `smoke` in bootstrap mode; both skip it under `AGENT_CLI_NO_DEVICE`, and `dev` skips it on a host that cannot run the platform's local device (iOS off macOS), where the run serves for a device elsewhere and binds nothing, as on main. Read the platform's inventory; a device tool that cannot run is main's `toolError`, and one that does not answer within the 20 s timeout refuses `create-timeout`, the reason `simctl create` shares. Under the lock, in one section: read the bindings, choose, write, reap; a refusal is thrown after the reap, so a deleted worktree's device does not block a rerun, and the device actions of the section's let-go rules run in a `finally` before the throw, so a reaped emulator is never left running without a file. After the section: boot and verify on every choice, whatever the inventory said (iOS: `simctl bootstatus -b`, which boots only when the simulator is not `Booted` and then waits, because `simctl boot` on a `Booted` simulator exits non-zero; Android: `sys.boot_completed`), with `BOOT_DEVICE_TIMEOUT_MS` per platform. A failed boot lets go of the binding; a spawn whose binding was never written kills its child through its handle. The caller prints `action` as one line on stderr. | Choice | Write under the lock | | --------------- | ---------------------------------------------------------------------------------------- | @@ -243,7 +243,7 @@ Inputs: the own binding and the other worktrees' bindings, each marked stale or ## Readers -- Rung order. With a platform flag and no `--eas`: that platform's local binding. With no platform flag: both local platforms are inspected, iOS on macOS only, and any `up` wins; when both are `up`, the binding with the newer `boundAt` wins, so after `dev --android` on a worktree whose simulator is still up the agent drives the instance it just asked for. When no platform is `up`, the first state that is not `none`, in iOS, Android order, decides per the inspect table's last column: iOS `gone` and Android `not-up` is a refusal naming Android; iOS `gone` and Android `none` goes to the EAS rung. When every local rung passed, verbs that fall back to EAS (`navigate` and `smoke` in `fallback` mode) run the EAS rung; `runtime:stop` and `runtime:reload` do not. With `--eas` the local rungs are skipped. A tool error seen on any rung is thrown after the EAS rung; else the refusal reports the first non-`none` state seen, with its How line; with only `none` states it names the platform of the flag, else iOS on macOS and Android elsewhere. The loop is `findBoundDeviceAsync(root, { platform?, eas?, extend })` in `rungs.ts`. It calls `inspectBindingAsync` per platform, fresh, and with `extend` calls `useBoundDeviceAsync` once, on the winner. `resolveDeviceAsync` calls it with `extend: true` and sets `adb: resolveAdb()` on a `local-android` winner, so `openRoute`'s `adb reverse` still runs. +- Rung order. With a platform flag and no `--eas`: that platform's local binding. With no platform flag: both local platforms are inspected, iOS on macOS only, and any `up` wins; when both are `up`, the binding with the newer `boundAt` wins, so after `dev --android` on a worktree whose simulator is still up the agent drives the instance it just asked for. When no platform is `up`, the first state that is not `none`, in iOS, Android order, decides per the inspect table's last column: iOS `gone` and Android `not-up` is a refusal naming Android; iOS `gone` and Android `none` goes to the EAS rung. When every local rung passed, verbs that fall back to EAS (`navigate` and `smoke` in `fallback` mode) run the EAS rung; `runtime:stop` and `runtime:reload` do not. With `--eas` the local rungs are skipped. A tool error seen on any rung is thrown after the EAS rung; else the refusal reports the first non-`none` state seen, with its How line; with only `none` states it names the platform of the flag, else iOS on macOS and Android elsewhere. The loop is `findBoundDeviceAsync(root, { platform?, extend })` in `rungs.ts`; it has no `eas` option, because `resolveDeviceAsync`'s `cloud: 'required'` short-circuit handles `--eas` before the loop. It calls `inspectBindingAsync` per platform, fresh, and with `extend` calls `useBoundDeviceAsync` once, on the winner. `resolveDeviceAsync` calls it with `extend: true` and sets `adb: resolveAdb()` on a `local-android` winner, so `openRoute`'s `adb reverse` still runs. - The installed readers (`src/installedApp/`) call `findBoundDeviceAsync` once per platform with `extend: false`, because `status` never writes. - The EAS rung is one probe call: `probeCloudSessionAsync({ boundIds })`. `ownCloudIdsAsync(root)` returns the ids of the own `cloud` bindings whose state is `recorded`; every probe caller uses it. When the selected id is an own binding's, the verb extends it per §Lease. When the listing shows a bound id as queued, the rung refuses `not-up` ("the bound session is still queued", How line: run the command again in a minute). When the listing shows it in no live state, the rung refuses `gone` with cause `device-gone` and writes nothing (Contract 1); its How line is the `dev` command with `--eas`, which on a running server opens a session through `openAppEas` and rewrites the binding (§EAS session binding). Until PR 5 the fallback without `--eas` accepts the dotenv id only, never the newest session, so a worktree with no binding does not drive another worktree's session; with `--eas` main's choice stays until PR 5. - `probeLocalDeviceAsync({ projectRoot, ... })` maps the states of both platforms, with its cache keyed by root: any `up` is `present`; else any `unreadable` or `unknown` is main's `unknown`; else `absent`, with the first state seen as the reason. `status`, the start follow-ups and dev-wait fold it into their suggestion ladders. @@ -277,7 +277,7 @@ Each reaped device is reported on stderr and in JSON with reason `expired`, `del | ------------------------ | ------------------------- | ---- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `NO_BOUND_DEVICE` | `none` | 20 | "No device is bound to this worktree" | the `dev` command | | `NO_BOUND_DEVICE` | `gone` | 20 | "the lease expired" or "the device is gone" | the `dev` command | -| `NO_BOUND_DEVICE` | `not-up` | 20 | "the bound device is not up" | the `dev` command | +| `NO_BOUND_DEVICE` | `not-up` | 20 | "the bound device is not up" | the `dev` command; a queued EAS session: run the command again in a minute | | `NO_BOUND_DEVICE` | `unknown` (timeout) | 22 | "the device check timed out" | run the command again | | `NO_BOUND_DEVICE` | `unreadable` | 7 | "the binding file does not parse; a newer CLI may have written it" | `rm -f ''` | | `DEVICE_UNAVAILABLE` | `unreadable` | 7 | the same | `rm -f ''` | @@ -298,7 +298,7 @@ Each reaped device is reported on stderr and in JSON with reason `expired`, `del ## EAS -- `probeCloudSessionAsync` calls the pure `selectCloudSession`, which takes `boundIds`. A session is a candidate only when the service lists it as queued or in progress and its id is in `boundIds`, is the dotenv id, or carries the tag `agent-cli:`; a queued bound session is reported as such, never replaced by a new start. Unbound in-progress sessions come back as `unbound` for the report. The probe reports which source the selected id came from. +- Pending §EAS session binding: `probeCloudSessionAsync` calls the pure `selectCloudSession`, which takes `boundIds`. A session is a candidate only when the service lists it as queued or in progress and its id is in `boundIds`, is the dotenv id, or carries the tag `agent-cli:`; a queued bound session is reported as such, never replaced by a new start. Unbound in-progress sessions come back as `unbound` for the report. The probe reports which source the selected id came from. - The session start passes `--max-idle-time-minutes 30` (PR 5) and `--tag agent-cli:` (PR 6). When the start exits non-zero with oclif's "Nonexistent flag" on stderr and no session id was read, it retries once without the flag that was refused. A session found by tag is recorded as `started`. The idle timeout ships with the bound-only selection, because a start that an agent's tool timeout kills before it returns an id leaves a session with no binding, which the bound-only probe never reuses. - `dev:stop --eas` stops the probe's session, bound or dotenv, and then each other `cloud` binding's session of this worktree, and removes each stopped session's binding with the `sessionId` release; it needs no `--release`. A plain `dev:stop` keeps `cloud` bindings and reports each as recorded, not checked. @@ -405,11 +405,11 @@ Per §Contracts every verb honors, without `android.ts`, `cloud.ts`, `reap.ts` a - Decided while building, where the RFC left it open: - The detached child's log carries one row, `Error code exit data `, written by `logCmdError` only under `__EXPO_AGENT_CLI_DETACHED=1`, which `devDetachAsync` sets on the spawn. The parent reads the row, the `Try:` line right above it, and nothing else, and rebuilds the error with its code, exit, `data` and `suggestedCommand`. - A boot that fails lets go of the binding and refuses `DEVICE_UNAVAILABLE` with reason `boot-failed` (§Output and errors). An inventory read that times out refuses `create-timeout`. - - `src/deviceBinding/` has `ios.ts` (the iOS `simctl` calls), `errors.ts`, `events.ts` and `tools.ts` (`defaultTools`) beside the RFC's files; `DeviceTools` holds `simctl`, `now` and `isPidAlive` until §Android instance adds the Android members, and `inspectBindingAsync` takes `local-ios` and `cloud` only. + - `DeviceTools` holds `simctl`, `now` and `isPidAlive` until §Android instance adds the Android members, and `inspectBindingAsync` takes `local-ios` and `cloud` only. - `findBoundDeviceAsync` takes no `eas` option; `resolveDeviceAsync` keeps its `cloud: 'required'` short-circuit. Main's Android rung is injected as `android?: AndroidRung`, because Contract 10 keeps `navigate` out of the module. The EAS fallback asks the service only when the dotenv names a session. - The `--plan` reason "will reuse or create a simulator" is added only for `--ios` without `--eas` and with devices on, so a stubbed harness's plans are unchanged. - `smoke`'s iOS boot skips under `AGENT_CLI_NO_DEVICE` and reports a failed boot phase; its install phase honours the boot's `installNeeded`, because a created simulator's disk holds nothing to read. -- `withDevice(plan, device)` runs after `withForwardedExpoArgs` and before the `--port` block. It sets `--device ` on every `run` and `install` step and replaces any `--device` there; on a plan with neither it is a no-op. `AppPresenceProbe.installDevice` goes for iOS. `devAsync` passes `acquired.device` through `executePlanAsync` to `openAppForRunAsync`'s local branch, which hands it to `openAppOnDeviceAsync`; its iOS probe and boot go, and it passes the device to `openRouteAsync` through a new `device?: BoundDevice` option, which skips `resolveDeviceAsync`, so the open inspects nothing twice. `report.booted` is `acquired.justBooted`. +- `withDevice(plan, device)` runs once a device is acquired, after the `--port` block, the `--plan` return and the refusals, for a serving draft (acquired in the resolver's callback) and a building one alike. It sets `--device ` on every `run` and `install` step and replaces any `--device` there; on a plan with neither it is a no-op. `AppPresenceProbe.installDevice` goes for iOS. `devAsync` passes `acquired.device` through `executePlanAsync` to `openAppForRunAsync`'s local branch, which hands it to `openAppOnDeviceAsync`; its iOS probe and boot go, and it passes the device to `openRouteAsync` through a new `device?: BoundDevice` option, which skips `resolveDeviceAsync`, so the open inspects nothing twice. `report.booted` is `acquired.justBooted`. - The callback runs for iOS only, and on macOS only; on another host `dev --ios` binds nothing and the plan is unprobed, which is what the live-cloud suite's Linux worker relies on. Android keeps main's probe-and-boot path, its first-adb rung, its `probeLocalDeviceAsync` branch and its `installDevice` until 3b. `acquire` is skipped for `--eas`. - `dev --plan` passes a callback that inspects the own binding and returns it as `reused` with `justBooted: false` when the state is `up` or `not-up`; otherwise null. For a building draft `devAsync` calls that same callback itself and pins the plan, with no probe. When the callback returned null, `devAsync` leaves the step unpinned and adds the reason "will reuse or create a simulator" to the plan, building or not. - `dev --detach`: the child acquires and never releases on exit. `devDetachAsync` gains an option `reuseBoundDevice`, default false, that `devAsync` passes as true and `smoke` leaves unset. With it, when the server is already running, `devDetachAsync` calls `acquireDeviceAsync` with `reuseOnly` before `reportDetached` prints, on macOS for `--ios` without `--eas` and without `AGENT_CLI_NO_DEVICE`. Linux and Windows reuse the server without acquiring an iOS simulator. A parked own simulator is booted, the boot is waited for, and the `action` line is printed on stderr before the report. Any other choice refuses `not-reusable` (exit 20), because a device created here would carry no app. The child's `DEVICE_*` and `NO_BOUND_DEVICE` errors are written to the child verdict and the parent rethrows them unchanged, per §Output and errors; `detachFailureError` keeps `DEV_DETACH_DIED` for everything else. @@ -547,6 +547,8 @@ An EAS session belongs to the worktree that started it. PRs 5 and 6 of §Deliver PR 5: how does a worktree keep to its own EAS session? PR 6: how does a session survive a lost registry? +**Status:** not built. Until it ships, the EAS fallback asks the service only when the dotenv names a session (§How `dev` uses it), and the cloud rows of the inspect and reap tables are unused. + ### State after EAS binding - PR 5: two worktrees get two sessions; `navigate --eas` uses its own; `dev:stop --eas` ends this worktree's sessions; a deleted worktree's `started` session is stopped from another worktree, a `dotenv` one only removed; `dev --eas` reaps too. @@ -570,7 +572,7 @@ About 350 source lines. - `--tag agent-cli:` on the session start, with the same "Nonexistent flag" retry. Any other failure is reported as today, because a start can create a session and still exit non-zero. The event `dev:open_app_eas_flag_unsupported { flag }` records a retry. - The tag field in `CloudSessionInfo`, named from a recorded `simulator:list --json` fixture that carries a tag. The tag match reads that one listing, with no second `eas` call. -- The runbook and a live test trimmed to the acceptance lists. `live-cloud` starts its session with `eas` directly, so the dotenv source stays and a reap never stops a `dotenv` session (Risk 7). +- The runbook and a live test trimmed to the acceptance lists. `live-cloud` starts its session with `eas` directly, so the dotenv source stays and a reap never stops a `dotenv` session (§Records). - Tests, named: `tag-matches-on-one-listing`, `tag-retry-only-on-nonexistent-flag`, with a stub version switch for the eas-cli skew (Risk 3). About 120 source lines. From 5eaa0b4cebe2ac9a46e0e23764050809c9117176 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 15 Oct 2026 00:01:46 +0200 Subject: [PATCH 15/17] =?UTF-8?q?[agent-cli]=20A=20failed=20boot=20lets=20?= =?UTF-8?q?go=20only=20of=20the=20binding=20it=20wrote=20(llp/0035=20?= =?UTF-8?q?=C2=A7Entry=20points)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../llp/0035-one-device-per-worktree.rfc.md | 2 +- .../deviceBinding/__tests__/acquire-test.ts | 51 ++++++++++++++++++- .../agent-cli/src/deviceBinding/index.ts | 28 ++++++++-- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 4186c6cc..56836041 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -239,7 +239,7 @@ Inputs: the own binding and the other worktrees' bindings, each marked stale or **`useBoundDeviceAsync(inspection, tools?) → BoundDevice`**: takes an `up` inspection, extends the lease per §Lease, and returns the device; `lost` throws `gone` with cause `expired` (exit 20). It never inspects again and never boots. -**`releaseWorktreeDevicesAsync(root, { platform?, sessionId? }) → ReleasedDevice[]`**, never reaps. Without `sessionId`: lets go of the local bindings, or only `platform`'s when given. With `sessionId`: removes the cloud binding that names that id, live or expired. Callers of the local release: `dev:stop --release`, after the server stop or when no server ran; smoke's cleanup. Callers of the `sessionId` release: every stop of a session this worktree bound, that is the foreground `dev --eas` exit, `dev:stop --eas` and `smoke --eas`, with no `--release` needed, because a stopped session leaves nothing to keep. The foreground `dev` exit and the detached child touch no local binding; the lease expires on its own. +**`releaseWorktreeDevicesAsync(root, { platform?, expected?, sessionId? }) → ReleasedDevice[]`**, never reaps. `expected` restricts a failed-boot cleanup to the binding that run acquired, so a replacement binding is left alone. Without `sessionId`: lets go of the local bindings, or only `platform`'s when given. Under the lock it acts only when the file still holds the binding it read: the same root, `boundAt` and device; an extended `expiresAt` is the same binding. With `sessionId`: removes the cloud binding that names that id, live or expired. Callers of the local release: `dev:stop --release`, after the server stop or when no server ran; smoke's cleanup. Callers of the `sessionId` release: every stop of a session this worktree bound, that is the foreground `dev --eas` exit, `dev:stop --eas` and `smoke --eas`, with no `--release` needed, because a stopped session leaves nothing to keep. The foreground `dev` exit and the detached child touch no local binding; the lease expires on its own. ## Readers diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts index d9cd36a3..34153a0e 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/acquire-test.ts @@ -4,7 +4,8 @@ import { vol } from 'memfs'; import { canonicalizeExistingPath } from '../../utils/dir'; import { acquireDeviceAsync, releaseWorktreeDevicesAsync } from '..'; import { bindingPathFor, readBindingFile } from '../registry'; -import { bindingFor, fakeTools, writeBinding } from './fakeTools'; +import type { Binding } from '../types'; +import { bindingFor, fakeTools, writeBinding, type FakeTools } from './fakeTools'; const ROOT = '/work/app'; @@ -97,6 +98,54 @@ describe(acquireDeviceAsync, () => { expect(read.kind === 'binding' && read.binding.expiresAt).toBe('2026-10-08T10:00:00.000Z'); }); + // failed-boot-leaves-replacement-binding + it.each([ + [ + 'bound another simulator during the boot', + (tools: FakeTools, [command]: string[]): Binding | null => { + if (command !== 'bootstatus') return null; + tools.simulators.push({ udid: 'OTHER', name: 'x', state: 'Booted' }); + return bindingFor(ROOT, 'OTHER'); + }, + ], + [ + 'rebound the same simulator after the release read the file', + (tools: FakeTools, [command, what]: string[]): Binding | null => { + const booted = tools.calls.some(([called]) => called === 'bootstatus'); + const read = readBindingFile(file()); + if (!booted || command !== 'list' || what !== 'devices' || read.kind !== 'binding') { + return null; + } + tools.simulators.find(({ udid }) => udid === 'CREATED-1')!.state = 'Booted'; + return { + ...read.binding, + boundAt: '2026-10-08T10:00:05.000Z', + expiresAt: '2026-10-08T11:00:05.000Z', + }; + }, + ], + ] as const)( + 'leaves the binding and simulator of a concurrent dev that %s alone when the boot fails', + async (_name, concurrentDev) => { + const tools = fakeTools({ bootFails: true }); + const simctl = tools.simctl; + let replacement: Binding | null = null; + tools.simctl = async (args, options) => { + if (replacement == null) { + replacement = concurrentDev(tools, args); + if (replacement != null) writeBinding(file(), replacement); + } + return simctl(args, options); + }; + + const error = await acquireDeviceAsync(ROOT, 'ios', { tools }).catch((e) => e); + + expect(error.data).toEqual({ reason: 'boot-failed' }); + expect(readBindingFile(file())).toEqual({ kind: 'binding', binding: replacement }); + expect(tools.calls.some(([command]) => command === 'shutdown')).toBe(false); + } + ); + // agent-cli-name-needs-binding it('never adopts an agent-cli simulator no binding names', async () => { const tools = fakeTools({ diff --git a/packages/@expo/agent-cli/src/deviceBinding/index.ts b/packages/@expo/agent-cli/src/deviceBinding/index.ts index 631c768e..b562e72c 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/index.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/index.ts @@ -91,7 +91,7 @@ export async function acquireDeviceAsync( timeoutMs: BOOT_DEVICE_TIMEOUT_MS.ios, }); if (!boot.ok) { - await releaseWorktreeDevicesAsync(projectRoot, { platform, tools }); + await releaseWorktreeDevicesAsync(projectRoot, { platform, tools, expected: binding }); throw deviceUnavailableError('boot-failed', { platform, detail: boot.reason ?? undefined }); } return { device, justBooted: !wasBooted, action }; @@ -167,13 +167,18 @@ async function bindIosSectionAsync( /** * Let go of this worktree's local bindings, or only `platform`'s. Never reaps. + * `expected` restricts failed-boot cleanup to that acquisition, leaving a replacement alone. * * A `created` simulator is parked and shut down; an `explicit` one keeps its file with an expired * lease; a binding whose simulator the inventory does not list loses its file. */ export async function releaseWorktreeDevicesAsync( projectRoot: string, - { platform, tools = defaultTools() }: { platform?: 'ios' | 'android'; tools?: DeviceTools } = {} + { + platform, + tools = defaultTools(), + expected, + }: { platform?: 'ios' | 'android'; tools?: DeviceTools; expected?: Binding } = {} ): Promise { if (platform === 'android') { return []; @@ -183,13 +188,14 @@ export async function releaseWorktreeDevicesAsync( if (before.kind !== 'binding' || before.binding.device.backend !== 'local-ios') { return []; } + if (expected && !sameBinding(before.binding, expected)) return []; const { udid, name, origin } = before.binding.device; const listing = await listSimulatorAsync(tools, udid); const letGo = await withRegistryLockAsync( async () => { const read = readBindingFile(file); - if (read.kind !== 'binding') { + if (read.kind !== 'binding' || !sameBinding(read.binding, before.binding)) { return null; } if (listing.kind === 'missing') { @@ -221,3 +227,19 @@ export async function releaseWorktreeDevicesAsync( }, ]; } + +/** Compare the acquisition too: a later dev may have rebound the same device. */ +function sameBinding(a: Binding, b: Binding): boolean { + const identity = ({ projectRoot, boundAt, device }: Binding) => [ + projectRoot, + boundAt, + device.backend, + device.platform, + deviceIdOf(device), + device.backend === 'local-android' ? device.origin.kind : device.origin, + ...(device.backend === 'local-android' && device.origin.kind === 'spawned' + ? [device.origin.avd, device.origin.port, device.origin.emulatorPid] + : []), + ]; + return JSON.stringify(identity(a)) === JSON.stringify(identity(b)); +} From 08b5222b71a80be0c3a3f12e1e2c1679a02f4ab6 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 15 Oct 2026 00:03:11 +0200 Subject: [PATCH 16/17] =?UTF-8?q?[agent-cli]=20A=20read=20verb=20refuses?= =?UTF-8?q?=20gone=20when=20its=20binding=20names=20another=20device=20(ll?= =?UTF-8?q?p/0035=20=C2=A7Lease)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../llp/0035-one-device-per-worktree.rfc.md | 4 ++-- .../deviceBinding/__tests__/inspect-test.ts | 19 +++++++++++++++++++ .../agent-cli/src/deviceBinding/inspect.ts | 11 +++++++++-- .../agent-cli/src/deviceBinding/lease.ts | 19 +++++++++++++++---- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 56836041..5d6ad5e5 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -180,9 +180,9 @@ export type ReleasedDevice = { ## Lease - `LEASE_MS = 3_600_000` (60 min), `EXTEND_EVERY_MS = 300_000` (5 min). -- `extendLease(root, platform, backend, tools, { waitMs })` → `'extended' | 'lost'`, under the lock. A live lease is renewed. An expired or missing one is `lost`, and nothing is written. +- `extendLease(root, platform, backend, tools, { waitMs, deviceId? })` → `'extended' | 'lost'`, under the lock. A live lease is renewed. An expired or missing one, or, with `deviceId`, one whose file names another device, is `lost`, and nothing is written. - A lock not obtained in `waitMs` throws `DEVICE_REGISTRY_LOCKED` before any write. -- Read verbs extend with a 5 s wait through `useBoundDeviceAsync`: a lock timeout warns and goes on; `lost` (the file went missing or expired since the inspect) throws `gone`. +- Read verbs extend with a 5 s wait through `useBoundDeviceAsync`: a lock timeout warns and goes on; `lost` (the file went missing, expired, or names another device since the inspect) throws `gone`. - `isStale(b, now)`: expired, or the root is deleted (Decision 6). `git worktree move` makes the old path stale at once. - `withLeaseExtendedAsync(root, work)`: a 5 min `unref` timer around `work`, cleared when `work` ends. Every tick tries every one of the worktree's four candidate files that exists, so a cloud file that appears after Metro starts, and a parked file that `dev --detach` revived, are both picked up. A 5 s lock wait that fails skips the tick. The timer warns once per change from `extended` to `lost`. A tick never throws. Used by the Metro runner and around the step runner. diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts index 5cc9c76a..3d42b13c 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/inspect-test.ts @@ -124,4 +124,23 @@ describe(useBoundDeviceAsync, () => { expect(error.message).toContain('lease'); expect(tools.calls).toEqual([['list', 'devices', '-j']]); }); + + it('throws gone and writes nothing when the binding names another simulator since the inspect', async () => { + const tools = fakeTools({ + simulators: [ + { udid: 'SIM-1', name: 'x', state: 'Booted' }, + { udid: 'SIM-2', name: 'y', state: 'Booted' }, + ], + }); + writeBinding(file(), bindingFor(ROOT, 'SIM-1', { expiresAt: '2026-10-08T10:30:00.000Z' })); + const inspection = await inspectBindingAsync(ROOT, 'ios', 'local-ios', tools); + writeBinding(file(), bindingFor(ROOT, 'SIM-2', { expiresAt: '2026-10-08T10:30:00.000Z' })); + + const error = await useBoundDeviceAsync(inspection, tools).catch((e) => e); + + expect(error.code).toBe('NO_BOUND_DEVICE'); + expect(error.data).toEqual({ reason: 'gone' }); + const read = readBindingFile(file()); + expect(read.kind === 'binding' && read.binding.expiresAt).toBe('2026-10-08T10:30:00.000Z'); + }); }); diff --git a/packages/@expo/agent-cli/src/deviceBinding/inspect.ts b/packages/@expo/agent-cli/src/deviceBinding/inspect.ts index dd909941..17ef1554 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/inspect.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/inspect.ts @@ -7,7 +7,13 @@ import { listSimulatorAsync, SIMCTL_CALL_TIMEOUT_MS } from './ios'; import { extendLeaseAsync, isExpired } from './lease'; import { bindingPathFor, readBindingFile } from './registry'; import { defaultTools } from './tools'; -import type { BoundDevice, DevicePlatform, DeviceTools, Inspection } from './types'; +import { + deviceIdOf, + type BoundDevice, + type DevicePlatform, + type DeviceTools, + type Inspection, +} from './types'; /** The wait of a read verb's lease extension; a timeout warns and goes on. */ export const READ_LOCK_WAIT_MS = 5_000; @@ -80,7 +86,7 @@ export function clearInspectCache(): void { /** * Take an `up` inspection, extend its lease, and hand back the device. Never inspects again and - * never boots. `lost` throws `gone` with cause `expired`. + * never boots. `lost`, a rebind to another device included, throws `gone` with cause `expired`. */ export async function useBoundDeviceAsync( inspection: Inspection, @@ -95,6 +101,7 @@ export async function useBoundDeviceAsync( try { outcome = await extendLeaseAsync(binding.projectRoot, platform, backend, tools, { waitMs: READ_LOCK_WAIT_MS, + deviceId: deviceIdOf(binding.device), }); } catch (error: unknown) { if ((error as { code?: string }).code !== 'DEVICE_REGISTRY_LOCKED') { diff --git a/packages/@expo/agent-cli/src/deviceBinding/lease.ts b/packages/@expo/agent-cli/src/deviceBinding/lease.ts index 65fee380..8f297006 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/lease.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/lease.ts @@ -9,7 +9,13 @@ import { withRegistryLockAsync, writeBindingFile, } from './registry'; -import type { Binding, BindingBackend, DevicePlatform, DeviceTools } from './types'; +import { + deviceIdOf, + type Binding, + type BindingBackend, + type DevicePlatform, + type DeviceTools, +} from './types'; export const LEASE_MS = 3_600_000; export const EXTEND_EVERY_MS = 300_000; @@ -39,7 +45,8 @@ export function isStale(binding: Binding, now: Date): boolean { } /** - * Renew a live lease under the lock. An expired or missing one is `lost`, and nothing is written. + * Renew a live lease under the lock. An expired or missing one, or, with `deviceId`, one whose + * file names another device, is `lost`, and nothing is written. * * @throws `DEVICE_REGISTRY_LOCKED` when the lock is not obtained in `waitMs`. */ @@ -48,14 +55,18 @@ export async function extendLeaseAsync( platform: DevicePlatform, backend: BindingBackend, tools: DeviceTools, - { waitMs }: { waitMs: number } + { waitMs, deviceId }: { waitMs: number; deviceId?: string } ): Promise<'extended' | 'lost'> { const file = bindingPathFor(projectRoot, platform, backend); return await withRegistryLockAsync( async () => { const read = readBindingFile(file); const now = tools.now(); - if (read.kind !== 'binding' || isExpired(read.binding, now)) { + if ( + read.kind !== 'binding' || + isExpired(read.binding, now) || + (deviceId != null && deviceIdOf(read.binding.device) !== deviceId) + ) { return 'lost'; } writeBindingFile(file, { From cb0a2e81c181f07858b4bccc19621ed7a3c867dd Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 15 Oct 2026 00:04:33 +0200 Subject: [PATCH 17/17] =?UTF-8?q?[agent-cli]=20Choose=20the=20highest-numb?= =?UTF-8?q?ered=20mainstream=20iPhone=20in=20any=20listing=20order=20(llp/?= =?UTF-8?q?0035=20=C2=A7Choice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../llp/0035-one-device-per-worktree.rfc.md | 2 +- .../src/deviceBinding/__tests__/ios-test.ts | 19 +++++++++++++++++++ .../@expo/agent-cli/src/deviceBinding/ios.ts | 16 +++++++++++----- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md index 5d6ad5e5..e277f331 100644 --- a/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md +++ b/packages/@expo/agent-cli/llp/0035-one-device-per-worktree.rfc.md @@ -195,7 +195,7 @@ export type ReleasedDevice = { ## Choice (`choose.ts`, pure) -Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`: the newest available iOS runtime that lists a mainstream iPhone, with its first `iPhone Pro`, else its first `iPhone `, because the newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone); only when no runtime has one does the newest runtime's first iPhone type apply. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs: +Inputs: the own binding and the other worktrees' bindings, each marked stale or not; the inventory, read before the lock; `explicit`; `reuseOnly`; `now`; on Android `busyPorts`, computed by `android.ts` in the same section: the running `emulator-NNNN` serials, the Android bindings whose lease is live, and every Android binding whose `emulatorPid` is alive, because a reap kills it only after the section. The free port is the first free even one in 5554..5584. `IosInventory` holds `SimulatorEntry[]` and `newestIosRuntime: { identifier, deviceType } | null`: the newest available iOS runtime that lists a mainstream iPhone, with its highest-numbered `iPhone Pro`, else `iPhone `, else `iPhone Pro Max`, whatever order `simctl` lists them in, because the newest runtime can list only a device no app renders on (iOS 27.1 lists iPhone Duo alone); only when no runtime has one does the newest runtime's first iPhone type apply. `AndroidInventory` holds the first AVD of `emulator -list-avds` and `runningSerials`. Outputs: ```ts | { kind: 'reuse'; binding } // own binding, live or expired, device present (iOS: listed and available; Android spawned: emulatorPid alive; Android explicit: serial listed); also --device naming the own device diff --git a/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts b/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts index 06e6a4e4..2fe9abe9 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/__tests__/ios-test.ts @@ -41,6 +41,25 @@ describe(parseNewestIosRuntime, () => { ).toMatchObject({ deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17' }); }); + it(`takes the highest-numbered Pro whatever the listing order, and a newer runtime's Pro Max over an older runtime's Pro`, () => { + expect( + parseNewestIosRuntime( + listing(runtime('27.0', ['iPhone 11 Pro', 'iPhone 18 Pro', 'iPhone 18 Pro Max'])) + ) + ).toMatchObject({ deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro' }); + expect( + parseNewestIosRuntime( + listing( + runtime('26.4', ['iPhone 17 Pro']), + runtime('27.0', ['iPhone 18 Pro Max', 'iPhone Air']) + ) + ) + ).toEqual({ + identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-27-0', + deviceType: 'com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro-Max', + }); + }); + it(`falls back to the newest runtime's first iPhone when no runtime has a mainstream one`, () => { expect( parseNewestIosRuntime( diff --git a/packages/@expo/agent-cli/src/deviceBinding/ios.ts b/packages/@expo/agent-cli/src/deviceBinding/ios.ts index 46973730..12d0c885 100644 --- a/packages/@expo/agent-cli/src/deviceBinding/ios.ts +++ b/packages/@expo/agent-cli/src/deviceBinding/ios.ts @@ -67,12 +67,18 @@ export function parseNewestIosRuntime(stdout: string): IosInventory['newestIosRu : null; } -/** An `iPhone Pro`, else an `iPhone `, by the name `simctl` lists the type under. */ +/** + * The highest-numbered `iPhone Pro`, else `iPhone `, else `iPhone Pro Max`, by the name + * `simctl` lists the type under, whatever order it lists them in. + */ function mainstreamIphone(types: { identifier: string; name?: string }[]): string | null { - for (const pattern of [/^iPhone \d+ Pro$/, /^iPhone \d+$/]) { - const match = types.find((type) => pattern.test(type.name ?? '')); - if (match) { - return match.identifier; + for (const pattern of [/^iPhone (\d+) Pro$/, /^iPhone (\d+)$/, /^iPhone (\d+) Pro Max$/]) { + const [highest] = types + .map((type) => ({ type, number: Number(pattern.exec(type.name ?? '')?.[1]) })) + .filter(({ number }) => !Number.isNaN(number)) + .sort((left, right) => right.number - left.number); + if (highest) { + return highest.type.identifier; } } return null;