From 50a78f31a9b17b9bfe4df3189a5ac088f555c5c3 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 23 Oct 2026 15:25:32 -0700 Subject: [PATCH 01/42] [simulator-launcher] Prepare Simulator services at guest startup Edited-by: Codex --- .changeset/config.json | 3 +- .changeset/simulator-launcher.md | 9 + packages/simulator-launcher/README.md | 59 ++ .../simulator-launcher/bin/simulator-launcher | 2 + packages/simulator-launcher/jest.config.ts | 12 + packages/simulator-launcher/package.json | 37 ++ .../src/__tests__/expoGo.test.ts | 397 +++++++++++++ .../src/__tests__/main.test.ts | 525 ++++++++++++++++++ .../src/__tests__/processes.test.ts | 286 ++++++++++ .../src/__tests__/simulatorSession.test.ts | 164 ++++++ .../src/__tests__/state.test.ts | 234 ++++++++ .../simulator-launcher/src/commands/claim.ts | 46 ++ .../simulator-launcher/src/commands/start.ts | 417 ++++++++++++++ .../simulator-launcher/src/commands/stop.ts | 52 ++ packages/simulator-launcher/src/main.ts | 63 +++ .../src/utils/agentDevice.ts | 46 ++ .../simulator-launcher/src/utils/argent.ts | 63 +++ .../simulator-launcher/src/utils/expoGo.ts | 130 +++++ .../src/utils/installedLauncher.ts | 24 + .../simulator-launcher/src/utils/processes.ts | 172 ++++++ .../simulator-launcher/src/utils/serveSim.ts | 28 + .../simulator-launcher/src/utils/state.ts | 116 ++++ packages/simulator-launcher/tsconfig.json | 16 + yarn.lock | 16 + 24 files changed, 2916 insertions(+), 1 deletion(-) create mode 100644 .changeset/simulator-launcher.md create mode 100644 packages/simulator-launcher/README.md create mode 100755 packages/simulator-launcher/bin/simulator-launcher create mode 100644 packages/simulator-launcher/jest.config.ts create mode 100644 packages/simulator-launcher/package.json create mode 100644 packages/simulator-launcher/src/__tests__/expoGo.test.ts create mode 100644 packages/simulator-launcher/src/__tests__/main.test.ts create mode 100644 packages/simulator-launcher/src/__tests__/processes.test.ts create mode 100644 packages/simulator-launcher/src/__tests__/simulatorSession.test.ts create mode 100644 packages/simulator-launcher/src/__tests__/state.test.ts create mode 100644 packages/simulator-launcher/src/commands/claim.ts create mode 100644 packages/simulator-launcher/src/commands/start.ts create mode 100644 packages/simulator-launcher/src/commands/stop.ts create mode 100644 packages/simulator-launcher/src/main.ts create mode 100644 packages/simulator-launcher/src/utils/agentDevice.ts create mode 100644 packages/simulator-launcher/src/utils/argent.ts create mode 100644 packages/simulator-launcher/src/utils/expoGo.ts create mode 100644 packages/simulator-launcher/src/utils/installedLauncher.ts create mode 100644 packages/simulator-launcher/src/utils/processes.ts create mode 100644 packages/simulator-launcher/src/utils/serveSim.ts create mode 100644 packages/simulator-launcher/src/utils/state.ts create mode 100644 packages/simulator-launcher/tsconfig.json diff --git a/.changeset/config.json b/.changeset/config.json index 9313193c89..d5ddfe2f3b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -20,7 +20,8 @@ "@expo/turtle-spawn", "@expo/template-file", "create-eas-build-function", - "@expo/vm-tools" + "@expo/vm-tools", + "@expo/simulator-launcher" ] ], "linked": [], diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md new file mode 100644 index 0000000000..d3975bc14e --- /dev/null +++ b/.changeset/simulator-launcher.md @@ -0,0 +1,9 @@ +--- +'@expo/simulator-launcher': minor +--- + +Add start, stop and claim commands for warm Simulator sessions. Claiming reuses the warm device or replaces it with the requested device after cleanup. + +Start unused-tool shutdown during claim without delaying handoff. Drain it during terminal cleanup; a shutdown failure prevents a stopped acknowledgement. + +Preinstall the latest stable Expo Go during initial warmup without delaying the candidate. Claim prevents late warmup installs and waits for one already in progress before handing the device to the job. diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md new file mode 100644 index 0000000000..3ddf9a05ce --- /dev/null +++ b/packages/simulator-launcher/README.md @@ -0,0 +1,59 @@ +# @expo/simulator-launcher + +Prepare an iOS Simulator with serve-sim, agent-device and Argent before an EAS job arrives. +Requires macOS. Only vm-tools is image-installed; its startup script runs the launcher: + +```sh +export EXPO_SIMULATOR_STARTUP=1 +export EXPO_SIMULATOR_DEVICE="iPhone 17" +exec bunx @expo/simulator-launcher@latest start +``` + +## Commands + +- `start`: boot the selected Simulator and launch the tools in the foreground. +- `claim ENGINE [DEVICE]`: hand the warm device to one job. Engines: `serve-sim`, `agent-device`, + `argent`. Omit DEVICE to reuse it, or pass an exact name or UDID to select another. + Replacement cleans up the old session before starting the requested device. +- `stop`: disable preparation for this VM boot and wait for owned resources to be cleaned up. + +```sh +node "$HOME/.expo/simulator-tools/run" claim serve-sim +# Returns one JSON record with the device, boot, engine, controller identity and tool paths. +bunx @expo/simulator-launcher@latest stop +``` + +`start` copies its self-contained JavaScript bundle to `$HOME/.expo/simulator-tools/run`. +Later package `claim`/`stop` commands use that installed bundle, so the same implementation controls +this boot even if `latest` changes; the direct Node command works without package resolution. + +Before any launcher has downloaded, the package `stop` command still disables later preparation. +Once startup has claimed this boot, stop requires its cleanup confirmation, even if the copy is missing. + +The launcher has one durable state file: `/tmp/expo-simulator-startup.json`, shared by all commands +regardless of HOME/TMPDIR. It contains the lifecycle, claim and native session metadata. A short +boot-scoped filesystem lock serializes updates; an abandoned lock fails closed without takeover. +This private format requires a fresh VM; there is no live migration. + +## Environment + +The startup script owns Node/Bun/PATH setup and these optional overrides: + +- `EXPO_SIMULATOR_STARTUP=1` enables preparation; `EXPO_SIMULATOR_DEVICE` selects an exact name or UDID. + Without a selector, start uses the shortest available iPhone name. +- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: tool versions, each defaulting to `latest`. +- `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations. +- Agent-device daemon/runner idle timers default to off. Override with + `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS` / `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` (milliseconds). + +## Boundaries + +- Preparation is a warmup opportunity, not proof that every service is ready. Build-tools checks + the tools and performs serve-sim's private `--adopt` handoff before app actions. +- One start attempt and one accepted claim per VM boot. Stop is permanent for that boot; + a failed replacement cannot skip cleanup and start another device. +- Warmup also tries to preinstall the latest stable Expo Go and install missing ffmpeg with Homebrew. + Ordinary failures warn. Claim prevents a late Expo Go install, waiting only if native install already + started; the job's requested app remains authoritative. +- Stop has a caller timeout, not a global cleanup deadline. A timeout or cleanup error does not prove + teardown completed, and EAS must not start a fallback session until cleanup is confirmed. diff --git a/packages/simulator-launcher/bin/simulator-launcher b/packages/simulator-launcher/bin/simulator-launcher new file mode 100755 index 0000000000..23c4c41457 --- /dev/null +++ b/packages/simulator-launcher/bin/simulator-launcher @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../dist/index').runCliAsync(); diff --git a/packages/simulator-launcher/jest.config.ts b/packages/simulator-launcher/jest.config.ts new file mode 100644 index 0000000000..9d280c0859 --- /dev/null +++ b/packages/simulator-launcher/jest.config.ts @@ -0,0 +1,12 @@ +import type { Config } from '@jest/types'; + +import baseConfig from '../../jest/jest.shared.config'; + +const config: Config.InitialOptions = { + ...baseConfig, + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, +}; + +export default config; diff --git a/packages/simulator-launcher/package.json b/packages/simulator-launcher/package.json new file mode 100644 index 0000000000..55b99d1dbc --- /dev/null +++ b/packages/simulator-launcher/package.json @@ -0,0 +1,37 @@ +{ + "name": "@expo/simulator-launcher", + "version": "25.3.0", + "description": "Launch and claim Simulator sessions on EAS workers", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "author": "Expo ", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/simulator-launcher" + }, + "bin": "bin/simulator-launcher", + "files": [ + "bin", + "dist/index.js" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "jest --config jest.config.ts", + "build": "ncc build src/main.ts -o dist --no-cache --no-source-map-register", + "typecheck": "tsc --noEmit", + "prepack": "yarn build", + "rebuild": "yarn build" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "22.20.5", + "@vercel/ncc": "^0.38.1", + "jest": "^29.7.0", + "ts-jest": "^29.4.12", + "typescript": "^6.0.3", + "zod": "^4.3.5" + } +} diff --git a/packages/simulator-launcher/src/__tests__/expoGo.test.ts b/packages/simulator-launcher/src/__tests__/expoGo.test.ts new file mode 100644 index 0000000000..11ffb52898 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/expoGo.test.ts @@ -0,0 +1,397 @@ +import fs from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; +import os from 'node:os'; +import path from 'node:path'; + +import { startExpoGoPreinstall } from '../utils/expoGo'; +import { commands } from '../utils/processes'; + +const runCommandAsync = commands.runAsync.bind(commands); +function createArchive(entries: { name: string; contents: Buffer }[]): Buffer { + const blocks: Buffer[] = []; + for (const { name, contents } of entries) { + const header = Buffer.alloc(512); + header.write(name); + header.write('0000644\0', 100); + header.write(contents.length.toString(8).padStart(11, '0') + '\0', 124); + header.fill(32, 148, 156); + header.write('0', 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); + blocks.push(header, contents, Buffer.alloc((512 - (contents.length % 512)) % 512)); + } + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); +} +function installCalls() { + return jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'xcrun'); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 400 && !predicate(); i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + expect(predicate()).toBe(true); +} + +let directory: string; +let archive: Buffer; +let controller: AbortController; +let warmup: ReturnType | undefined; +const stableUrl = 'https://example.test/Expo-Go-stable.tar.gz'; +const sdkVersions = { + '57.0.0': { + releaseNoteUrl: 'https://example.test/57', + iosClientUrl: 'https://example.test/old.tar.gz', + }, + '58.0.0': { releaseNoteUrl: 'https://example.test/58', iosClientUrl: stableUrl }, + '59.0.0': { + isBeta: true, + releaseNoteUrl: 'https://example.test/beta', + iosClientUrl: 'https://example.test/beta.tar.gz', + }, + '60.0.0': { iosClientUrl: 'https://example.test/canary.tar.gz' }, + '61.1.0': { + releaseNoteUrl: 'https://example.test/not-published', + iosClientUrl: 'https://example.test/not-published.tar.gz', + }, +}; + +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'expo-go-unit-')); + archive = createArchive([ + { name: 'Info.plist', contents: Buffer.from('fixture') }, + ]); + jest.spyOn(os, 'tmpdir').mockReturnValue(directory); + jest + .spyOn(global, 'fetch') + .mockImplementation(async url => + url === 'https://api.expo.dev/v2/versions' + ? Response.json({ iosUrl: 'https://example.test/legacy.tar.gz', sdkVersions }) + : new Response(Uint8Array.from(archive)) + ); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args, signal) => + file === '/usr/bin/tar' ? await runCommandAsync(file, args, signal) : '' + ); + jest.spyOn(commands, 'throwIfCommandCloseTimedOut').mockImplementation(() => {}); + jest.spyOn(process.stderr, 'write').mockReturnValue(true); + controller = new AbortController(); + warmup = undefined; +}); +afterEach(async () => { + controller.abort(); + await warmup?.stopAsync().catch(() => {}); + jest.restoreAllMocks(); + await fs.rm(directory, { recursive: true, force: true }); +}); + +it('uses the website stable release policy, extracts flat contents as .app and installs only after boot', async () => { + const boot = deferred(); + warmup = startExpoGoPreinstall('UDID', boot.promise, controller.signal); + try { + await until(() => jest.mocked(fetch).mock.calls.length === 2); + expect(fetch).toHaveBeenLastCalledWith( + stableUrl, + expect.objectContaining({ + headers: { + Accept: 'application/json', + 'User-Agent': 'expo-simulator-launcher', + }, + }) + ); + expect(installCalls()).toHaveLength(0); + boot.resolve(); + await warmup.resultPromise; + expect(installCalls()).toHaveLength(1); + const [file, args, signal] = installCalls()[0]; + expect(file).toBe('xcrun'); + expect(args.slice(0, 3)).toEqual(['simctl', 'install', 'UDID']); + expect(path.basename(args[3])).toBe('ExpoGo.app'); + expect(signal).toBeUndefined(); // Never cancel an in-flight installd transaction through simctl. + await expect(fs.stat(path.dirname(args[3]))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + boot.resolve(); + } +}); + +it('claim aborts a pending download without waiting for it, and a late fetch cannot install', async () => { + const pending = deferred(); + jest.mocked(fetch).mockReturnValueOnce(pending.promise); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + try { + await until(() => jest.mocked(fetch).mock.calls.length > 0); + await warmup.claimAsync(); + expect(jest.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true); + pending.resolve(Response.json({ sdkVersions })); // Even an uncooperative late fetch cannot install. + await warmup.stopAsync(); + expect(installCalls()).toHaveLength(0); + expect( + (await fs.readdir(directory)).filter(name => name.startsWith('simulator-expo-go-')) + ).toEqual([]); + } finally { + pending.resolve(Response.json({ sdkVersions })); + } +}); + +it('claim only waits for the native install; terminal stop also drains temporary-file cleanup', async () => { + const installation = deferred(); + const cleanup = deferred(); + const remove = fs.rm.bind(fs); + let cleanupStarted = false; + jest.spyOn(fs, 'rm').mockImplementation(async (file, options) => { + cleanupStarted = true; + await cleanup.promise; + return await remove(file, options); + }); + jest + .mocked(commands.runAsync) + .mockImplementation(async (file, args) => + file === '/usr/bin/tar' ? await runCommandAsync(file, args) : await installation.promise + ); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + try { + await until(() => installCalls().length > 0); + let claimed = false; + const claiming = warmup.claimAsync().then(() => { + claimed = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(claimed).toBe(false); + installation.resolve(''); + await claiming; + expect(claimed).toBe(true); + await until(() => cleanupStarted); + let stopped = false; + const stopping = warmup.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + cleanup.resolve(); + await stopping; + } finally { + installation.resolve(''); + cleanup.resolve(); + } +}); + +it('claim during extraction returns without waiting but cannot begin an install afterward', async () => { + const extraction = deferred(); + let extracting = false; + jest.mocked(commands.runAsync).mockImplementation(async (file, args) => { + expect(file).toBe('/usr/bin/tar'); + extracting = true; + await extraction.promise; + return await runCommandAsync(file, args); + }); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + try { + await until(() => extracting); + await warmup.claimAsync(); + expect(installCalls()).toHaveLength(0); + let stopped = false; + const stopping = warmup.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + extraction.resolve(); + await stopping; + expect(installCalls()).toHaveLength(0); + } finally { + extraction.resolve(); + } +}); + +it.each(['metadata', 'archive', 'install'])( + 'warns on an ordinary %s failure without failing warmup', + async phase => { + if (phase === 'metadata') { + jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 })); + } + if (phase === 'archive') { + jest + .mocked(fetch) + .mockResolvedValueOnce(Response.json({ sdkVersions })) + .mockResolvedValueOnce(new Response('', { status: 503 })); + } + if (phase === 'install') { + jest.mocked(commands.runAsync).mockImplementation(async (file, args) => { + if (file === '/usr/bin/tar') { + return await runCommandAsync(file, args); + } + throw Object.assign(new Error('install failed'), { code: 1 }); + }); + } + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + await warmup.resultPromise; + await warmup.claimAsync(); + await warmup.stopAsync(); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Could not preinstall Expo Go') + ); + } +); + +it.each(['signal', 'close timeout', 'temp removal'])( + 'fails closed on %s instead of treating it as optional installation failure', + async kind => { + const error = Object.assign(new Error(`uncertain ${kind}`), { + code: kind === 'signal' ? null : 1, + }); + if (kind === 'temp removal') { + jest.spyOn(fs, 'rm').mockRejectedValueOnce(error); + } else { + jest.mocked(commands.runAsync).mockImplementation(async (file, args) => { + if (file === '/usr/bin/tar') { + return await runCommandAsync(file, args); + } + if (kind === 'close timeout') { + jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => { + throw error; + }); + } + throw error; + }); + } + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + await expect(warmup.resultPromise).rejects.toBe(error); + await expect(warmup.claimAsync()).rejects.toBe(error); + await expect(warmup.stopAsync()).rejects.toBe(error); + } +); + +it.each(['valid', 'traversal', 'malformed'] as const)( + 'drains the real owned extractor for a %s archive before removing temp files or settling stop', + async kind => { + const contents = Buffer.alloc(4 * 1024 * 1024, 97); + archive = + kind === 'malformed' + ? Buffer.from('not a gzip or tar archive') + : createArchive([ + ...(kind === 'traversal' + ? [{ name: '../escaped', contents: Buffer.from('unsafe') }] + : []), + { name: 'Info.plist', contents: Buffer.from('fixture') }, + { name: 'ordinary-file', contents }, + ]); + const start = commands.start.bind(commands); + let extractor: ReturnType | undefined; + let extractorClosed = false; + const remove = fs.rm.bind(fs); + const removing = jest.spyOn(fs, 'rm').mockImplementation(async (file, options) => { + expect(extractorClosed).toBe(true); + if (kind === 'traversal') { + await expect(fs.access(path.join(String(file), 'escaped'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + if (kind !== 'malformed') { + expect( + (await fs.readFile(path.join(String(file), 'ExpoGo.app', 'ordinary-file'))).equals( + contents + ) + ).toBe(true); + } + return await remove(file, options); + }); + jest.spyOn(commands, 'start').mockImplementation((file, args, signal, capture) => { + expect(file).toBe('/usr/bin/tar'); + expect(args).toEqual([ + '-xzf', + expect.stringMatching(/\/expo-go.tar.gz$/), + '-C', + expect.stringMatching(/\/ExpoGo.app$/), + ]); + expect(signal).toBeUndefined(); + extractor = start(file, args, signal, capture); + extractor.child.once('close', () => { + extractorClosed = true; + }); + expect(extractor.child.kill('SIGSTOP')).toBe(true); + return extractor; + }); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + try { + await until(() => extractor !== undefined); + await warmup.claimAsync(); // Only forbids new install; extraction remains owned and pending. + expect(extractorClosed).toBe(false); + let stopped = false; + const stopping = warmup.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + expect(removing).not.toHaveBeenCalled(); + extractor!.child.kill('SIGCONT'); + await stopping; + expect(extractorClosed).toBe(true); + expect(removing).toHaveBeenCalledTimes(1); + expect(installCalls()).toHaveLength(0); + expect(await fs.readdir(directory)).toEqual([]); + if (kind !== 'valid') { + const extractionError = await extractor!.resultPromise.catch(error => error); + expect(extractionError.code).toBeGreaterThan(0); + } + } finally { + // Reap only this test's owned child, including an assertion failure while it is stopped. + if (extractor?.child.exitCode == null && extractor?.child.signalCode == null) { + extractor?.child.kill('SIGCONT'); + extractor?.child.kill('SIGKILL'); + } + await extractor?.resultPromise.catch(() => {}); + removing.mockRestore(); + } + } +); + +it('keeps owned temp files and rejects terminal stop when extractor close is uncertain', async () => { + const failure = new Error('extractor close not proved'); + const remove = jest.spyOn(fs, 'rm'); + jest.mocked(commands.runAsync).mockImplementationOnce(async file => { + expect(file).toBe('/usr/bin/tar'); + jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => { + throw failure; + }); + throw failure; + }); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + await expect(warmup.resultPromise).rejects.toBe(failure); + await expect(warmup.stopAsync()).rejects.toBe(failure); + await expect(warmup.claimAsync()).rejects.toBe(failure); + expect(remove).not.toHaveBeenCalled(); + expect((await fs.readdir(directory)).some(name => name.startsWith('simulator-expo-go-'))).toBe( + true + ); + expect(installCalls()).toHaveLength(0); +}); + +it.each(['traversal', 'malformed'] as const)( + 'never installs after a real %s archive failure without a claim', + async kind => { + archive = + kind === 'malformed' + ? Buffer.from('not an archive') + : createArchive([ + { name: '../escaped', contents: Buffer.from('unsafe') }, + { name: 'Info.plist', contents: Buffer.from('fixture') }, + ]); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + await warmup.resultPromise; + expect(installCalls()).toHaveLength(0); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Could not preinstall Expo Go') + ); + expect(await fs.readdir(directory)).toEqual([]); + } +); diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts new file mode 100644 index 0000000000..e2e6add6bc --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -0,0 +1,525 @@ +import { type ChildProcess } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { claimAsync } from '../commands/claim'; +import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start'; +import { stopAsync } from '../commands/stop'; +import { mainAsync } from '../main'; +import { startExpoGoPreinstall } from '../utils/expoGo'; +import { type ChildProcessHandle, commands } from '../utils/processes'; +import * as processes from '../utils/processes'; +import { + LAUNCHER_STATE_FILE, + readLauncherStateAsync, + updateLauncherStateAsync, + waitForLauncherStateAsync, +} from '../utils/state'; + +jest.mock('../utils/expoGo'); +const boot = 'boot-one'; +let root: string; +let environment: NodeJS.ProcessEnv; +let active: Promise[]; +let jobs: ChildProcessHandle[]; +const platform = process.platform; +const deviceList = { + devices: { + ios: [ + { name: 'iPhone 17', udid: 'PHONE' }, + { name: 'iPhone 17 Pro', udid: 'PRO' }, + ], + }, +}; + +const nativeFs = { + readFile: fs.readFile.bind(fs), + writeFile: fs.writeFile.bind(fs), + rename: fs.rename.bind(fs), + rm: fs.rm.bind(fs), + mkdir: fs.mkdir.bind(fs), + rmdir: fs.rmdir.bind(fs), + stat: fs.stat.bind(fs), +}; +const remap = (file: unknown): unknown => + typeof file === 'string' && file.startsWith(LAUNCHER_STATE_FILE) + ? path.join(root, 'control', path.basename(file)) + : file; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +async function until(check: () => boolean): Promise { + for (let i = 0; i < 200 && !check(); i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + expect(check()).toBe(true); +} +async function status(value: string) { + return await waitForLauncherStateAsync(boot, state => state?.status === value, 2_000); +} +async function beginSession(): Promise[]> { + await updateLauncherStateAsync(boot, () => ({ + bootUuid: boot, + status: 'starting', + controllerPid: process.pid, + })); + const completion = runWarmSimulatorSessionAsync(boot).catch(error => error); + active.push(completion); + return [completion]; +} +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-session-')); + jest.spyOn(os, 'homedir').mockReturnValue(root); + await fs.mkdir(path.join(root, 'control')); + for (const name of ['readFile', 'writeFile', 'rename', 'rm', 'mkdir', 'rmdir', 'stat'] as const) { + jest.spyOn(fs, name).mockImplementation(((...args: unknown[]) => { + args[0] = remap(args[0]); + if (name === 'rename') { + args[1] = remap(args[1]); + } + return (nativeFs[name] as (...args: unknown[]) => unknown)(...args); + }) as never); + } + + Object.defineProperty(process, 'platform', { value: 'darwin' }); + environment = { ...process.env }; + process.env.EXPO_SIMULATOR_STARTUP = '1'; + active = []; + jobs = []; + jest + .mocked(startExpoGoPreinstall) + .mockReset() + .mockReturnValue({ + resultPromise: Promise.resolve(), + claimAsync: jest.fn(async () => {}), + stopAsync: jest.fn(async () => {}), + }); + jest.spyOn(commands, 'runAsync').mockImplementation(async (file, args) => { + if (file === '/usr/sbin/sysctl') { + return boot; + } + if (args.includes('lstart=')) { + return 'Thu Oct 22 10:00:00 2026'; + } + if (args.includes('list')) { + return JSON.stringify(deviceList); + } + return ''; + }); + jest.spyOn(commands, 'start').mockImplementation(() => { + const job = { + child: { + pid: 90000 + jobs.length, + exitCode: null, + signalCode: null, + kill: jest.fn(), + } as unknown as ChildProcess, + resultPromise: Promise.resolve(''), + }; + jobs.push(job); + return job; + }); + jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue(); +}); +afterEach(async () => { + if (active.length) { + process.emit('SIGTERM', 'SIGTERM'); + } + await Promise.allSettled(active); + jest.restoreAllMocks(); + process.env = environment; + Object.defineProperty(process, 'platform', { value: platform }); + await fs.rm(root, { recursive: true, force: true }); +}); + +test('concurrent starts acquire one session and pin only the winning bundle', async () => { + const copy = jest.spyOn(fs, 'copyFile'); + const results: unknown[] = []; + const starts = [0, 1].map(() => + startAsync(boot, require.resolve('../main')).then( + value => results.push(value), + error => results.push(error) + ) + ); + active.push(...starts); + await status('ready'); + await until(() => results.length === 1); + expect(results[0]).toBeInstanceOf(Error); + expect(copy).toHaveBeenCalledTimes(1); + expect(await fs.readFile(path.join(root, '.expo/simulator-tools/run'), 'utf8')).toContain( + 'runCliAsync' + ); + expect(jobs).toHaveLength(3); + await stopAsync(boot); + await Promise.all(starts); + expect((await readLauncherStateAsync(boot))?.status).toBe('disabled'); +}); + +test('stop before download makes every later start a resource-free disabled no-op', async () => { + expect(await stopAsync(boot)).toBe(0); + const copy = jest.spyOn(fs, 'copyFile'); + expect(await startAsync(boot, 'not-downloaded')).toBe(0); + expect(copy).not.toHaveBeenCalled(); + expect(commands.runAsync).not.toHaveBeenCalled(); + expect(commands.start).not.toHaveBeenCalled(); + expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' }); +}); + +test('package stop under a different HOME and TMPDIR still waits for the active session cleanup', async () => { + const [completion] = await beginSession(); + await status('ready'); + const drain = deferred(); + jest.mocked(processes.stopProcessAndDescendantsAsync).mockImplementation(async () => { + await drain.promise; + }); + const otherHome = path.join(root, 'different-home'); + process.env.HOME = otherHome; + process.env.TMPDIR = path.join(root, 'different-tmp'); + jest.mocked(os.homedir).mockReturnValue(otherHome); + let finished = false; + const stopping = mainAsync(['stop']).then(value => { + finished = true; + return value; + }); + try { + await status('stopping'); + expect(finished).toBe(false); + expect((await readLauncherStateAsync(boot))?.status).toBe('stopping'); + drain.resolve(); + expect(await stopping).toBe(0); + expect(await completion).toBeUndefined(); + expect((await readLauncherStateAsync(boot))?.status).toBe('disabled'); + } finally { + drain.resolve(); + await stopping; + } +}); + +test('failed bundle pin stays fail-closed instead of fabricating cleanup', async () => { + jest.spyOn(fs, 'copyFile').mockRejectedValueOnce(new Error('copy failed')); + await expect(startAsync(boot, 'source')).rejects.toThrow('copy failed'); + expect((await readLauncherStateAsync(boot))?.status).toBe('starting'); + await expect(startAsync(boot, 'source')).rejects.toThrow('already'); + expect(commands.start).not.toHaveBeenCalled(); +}); + +test('startup returns native identity and effective paths in the single ready record', async () => { + const [completion] = await beginSession(); + const ready = await status('ready'); + expect(ready).toMatchObject({ + controllerPid: process.pid, + controllerStartTime: 'Thu Oct 22 10:00:00 2026', + device: { udid: 'PHONE' }, + }); + expect(ready?.agentDeviceStateDir).toContain(root); + expect(ready?.argentEventLogPath).toContain(root); + expect(await fs.readdir(path.join(root, 'control'))).toEqual([ + path.basename(LAUNCHER_STATE_FILE), + ]); + await stopAsync(boot); + expect(await completion).toBeUndefined(); +}); + +for (const slower of ['boot', 'config']) { + test(`serve-sim and agent depend only on boot while ${slower} is pending`, async () => { + const booting = deferred(); + const config = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('bootstatus') + ? booting.promise + : args.includes('artifacts-list-endpoint') + ? config.promise + : original(file, args, signal) + ); + const [completion] = await beginSession(); + try { + await until(() => + jest + .mocked(commands.runAsync) + .mock.calls.some(([, args]) => args.includes('artifacts-list-endpoint')) + ); + if (slower === 'config') { + booting.resolve(''); + await until(() => jobs.length === 2); + expect(jobs).toHaveLength(2); + } else { + config.resolve(''); + await new Promise(resolve => setImmediate(resolve)); + expect(jobs).toHaveLength(0); + } + booting.resolve(''); + config.resolve(''); + await status('ready'); + expect(jobs).toHaveLength(3); + await stopAsync(boot); + expect(await completion).toBeUndefined(); + } finally { + booting.resolve(''); + config.resolve(''); + } + }); +} + +test('prepare failure drains owned services and records the failure after confirmed cleanup', async () => { + jest.mocked(commands.start).mockImplementation((_file, args) => { + const job = { + child: { pid: 90000 + jobs.length, exitCode: null, signalCode: null } as ChildProcess, + resultPromise: args.includes('prepare') + ? Promise.reject(new Error('prepare failed')) + : Promise.resolve(''), + }; + void job.resultPromise.catch(() => {}); + jobs.push(job); + return job; + }); + const [completion] = await beginSession(); + expect(((await completion) as Error).message).toBe('prepare failed'); + expect( + jest.mocked(processes.stopProcessAndDescendantsAsync).mock.calls.map(([job]) => job) + ).toEqual(jobs); + expect(await readLauncherStateAsync(boot)).toMatchObject({ + status: 'disabled', + error: 'prepare failed', + }); +}); + +test('failed cleanup remains stopping with a reason and never reports disabled', async () => { + const [completion] = await beginSession(); + await status('ready'); + jest + .mocked(processes.stopProcessAndDescendantsAsync) + .mockRejectedValue(new Error('owned child still running')); + process.emit('SIGTERM', 'SIGTERM'); + expect(await completion).toBeInstanceOf(AggregateError); + expect(await readLauncherStateAsync(boot)).toMatchObject({ + status: 'stopping', + error: expect.stringContaining('cleanup could not be confirmed'), + }); +}); + +for (const selector of [undefined, 'phone', 'iPhone 17 Pro']) { + test(`claim selects ${selector ?? 'the warm device'} and returns the claimed metadata record`, async () => { + const [completion] = await beginSession(); + await status('ready'); + const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + await claimAsync(boot, 'serve-sim', selector); + const result = JSON.parse(String(output.mock.calls[0][0])); + const replacement = selector === 'iPhone 17 Pro'; + expect(result).toMatchObject({ + bootUuid: boot, + status: 'claimed', + engine: 'serve-sim', + controllerPid: process.pid, + device: { udid: replacement ? 'PRO' : 'PHONE' }, + }); + expect(result.agentDeviceStateDir).toBeTruthy(); + expect( + jest.mocked(commands.runAsync).mock.calls.filter(([, args]) => args.includes('list')) + ).toHaveLength(1); + if (replacement) { + const calls = jest.mocked(commands.runAsync).mock.calls; + expect( + calls.findIndex(([, args]) => args.join(' ') === 'simctl shutdown PHONE') + ).toBeLessThan(calls.findIndex(([, args]) => args.join(' ') === 'simctl bootstatus PRO -b')); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); + } + await expect(claimAsync(boot, 'argent')).rejects.toThrow('available'); + await stopAsync(boot); + expect(await completion).toBeUndefined(); + }); +} + +test('a device snapshot miss refreshes once to select a newly available device', async () => { + let lists = 0; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => + args.includes('list') + ? Promise.resolve( + JSON.stringify({ + devices: { + ios: [ + ...deviceList.devices.ios, + ...(lists++ ? [{ name: 'iPhone 18', udid: 'NEW' }] : []), + ], + }, + }) + ) + : original(file, args, signal) + ); + const [completion] = await beginSession(); + await status('ready'); + jest.spyOn(process.stdout, 'write').mockReturnValue(true); + await claimAsync(boot, 'argent', 'NEW'); + expect(lists).toBe(2); + expect((await readLauncherStateAsync(boot))?.device?.udid).toBe('NEW'); + await stopAsync(boot); + await completion; +}); + +for (const engine of ['serve-sim', 'agent-device', 'argent']) { + test(`${engine} claim does not wait for unused tools, but terminal cleanup does`, async () => { + const [completion] = await beginSession(); + await status('ready'); + const drain = deferred(); + jest.mocked(processes.stopProcessAndDescendantsAsync).mockImplementation(async () => { + await drain.promise; + }); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('daemon') ? drain.promise.then(() => '') : original(file, args, signal) + ); + jest.spyOn(process.stdout, 'write').mockReturnValue(true); + try { + await claimAsync(boot, engine); + expect((await readLauncherStateAsync(boot))?.status).toBe('claimed'); + const stopping = stopAsync(boot); + await status('stopping'); + expect((await readLauncherStateAsync(boot))?.status).not.toBe('disabled'); + drain.resolve(); + await stopping; + await completion; + } finally { + drain.resolve(); + } + }); +} + +test('stop during replacement prevents a late claimed result and drains newly owned work', async () => { + const [completion] = await beginSession(); + await status('ready'); + const entered = deferred(); + const release = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => { + if (args.includes('bootstatus') && args.includes('PRO')) { + entered.resolve(); + await release.promise; + signal?.throwIfAborted(); + } + return await original(file, args, signal); + }); + const claiming = claimAsync(boot, 'serve-sim', 'PRO').catch(error => error); + try { + await entered.promise; + const stopping = stopAsync(boot); + await status('stopping'); + release.resolve(''); + await stopping; + expect(await claiming).toBeInstanceOf(Error); + await completion; + expect(await readLauncherStateAsync(boot)).toMatchObject({ status: 'disabled' }); + expect(commands.runAsync).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'PRO']); + } finally { + release.resolve(''); + } +}); + +test('claim joins an already-started Expo Go install and terminal stop drains the whole task', async () => { + const install = deferred(); + const cleanup = deferred(); + jest.mocked(startExpoGoPreinstall).mockReturnValue({ + resultPromise: cleanup.promise, + claimAsync: jest.fn(() => install.promise), + stopAsync: jest.fn(() => cleanup.promise), + }); + const [completion] = await beginSession(); + await status('ready'); + jest.spyOn(process.stdout, 'write').mockReturnValue(true); + const claiming = claimAsync(boot, 'serve-sim'); + try { + await status('claiming'); + expect((await readLauncherStateAsync(boot))?.status).not.toBe('claimed'); + install.resolve(); + await claiming; + const stopping = stopAsync(boot); + await status('stopping'); + expect((await readLauncherStateAsync(boot))?.status).not.toBe('disabled'); + cleanup.resolve(); + await stopping; + await completion; + } finally { + install.resolve(); + cleanup.resolve(); + } +}); + +test.each(['install', 'run', 'adopt', 'prepare'])('rejects removed command %s', async command => { + await expect(mainAsync([command])).rejects.toThrow('Usage:'); + expect(commands.runAsync).not.toHaveBeenCalled(); +}); +for (const target of ['linux', 'win32']) { + test(`rejects ${target} before state or native work`, async () => { + Object.defineProperty(process, 'platform', { value: target }); + await expect(mainAsync(['start'])).rejects.toThrow('requires macOS'); + expect(await fs.readdir(path.join(root, 'control'))).toEqual([]); + }); +} + +test('package stop and claim use the installed bundle through Node', async () => { + const directory = path.join(root, '.expo/simulator-tools'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, 'run'), 'installed implementation'); + await mainAsync(['stop']); + await mainAsync(['claim', 'argent', 'iPhone 17 Pro']); + expect(commands.start).toHaveBeenNthCalledWith(1, process.execPath, [ + path.join(directory, 'run'), + 'stop', + ]); + expect(commands.start).toHaveBeenNthCalledWith(2, process.execPath, [ + path.join(directory, 'run'), + 'claim', + 'argent', + 'iPhone 17 Pro', + ]); + expect(commands.runAsync).not.toHaveBeenCalled(); +}); + +test.each(['stop', 'claim'])( + 'installed %s forwards signals, waits for completion and propagates delegated ENOENT', + async command => { + const directory = path.join(root, '.expo/simulator-tools'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, 'run'), 'installed implementation'); + const completion = deferred(); + const child = { kill: jest.fn() } as unknown as ChildProcess; + jest.mocked(commands.start).mockReturnValue({ child, resultPromise: completion.promise }); + const term = process.listenerCount('SIGTERM'), + int = process.listenerCount('SIGINT'); + let finished = false; + const result = mainAsync([command, 'serve-sim']).catch(error => error); + void result.then(() => { + finished = true; + }); + try { + await until(() => jest.mocked(commands.start).mock.calls.length === 1); + process.emit('SIGTERM', 'SIGTERM'); + process.emit('SIGINT', 'SIGINT'); + expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM'); + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGINT'); + expect(finished).toBe(false); + const error = Object.assign(new Error('installed command failed'), { code: 'ENOENT' }); + completion.reject(error); + expect(await result).toBe(error); + expect(commands.runAsync).not.toHaveBeenCalled(); + expect(await fs.readdir(directory)).toEqual(['run']); + expect(process.listenerCount('SIGTERM')).toBe(term); + expect(process.listenerCount('SIGINT')).toBe(int); + } finally { + completion.resolve(''); + await result; + } + } +); diff --git a/packages/simulator-launcher/src/__tests__/processes.test.ts b/packages/simulator-launcher/src/__tests__/processes.test.ts new file mode 100644 index 0000000000..7ba3816506 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/processes.test.ts @@ -0,0 +1,286 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; + +jest.mock('node:child_process', () => ({ spawn: jest.fn() })); +let commands: typeof import('../utils/processes').commands; +let stopProcessAndDescendantsAsync: typeof import('../utils/processes').stopProcessAndDescendantsAsync; +let child: ChildProcess; + +beforeEach(() => { + jest.useFakeTimers(); + jest.isolateModules(() => { + ({ commands, stopProcessAndDescendantsAsync } = require('../utils/processes')); + }); + child = Object.assign(new EventEmitter(), { + pid: 90001, + exitCode: null, + signalCode: null, + stdout: new PassThrough(), + kill: jest.fn().mockReturnValue(true), + unref: jest.fn(), + }) as unknown as ChildProcess; + jest.mocked(spawn).mockReturnValue(child); +}); +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +test('captures output and waits for close rather than wrapper exit', async () => { + const job = commands.start('command', [], undefined, true); + let finished = false; + const result = job.resultPromise.then(output => { + finished = true; + return output; + }); + expect(child.listenerCount('close')).toBe(1); + child.stdout!.emit('data', Buffer.from('output')); + child.emit('exit', 0, null); + await Promise.resolve(); + expect(finished).toBe(false); + child.emit('close', 0, null); + await expect(result).resolves.toBe('output'); + expect(() => commands.throwIfCommandCloseTimedOut()).not.toThrow(); +}); + +for (const code of ['ABORT_ERR', 'ENOENT']) { + test(`preserves original ${code} error and observes close before awaiting`, async () => { + const error = Object.assign(new Error('original failure'), { code }); + const job = commands.start('command', []); + let finished = false; + const result = job.resultPromise.catch(error => { + finished = true; + return error; + }); + expect(child.listenerCount('close')).toBe(1); + child.emit('error', error); + await Promise.resolve(); + expect(finished).toBe(false); + child.emit('close', null, null); + await expect(result).resolves.toBe(error); + }); +} + +test('nonzero exits reject only after close and retain exit code', async () => { + const job = commands.start('command', []); + const result = job.resultPromise.catch(error => error); + child.emit('exit', 7, null); + child.emit('close', 7, null); + expect((await result).code).toBe(7); +}); + +for (const event of ['error', 'exit']) { + test(`bounded close drain fails teardown even after late close: ${event}`, async () => { + const original = new Error('original abort'); + const job = commands.start('command', []); + const result = job.resultPromise.catch(error => error); + if (event === 'error') { + child.emit('error', original); + } else { + child.emit('exit', 0, null); + } + await jest.advanceTimersByTimeAsync(130_000); + const failure = await result; + if (event === 'error') { + expect(failure).toBe(original); + } else { + expect(failure.message).toContain('did not finish closing'); + } + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(child.unref).toHaveBeenCalled(); + expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing'); + child.emit('close', 0, null); + expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing'); + }); +} + +test('descendants drain before their parent, then the owned child close is awaited', async () => { + const alive = new Set([90001, 90002]); + const signals: number[] = []; + jest + .mocked(child.kill) + .mockImplementation(signal => process.kill(child.pid!, signal ?? 'SIGTERM')); + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (!alive.has(pid)) { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + } + if (signal === 'SIGTERM') { + signals.push(pid); + alive.delete(pid); + } + return true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args) => + file === 'pgrep' && args[1] === '90001' ? '90002\n' : '' + ); + let closed!: () => void; + const resultPromise = new Promise(resolve => { + closed = () => resolve(''); + }); + let finished = false; + const stopping = stopProcessAndDescendantsAsync({ child, resultPromise }).then(() => { + finished = true; + }); + await jest.advanceTimersByTimeAsync(1); + expect(signals).toEqual([90002, 90001]); + expect(finished).toBe(false); + closed(); + await stopping; + expect(finished).toBe(true); +}); + +test('an exited foreground wrapper is failed teardown; never inspect its possibly reused PID', async () => { + Object.defineProperty(child, 'exitCode', { value: 0 }); + const kill = jest.spyOn(process, 'kill'); + await expect( + stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }) + ).rejects.toThrow('child processes'); + expect(kill).not.toHaveBeenCalled(); + await expect( + stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }, true) + ).resolves.toBeUndefined(); +}); + +test('a descendant that ignores TERM prevents stop success at the deadline', async () => { + jest.spyOn(process, 'kill').mockImplementation((_pid, signal) => { + if (signal === 'SIGTERM') { + Object.defineProperty(child, 'exitCode', { value: 0 }); + } + return true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args) => + file === 'pgrep' ? (args[1] === '90001' ? '90002' : '') : 'S' + ); + const result = stopProcessAndDescendantsAsync({ + child, + resultPromise: Promise.resolve(''), + }).catch(error => error); + await jest.advanceTimersByTimeAsync(130_100); + expect((await result).message).toContain('90002 is still running'); + expect(jest.mocked(process.kill).mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual( + [[90002, 'SIGTERM']] + ); +}); + +test('ps failure cannot fabricate descendant teardown', async () => { + jest.spyOn(process, 'kill').mockReturnValue(true); + jest.spyOn(commands, 'runAsync').mockImplementation(async file => { + if (file === 'pgrep') { + return ''; + } + throw new Error('ps failed'); + }); + await expect( + stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }) + ).rejects.toThrow('ps failed'); +}); + +test('normal wrapper exit during drain still drains every known child and awaits owned close without probing its reused PID', async () => { + const job = commands.start('wrapper', []); + const signals: number[] = []; + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === child.pid) { + throw new Error('Possibly reused wrapper PID must not be probed or signalled.'); + } + if (signal === 'SIGTERM') { + signals.push(pid); + Object.defineProperty(child, 'exitCode', { value: 0 }); + child.emit('exit', 0, null); + return true; + } + if (signals.includes(pid)) { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + } + return true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args) => + file === 'pgrep' && args[1] === '90001' ? '90002\n90003' : '' + ); + let finished = false; + const stopping = stopProcessAndDescendantsAsync(job).then(() => { + finished = true; + }); + await jest.advanceTimersByTimeAsync(1); + expect(signals).toEqual([90002, 90003]); + expect(child.kill).not.toHaveBeenCalled(); + expect(finished).toBe(false); + child.emit('close', 0, null); + await stopping; + expect(finished).toBe(true); +}); + +test('wrapper exit before completed descendant discovery fails without inspecting discovered PIDs', async () => { + const kill = jest.spyOn(process, 'kill'); + jest.spyOn(commands, 'runAsync').mockImplementation(async () => { + Object.defineProperty(child, 'exitCode', { value: 0 }); + return '90002'; + }); + await expect( + stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }) + ).rejects.toThrow('before its child processes could be identified'); + expect(commands.runAsync).toHaveBeenCalledTimes(1); + expect(kill).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); +}); + +test.each([ + { exitCode: 1, signalCode: null }, + { exitCode: null, signalCode: 'SIGTERM' }, +])('unexpected wrapper exit during drain fails closed: %j', async exit => { + const signals: number[] = []; + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (signal === 'SIGTERM') { + signals.push(pid); + Object.assign(child, exit); + } + if (!signal && signals.includes(pid)) { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + } + return true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args) => + file === 'pgrep' && args[1] === '90001' ? '90002' : '' + ); + await expect( + stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }) + ).rejects.toThrow('exited unexpectedly while its child processes were stopping'); + expect(signals).toEqual([90002]); + expect(child.kill).not.toHaveBeenCalled(); +}); + +test('normal wrapper exit after draining does not bypass a missing close timeout', async () => { + const job = commands.start('wrapper', []); + let descendantAlive = true; + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid !== 90002) { + throw new Error('Possibly reused wrapper PID must not be probed or signalled.'); + } + if (signal === 'SIGTERM') { + descendantAlive = false; + Object.defineProperty(child, 'exitCode', { value: 0 }); + child.emit('exit', 0, null); + } else if (!descendantAlive) { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + } + return true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args) => + file === 'pgrep' && args[1] === '90001' ? '90002' : '' + ); + const stopping = stopProcessAndDescendantsAsync(job).catch(error => error); + await jest.advanceTimersByTimeAsync(130_001); + expect((await stopping).message).toContain('did not finish closing'); + child.emit('close', 0, null); + expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing'); +}); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts new file mode 100644 index 0000000000..b505c29d24 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -0,0 +1,164 @@ +import { type ChildProcess } from 'node:child_process'; +import os from 'node:os'; + +import { createWarmSimulatorSession } from '../commands/start'; +import { stopServicesAndShutdownSimulatorAsync } from '../commands/stop'; +import { startAgentDevicePreparation, stopAgentDeviceDaemonAsync } from '../utils/agentDevice'; +import { prepareArgentEnvironmentAsync, startArgentServer } from '../utils/argent'; +import { startExpoGoPreinstall } from '../utils/expoGo'; +import { + type ChildProcessHandle, + commands, + stopProcessAndDescendantsAsync, +} from '../utils/processes'; +import { startServeSim } from '../utils/serveSim'; + +jest.mock('../commands/stop'); +jest.mock('../utils/agentDevice'); +jest.mock('../utils/argent'); +jest.mock('../utils/expoGo'); +jest.mock('../utils/processes'); +jest.mock('../utils/serveSim'); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 100 && !predicate(); i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(predicate()).toBe(true); +} +function handle(pid: number): ChildProcessHandle { + return { child: { pid } as ChildProcess, resultPromise: Promise.resolve('') }; +} +const serveSim = handle(10); +const agent = handle(11); +const argent = handle(12); +let controller: AbortController; +let environment: NodeJS.ProcessEnv; +let onPreparationFailure: jest.Mock; +let onBackgroundFailure: jest.Mock; + +function createSession() { + return createWarmSimulatorSession({ + signal: controller.signal, + throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), + onPreparationFailure, + onBackgroundFailure, + }); +} +beforeEach(() => { + jest.resetAllMocks(); + environment = { ...process.env }; + delete process.env.EXPO_SIMULATOR_DEVICE; + process.env.AGENT_DEVICE_STATE_DIR = '/isolated-session-fixture/agent'; + process.env.ARGENT_EVENT_LOG = '/isolated-session-fixture/events'; + jest.spyOn(os, 'homedir').mockReturnValue('/isolated-session-fixture'); + controller = new AbortController(); + onPreparationFailure = jest.fn(error => controller.abort(error)); + onBackgroundFailure = jest.fn(error => controller.abort(error)); + jest + .mocked(commands.runAsync) + .mockImplementation(async (_file, args) => + args.includes('list') + ? JSON.stringify({ devices: { ios: [{ udid: 'PHONE', name: 'iPhone 17' }] } }) + : 'native-start-time' + ); + jest.mocked(startServeSim).mockReturnValue(serveSim); + jest.mocked(startAgentDevicePreparation).mockReturnValue(agent); + jest.mocked(startArgentServer).mockReturnValue(argent); + jest.mocked(startExpoGoPreinstall).mockReturnValue({ + claimAsync: jest.fn(async () => {}), + stopAsync: jest.fn(async () => {}), + resultPromise: Promise.resolve(), + }); +}); +afterEach(() => { + jest.restoreAllMocks(); + process.env = environment; +}); + +it('creates an idle owner synchronously and stops services acquired before preparation fails', async () => { + const argentPreparation = deferred(); + const failure = new Error('Argent preparation failed'); + jest.mocked(prepareArgentEnvironmentAsync).mockReturnValueOnce(argentPreparation.promise); + const session = createSession(); + expect(commands.runAsync).not.toHaveBeenCalled(); + const preparing = session.prepareAsync(); + const rejected = expect(preparing).rejects.toBe(failure); + try { + await until(() => jest.mocked(startAgentDevicePreparation).mock.calls.length > 0); + argentPreparation.reject(failure); + await rejected; + expect(onPreparationFailure).toHaveBeenCalledWith(failure); + expect(onBackgroundFailure).not.toHaveBeenCalled(); + await session.stopAsync(); + expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( + new Set([serveSim, agent]), + 'PHONE', + agent + ); + expect(startArgentServer).not.toHaveBeenCalled(); + } finally { + argentPreparation.reject(failure); + await preparing.catch(() => {}); + } +}); + +it('drains unused tools and the whole Expo Go task before native cleanup, retaining their failure', async () => { + const unusedAgent = deferred(); + const unusedArgent = deferred(); + const expoCleanup = deferred(); + const failure = new Error('Argent stop failed'); + const preinstall = { + claimAsync: jest.fn(async () => {}), + stopAsync: jest.fn(() => expoCleanup.promise), + resultPromise: expoCleanup.promise, + }; + jest.mocked(startExpoGoPreinstall).mockReturnValueOnce(preinstall); + jest.mocked(stopProcessAndDescendantsAsync).mockReturnValueOnce(unusedArgent.promise); + jest.mocked(stopAgentDeviceDaemonAsync).mockReturnValueOnce(unusedAgent.promise); + const session = createSession(); + expect(await session.prepareAsync()).toMatchObject({ + device: { udid: 'PHONE' }, + controllerPid: process.pid, + controllerStartTime: 'native-start-time', + agentDeviceStateDir: '/isolated-session-fixture/agent', + argentEventLogPath: '/isolated-session-fixture/events', + }); + expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ + udid: 'PHONE', + name: 'iPhone 17', + }); + expect(preinstall.claimAsync).toHaveBeenCalledTimes(1); + const stopping = session.stopAsync(); + const rejected = expect(stopping).rejects.toMatchObject({ errors: [failure] }); + try { + unusedArgent.reject(failure); + await new Promise(resolve => setImmediate(resolve)); + expect(onBackgroundFailure).toHaveBeenCalledWith(failure); + expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + unusedAgent.resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + expoCleanup.resolve(); + await rejected; + expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( + new Set([serveSim, agent, argent]), + 'PHONE', + agent + ); + } finally { + unusedArgent.reject(failure); + unusedAgent.resolve(); + expoCleanup.resolve(); + await stopping.catch(() => {}); + } +}); diff --git a/packages/simulator-launcher/src/__tests__/state.test.ts b/packages/simulator-launcher/src/__tests__/state.test.ts new file mode 100644 index 0000000000..318b3b58a9 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/state.test.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { claimAsync } from '../commands/claim'; +import { stopAsync } from '../commands/stop'; +import { + LAUNCHER_STATE_FILE, + readLauncherStateAsync, + updateLauncherStateAsync, + waitForLauncherStateAsync, +} from '../utils/state'; + +const boot = 'boot-one'; +let root: string; +const info = { + device: { udid: 'PHONE', name: 'iPhone 17' }, + controllerPid: 9000, + controllerStartTime: 'Thu Oct 22 10:00:00 2026', + agentDeviceStateDir: '/agent', + argentEventLogPath: '/argent/events', +}; +const directory = () => path.join(root, 'control'); + +const nativeFs = { + readFile: fs.readFile.bind(fs), + writeFile: fs.writeFile.bind(fs), + rename: fs.rename.bind(fs), + rm: fs.rm.bind(fs), + mkdir: fs.mkdir.bind(fs), + rmdir: fs.rmdir.bind(fs), + stat: fs.stat.bind(fs), +}; +const remap = (file: unknown): unknown => + typeof file === 'string' && file.startsWith(LAUNCHER_STATE_FILE) + ? path.join(root, 'control', path.basename(file)) + : file; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(yes => { + resolve = yes; + }); + return { promise, resolve }; +} +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-state-')); + jest.spyOn(os, 'homedir').mockReturnValue(root); + await fs.mkdir(path.join(root, 'control')); + for (const name of ['readFile', 'writeFile', 'rename', 'rm', 'mkdir', 'rmdir', 'stat'] as const) { + jest.spyOn(fs, name).mockImplementation(((...args: unknown[]) => { + args[0] = remap(args[0]); + if (name === 'rename') { + args[1] = remap(args[1]); + } + return (nativeFs[name] as (...args: unknown[]) => unknown)(...args); + }) as never); + } +}); +afterEach(async () => { + jest.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); +}); + +test('missing and old-boot state are unavailable, while access errors propagate', async () => { + expect(await readLauncherStateAsync(boot)).toBeUndefined(); + await updateLauncherStateAsync('old-boot', () => ({ bootUuid: 'old-boot', status: 'disabled' })); + expect(await readLauncherStateAsync(boot)).toBeUndefined(); + const error = Object.assign(new Error('state inaccessible'), { code: 'EACCES' }); + jest.spyOn(fs, 'readFile').mockRejectedValueOnce(error); + await expect(readLauncherStateAsync(boot)).rejects.toBe(error); +}); + +test('old-boot locks are ignored without being removed or stolen', async () => { + await fs.mkdir(`${LAUNCHER_STATE_FILE}.old-boot.lock`, { recursive: true }); + await stopAsync(boot); + expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' }); + expect((await fs.stat(`${LAUNCHER_STATE_FILE}.old-boot.lock`)).isDirectory()).toBe(true); +}); + +test('a current-boot crash lock times out fail-closed and remains untouched', async () => { + const lock = `${LAUNCHER_STATE_FILE}.${boot}.lock`; + await fs.mkdir(lock, { recursive: true }); + let calls = 0; + jest.spyOn(Date, 'now').mockImplementation(() => (calls++ ? 5_001 : 0)); + await expect(stopAsync(boot)).rejects.toThrow('state is locked'); + expect((await fs.stat(lock)).isDirectory()).toBe(true); + expect(await readLauncherStateAsync(boot)).toBeUndefined(); +}); + +for (const operation of ['callback', 'writeFile', 'rename'] as const) { + test(`${operation} failure preserves old state and releases only the owned temporary files/lock`, async () => { + await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting' })); + const error = Object.assign(new Error('disk error'), { code: 'EIO' }); + if (operation !== 'callback') { + jest.spyOn(fs, operation).mockRejectedValueOnce(error); + } + await expect( + updateLauncherStateAsync(boot, state => { + if (operation === 'callback') { + throw error; + } + return { ...state!, status: 'ready' }; + }) + ).rejects.toBe(error); + expect((await readLauncherStateAsync(boot))?.status).toBe('starting'); + expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]); + }); +} + +test('cancellation after the temporary write cannot publish the next state', async () => { + await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting' })); + const abort = new AbortController(); + const write = nativeFs.writeFile; + jest.spyOn(fs, 'writeFile').mockImplementation((async (file, data, ...args) => { + await write(remap(file) as string, data, ...args); + abort.abort(new Error('cancelled')); + }) as typeof fs.writeFile); + await expect( + updateLauncherStateAsync(boot, state => ({ ...state!, status: 'ready' }), abort.signal) + ).rejects.toThrow('cancelled'); + expect((await readLauncherStateAsync(boot))?.status).toBe('starting'); + expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]); +}); + +test.each(['ready', 'claimed'] as const)( + 'a pending %s writer publishes complete JSON before a stop, which cannot be overwritten', + async publication => { + await updateLauncherStateAsync(boot, () => ({ + bootUuid: boot, + status: publication === 'ready' ? 'starting' : 'claiming', + engine: 'serve-sim', + ...info, + })); + const writing = deferred(); + const release = deferred(); + const write = nativeFs.writeFile; + jest.spyOn(fs, 'writeFile').mockImplementation((async (file, data, ...args) => { + if (JSON.parse(String(data)).status === publication) { + await write(remap(file) as string, '{"bootUuid":'); + writing.resolve(); + await release.promise; + await write(remap(file) as string, data); + return; + } + await write(remap(file) as string, data, ...args); + }) as typeof fs.writeFile); + const ready = updateLauncherStateAsync(boot, state => ({ ...state!, status: publication })); + try { + await writing.promise; + expect((await readLauncherStateAsync(boot))?.status).toBe( + publication === 'ready' ? 'starting' : 'claiming' + ); + const stopping = stopAsync(boot); + release.resolve(); + await ready; + await waitForLauncherStateAsync(boot, state => state?.status === 'stopping', 1_000); + await expect( + updateLauncherStateAsync(boot, state => { + if (state?.status !== 'ready') { + throw new Error('stop already won'); + } + return { ...state, status: 'claimed' }; + }) + ).rejects.toThrow('stop already won'); + await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' })); + expect(await stopping).toBe(0); + } finally { + release.resolve(); + await ready; + } + } +); + +test('two job claims acquire only one ready session and the result includes its metadata', async () => { + await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info })); + const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + const results: unknown[] = []; + const claims = ['serve-sim', 'argent'].map(engine => + claimAsync(boot, engine).then( + value => results.push(value), + error => results.push(error) + ) + ); + await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000); + for (let i = 0; i < 100 && results.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + expect(results[0]).toBeInstanceOf(Error); + await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' })); + await Promise.all(claims); + expect(results.filter(value => value === 0)).toHaveLength(1); + expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({ + ...info, + status: 'claimed', + bootUuid: boot, + }); + await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('available'); +}); + +test.each([ + [undefined, undefined], + ['unsupported', undefined], + ['serve-sim', ' '], +])( + 'invalid claim engine/selector %s/%s cannot mutate or acquire ready state', + async (engine, selector) => { + await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info })); + const before = await readLauncherStateAsync(boot); + await expect(claimAsync(boot, engine, selector)).rejects.toThrow( + 'Invalid Simulator session request' + ); + expect(await readLauncherStateAsync(boot)).toEqual(before); + expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]); + } +); + +test('concurrent resource-free stops leave one disabled record without extra ownership files', async () => { + expect(await Promise.all([stopAsync(boot), stopAsync(boot)])).toEqual([0, 0]); + expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]); + expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' }); +}); + +test('stopping is not cleanup completion and polling stays bounded', async () => { + await updateLauncherStateAsync(boot, () => ({ + bootUuid: boot, + status: 'stopping', + error: 'cleanup incomplete', + })); + await expect( + waitForLauncherStateAsync(boot, state => state?.status === 'disabled', 1) + ).rejects.toThrow('cleanup may still be running'); + expect((await readLauncherStateAsync(boot))?.status).toBe('stopping'); +}); diff --git a/packages/simulator-launcher/src/commands/claim.ts b/packages/simulator-launcher/src/commands/claim.ts new file mode 100644 index 0000000000..8b7362575b --- /dev/null +++ b/packages/simulator-launcher/src/commands/claim.ts @@ -0,0 +1,46 @@ +import { + ClaimInputSchema, + WarmSessionInfoSchema, + updateLauncherStateAsync, + waitForLauncherStateAsync, +} from '../utils/state'; + +export async function claimAsync( + bootUuid: string, + engine?: string, + requestedDevice?: string +): Promise { + const input = ClaimInputSchema.safeParse({ engine, requestedDevice }); + if (!input.success) { + throw new Error( + `Invalid Simulator session request. Choose ${ClaimInputSchema.shape.engine.options.join(', ')} and an optional device name or UDID.` + ); + } + const acquired = await updateLauncherStateAsync(bootUuid, state => { + if (state?.status !== 'ready') { + throw new Error('No warm Simulator session is available to claim on this VM.'); + } + WarmSessionInfoSchema.parse(state); + return { ...state, ...input.data, status: 'claiming' }; + }); + const claimed = await waitForLauncherStateAsync( + bootUuid, + state => { + if (!state || state.status === 'stopping' || state.status === 'disabled') { + throw new Error('Simulator preparation was interrupted; cleanup may still be running.'); + } + if ( + state.controllerPid !== acquired.controllerPid || + state.controllerStartTime !== acquired.controllerStartTime || + state.engine !== engine + ) { + throw new Error('The Simulator launcher returned a different session or engine.'); + } + return state.status === 'claimed'; + }, + 420_000 + ); + WarmSessionInfoSchema.parse(claimed); + process.stdout.write(`${JSON.stringify(claimed)}\n`); + return 0; +} diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts new file mode 100644 index 0000000000..cba3c3f771 --- /dev/null +++ b/packages/simulator-launcher/src/commands/start.ts @@ -0,0 +1,417 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; +import { z } from 'zod'; + +import { stopServicesAndShutdownSimulatorAsync } from './stop'; +import { + setAgentDeviceEnvironmentDefaults, + startAgentDevicePreparation, + stopAgentDeviceDaemonAsync, +} from '../utils/agentDevice'; +import { + prepareArgentEnvironmentAsync, + setArgentEnvironmentDefaults, + startArgentServer, +} from '../utils/argent'; +import { startExpoGoPreinstall } from '../utils/expoGo'; +import { + type ChildProcessHandle, + commands, + stopProcessAndDescendantsAsync, +} from '../utils/processes'; +import { startServeSim } from '../utils/serveSim'; +import { + ClaimInputSchema, + type LauncherState, + type SimulatorDevice, + SimulatorDeviceSchema, + type WarmSessionInfo, + getInstalledLauncherDirectory, + readLauncherStateAsync, + updateLauncherStateAsync, +} from '../utils/state'; + +type WarmSimulatorSession = { + prepareAsync(): Promise; + claimAsync(request: z.infer): Promise; + stopAsync(): Promise; +}; + +const SimulatorListSchema = z.object({ + devices: z.record( + z.string(), + z.array(SimulatorDeviceSchema.extend({ isAvailable: z.boolean().optional() })) + ), +}); + +export async function startAsync(bootUuid: string, sourceBundlePath: string): Promise { + const initial = await updateLauncherStateAsync(bootUuid, state => { + if (state?.status === 'disabled') { + return state; + } + if (state) { + throw new Error( + 'Simulator preparation has already been started or disabled for this VM boot.' + ); + } + return { bootUuid, status: 'starting', controllerPid: process.pid }; + }); + if (initial.status === 'disabled') { + return 0; + } + await fs.mkdir(getInstalledLauncherDirectory(), { recursive: true }); + const installedLauncherPath = path.join(getInstalledLauncherDirectory(), 'run'); + const temporaryPath = `${installedLauncherPath}.${process.pid}`; + try { + await fs.copyFile(sourceBundlePath, temporaryPath); + await fs.rename(temporaryPath, installedLauncherPath); + } finally { + await fs.rm(temporaryPath, { force: true }); + } + await runWarmSimulatorSessionAsync(bootUuid); + return 0; +} + +export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise { + const shutdown = new AbortController(); + const signal = shutdown.signal; + const interruption = new Error( + 'Simulator preparation was interrupted; cleanup may still be running.' + ); + const requestShutdown = (): void => shutdown.abort(interruption); + process.on('SIGTERM', requestShutdown); + process.on('SIGINT', requestShutdown); + const watcherAbort = new AbortController(); + let sessionError: unknown; + let warm: WarmSessionInfo | undefined; + function requireOwnedState( + state: LauncherState | undefined, + expected?: LauncherState['status'] + ): LauncherState { + if ( + !state || + state.controllerPid !== process.pid || + (expected && state.status !== expected) || + (warm && state.controllerStartTime && state.controllerStartTime !== warm.controllerStartTime) + ) { + throw interruption; + } + return state; + } + async function throwIfStoppingAsync(): Promise { + signal.throwIfAborted(); + const state = requireOwnedState(await readLauncherStateAsync(bootUuid)); + if (state.status === 'stopping' || state.status === 'disabled') { + requestShutdown(); + } + signal.throwIfAborted(); + } + const session = createWarmSimulatorSession({ + signal, + throwIfStoppingAsync, + onPreparationFailure(error) { + if (!signal.aborted) { + sessionError = error; + } + shutdown.abort(error); + }, + onBackgroundFailure(error) { + sessionError ??= error; + requestShutdown(); + }, + }); + const watcher = (async () => { + while (!watcherAbort.signal.aborted) { + await throwIfStoppingAsync(); + await sleepAsync(100, undefined, { signal: watcherAbort.signal }); + } + })().catch(error => { + if (!watcherAbort.signal.aborted && !signal.aborted) { + sessionError ??= error; + requestShutdown(); + } + }); + try { + try { + if (process.env.EXPO_SIMULATOR_STARTUP !== '1') { + requestShutdown(); + } + await throwIfStoppingAsync(); + warm = await session.prepareAsync(); + await updateLauncherStateAsync( + bootUuid, + state => ({ + ...requireOwnedState(state, 'starting'), + ...warm!, + status: 'ready', + }), + signal + ); + while (!signal.aborted) { + const state = requireOwnedState(await readLauncherStateAsync(bootUuid)); + if (state.status === 'claiming') { + const request = ClaimInputSchema.parse(state); + const device = await session.claimAsync(request); + await updateLauncherStateAsync( + bootUuid, + current => { + const owned = requireOwnedState(current, 'claiming'); + if ( + owned.engine !== request.engine || + owned.requestedDevice !== request.requestedDevice + ) { + throw interruption; + } + return { ...owned, status: 'claimed', device }; + }, + signal + ); + } + await sleepAsync(100, undefined, { signal }); + } + } catch (error) { + if (!signal.aborted) { + sessionError ??= error; + } + } + await updateLauncherStateAsync(bootUuid, state => ({ + ...requireOwnedState(state), + status: 'stopping', + ...(sessionError !== undefined + ? { error: sessionError instanceof Error ? sessionError.message : String(sessionError) } + : {}), + })).catch(error => { + sessionError ??= error; + }); + try { + await session.stopAsync(); + } catch (error) { + await updateLauncherStateAsync(bootUuid, state => ({ + ...requireOwnedState(state), + status: 'stopping', + error: error instanceof Error ? error.message : String(error), + })).catch(() => {}); + throw error; + } + await updateLauncherStateAsync(bootUuid, state => ({ + ...requireOwnedState(state, 'stopping'), + status: 'disabled', + })); + if (sessionError) { + throw sessionError; + } + } finally { + watcherAbort.abort(); + await watcher; + process.off('SIGTERM', requestShutdown); + process.off('SIGINT', requestShutdown); + } +} + +/** Own resources before preparation starts so every partial attempt can be stopped. */ +export function createWarmSimulatorSession({ + signal, + throwIfStoppingAsync, + onPreparationFailure, + onBackgroundFailure, +}: { + signal: AbortSignal; + throwIfStoppingAsync(): Promise; + onPreparationFailure(error: unknown): void; + onBackgroundFailure(error: unknown): void; +}): WarmSimulatorSession { + const serviceProcesses = new Set(); + // Track the attempted device for cleanup; publish currentDevice only after startup succeeds. + let ownedSimulatorUdid: string | undefined; + let currentDevice: SimulatorDevice; + let availableDevices: SimulatorDevice[]; + let agentDevicePrepareProcess: ChildProcessHandle | undefined; + let argentProcess: ChildProcessHandle | undefined; + let expoGoPreinstall: ReturnType | undefined; + let unusedToolsCleanup: Promise[]> = Promise.resolve([]); + + async function listAvailableSimulatorsAsync(): Promise { + const { devices } = SimulatorListSchema.parse( + JSON.parse( + await commands.runAsync( + 'xcrun', + ['simctl', 'list', 'devices', 'available', '--json'], + signal + ) + ) + ); + return Object.values(devices) + .flat() + .filter(device => device.isAvailable !== false); + } + + async function resolveSimulatorDeviceAsync( + selector?: string, + refreshOnMiss = false + ): Promise { + for (let attempt = 0; ; attempt++) { + const matches = selector + ? availableDevices.filter( + device => + device.name === selector || device.udid.toLowerCase() === selector.toLowerCase() + ) + : availableDevices + .filter(device => device.name.startsWith('iPhone')) + .sort((a, b) => a.name.length - b.name.length) + .slice(0, 1); + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1 || !refreshOnMiss || attempt === 1) { + throw new Error( + `Could not select one available Simulator for ${selector ?? 'the default iPhone'}. Use an exact available device name or UDID.` + ); + } + await throwIfStoppingAsync(); + availableDevices = await listAvailableSimulatorsAsync(); + } + } + + async function bootSimulatorAndStartServicesAsync(device: SimulatorDevice): Promise { + await throwIfStoppingAsync(); + ownedSimulatorUdid = device.udid; + const simulatorBootPromise = commands + .runAsync('xcrun', ['simctl', 'bootstatus', ownedSimulatorUdid, '-b'], signal) + .catch(async () => { + signal.throwIfAborted(); + await commands.runAsync('xcrun', ['simctl', 'bootstatus', ownedSimulatorUdid!], signal); + }); + if (!expoGoPreinstall) { + expoGoPreinstall = startExpoGoPreinstall(device.udid, simulatorBootPromise, signal); + void expoGoPreinstall.resultPromise.catch(onBackgroundFailure); + } + const argentPreparationPromise = prepareArgentEnvironmentAsync(signal); + const serveSimPromise = simulatorBootPromise.then(() => { + serviceProcesses.add(startServeSim(ownedSimulatorUdid!, signal)); + }); + const agentDevicePromise = simulatorBootPromise.then(async () => { + agentDevicePrepareProcess = startAgentDevicePreparation(ownedSimulatorUdid!, signal); + serviceProcesses.add(agentDevicePrepareProcess); + await Promise.race([ + agentDevicePrepareProcess.resultPromise, + new Promise((_resolve, reject) => { + const aborted = (): void => reject(signal.reason); + signal.addEventListener('abort', aborted, { once: true }); + if (signal.aborted) { + aborted(); + } + }), + ]); + }); + const argentPromise = Promise.all([simulatorBootPromise, argentPreparationPromise]).then(() => { + argentProcess = startArgentServer(signal); + serviceProcesses.add(argentProcess); + }); + try { + await Promise.all([serveSimPromise, agentDevicePromise, argentPromise]); + } catch (error) { + onPreparationFailure(error); + await Promise.allSettled([ + simulatorBootPromise, + argentPreparationPromise, + serveSimPromise, + agentDevicePromise, + argentPromise, + ]); + throw error; + } + } + + return { + async prepareAsync(): Promise { + availableDevices = await listAvailableSimulatorsAsync(); + const selectedDevice = await resolveSimulatorDeviceAsync(process.env.EXPO_SIMULATOR_DEVICE); + setArgentEnvironmentDefaults(); + setAgentDeviceEnvironmentDefaults(); + const controllerStartTime = ( + await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) + ).trim(); + if (!controllerStartTime) { + throw new Error( + 'Could not read the Simulator launcher process start time; the warm session cannot be verified.' + ); + } + await bootSimulatorAndStartServicesAsync(selectedDevice); + currentDevice = selectedDevice; + return { + device: currentDevice, + controllerPid: process.pid, + controllerStartTime, + agentDeviceStateDir: process.env.AGENT_DEVICE_STATE_DIR!, + argentEventLogPath: process.env.ARGENT_EVENT_LOG!, + }; + }, + + async claimAsync(request: z.infer): Promise { + await expoGoPreinstall?.claimAsync(); + await throwIfStoppingAsync(); + const device = + !request.requestedDevice || + request.requestedDevice.toLowerCase() === currentDevice.udid.toLowerCase() + ? currentDevice + : await resolveSimulatorDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); + if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) { + // Replacement drains the old native resources without completing terminal shutdown. + await stopServicesAndShutdownSimulatorAsync( + serviceProcesses, + ownedSimulatorUdid, + agentDevicePrepareProcess + ); + ownedSimulatorUdid = undefined; + agentDevicePrepareProcess = undefined; + argentProcess = undefined; + await throwIfStoppingAsync(); + await bootSimulatorAndStartServicesAsync(device); + currentDevice = device; + } + const unusedTools: Promise[] = []; + if (request.engine !== 'argent') { + unusedTools.push( + stopProcessAndDescendantsAsync(argentProcess!).then(() => { + serviceProcesses.delete(argentProcess!); + }) + ); + } + if (request.engine !== 'agent-device') { + unusedTools.push(stopAgentDeviceDaemonAsync()); + } + unusedToolsCleanup = Promise.allSettled( + unusedTools.map(cleanup => + cleanup.catch(error => { + onBackgroundFailure(error); + throw error; + }) + ) + ); + return currentDevice; + }, + + /** Drain all owned work. Rejection means terminal cleanup cannot be acknowledged. */ + async stopAsync(): Promise { + const [unusedToolsResults, expoGoResults] = await Promise.all([ + unusedToolsCleanup, + Promise.allSettled([expoGoPreinstall?.stopAsync()]), + ]); + await stopServicesAndShutdownSimulatorAsync( + serviceProcesses, + ownedSimulatorUdid, + agentDevicePrepareProcess + ); + const warmupErrors = [...unusedToolsResults, ...expoGoResults].flatMap(result => + result.status === 'rejected' ? [result.reason] : [] + ); + if (warmupErrors.length) { + throw new AggregateError( + warmupErrors, + 'Warm Simulator background work could not be cleaned up; cleanup remains incomplete.' + ); + } + }, + }; +} diff --git a/packages/simulator-launcher/src/commands/stop.ts b/packages/simulator-launcher/src/commands/stop.ts new file mode 100644 index 0000000000..bf6ec8fac5 --- /dev/null +++ b/packages/simulator-launcher/src/commands/stop.ts @@ -0,0 +1,52 @@ +import { stopAgentDeviceDaemonAsync } from '../utils/agentDevice'; +import { + type ChildProcessHandle, + commands, + stopProcessAndDescendantsAsync, +} from '../utils/processes'; +import { updateLauncherStateAsync, waitForLauncherStateAsync } from '../utils/state'; + +export async function stopAsync(bootUuid: string): Promise { + const state = await updateLauncherStateAsync(bootUuid, current => + !current || current.status === 'disabled' + ? { ...current, bootUuid, status: 'disabled' } + : { ...current, status: 'stopping' } + ); + if (state.status !== 'disabled') { + await waitForLauncherStateAsync(bootUuid, current => current?.status === 'disabled', 140_000); + } + return 0; +} + +export async function stopServicesAndShutdownSimulatorAsync( + serviceProcesses: Set, + simulatorUdid?: string, + agentDevicePrepareProcess?: ChildProcessHandle +): Promise { + const cleanupErrors: unknown[] = []; + for (const childProcessHandle of serviceProcesses) { + await stopProcessAndDescendantsAsync( + childProcessHandle, + /* allowCompletedProcess */ childProcessHandle === agentDevicePrepareProcess + ) + .then(() => serviceProcesses.delete(childProcessHandle)) + .catch(error => cleanupErrors.push(error)); + } + if (simulatorUdid) { + await stopAgentDeviceDaemonAsync().catch(error => cleanupErrors.push(error)); + await commands + .runAsync('xcrun', ['simctl', 'shutdown', simulatorUdid]) + .catch(error => cleanupErrors.push(error)); + } + try { + commands.throwIfCommandCloseTimedOut(); + } catch (error) { + cleanupErrors.push(error); + } + if (cleanupErrors.length) { + throw new AggregateError( + cleanupErrors, + 'Warm Simulator cleanup could not be confirmed; services or the device may still be running.' + ); + } +} diff --git a/packages/simulator-launcher/src/main.ts b/packages/simulator-launcher/src/main.ts new file mode 100644 index 0000000000..d1efcab61f --- /dev/null +++ b/packages/simulator-launcher/src/main.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { claimAsync } from './commands/claim'; +import { startAsync } from './commands/start'; +import { stopAsync } from './commands/stop'; +import { runInstalledLauncherCommandAsync } from './utils/installedLauncher'; +import { commands } from './utils/processes'; +import { getInstalledLauncherDirectory } from './utils/state'; + +export async function mainAsync(args = process.argv.slice(2)): Promise { + if (process.platform !== 'darwin') { + throw new Error('simulator-launcher requires macOS (darwin).'); + } + const [command, engine, requestedDevice] = args; + if (command !== 'start' && command !== 'stop' && command !== 'claim') { + throw new Error('Usage: simulator-launcher '); + } + const installedLauncherPath = path.join(getInstalledLauncherDirectory(), 'run'); + const isInstalledLauncher = path.resolve(__filename) === installedLauncherPath; + if ((command === 'stop' || command === 'claim') && !isInstalledLauncher) { + const installedLauncherExists = await fs + .stat(installedLauncherPath) + .then(() => true) + .catch(error => { + if (command !== 'stop' || error.code !== 'ENOENT') { + throw error; + } + return false; + }); + if (installedLauncherExists) { + return await runInstalledLauncherCommandAsync([command, ...args.slice(1)]); + } + } + const bootUuid = ( + await commands.runAsync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']) + ).trim(); + if (!bootUuid) { + throw new Error( + 'Could not read the VM boot identifier (kern.bootsessionuuid); startup cannot continue.' + ); + } + switch (command) { + case 'start': + return await startAsync(bootUuid, __filename); + case 'stop': + return await stopAsync(bootUuid); + case 'claim': + return await claimAsync(bootUuid, engine, requestedDevice); + } +} + +export async function runCliAsync(): Promise { + try { + process.exitCode = await mainAsync(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; + } +} +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) { + void runCliAsync(); +} diff --git a/packages/simulator-launcher/src/utils/agentDevice.ts b/packages/simulator-launcher/src/utils/agentDevice.ts new file mode 100644 index 0000000000..67e5d0b1cf --- /dev/null +++ b/packages/simulator-launcher/src/utils/agentDevice.ts @@ -0,0 +1,46 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { type ChildProcessHandle, commands } from './processes'; + +export function setAgentDeviceEnvironmentDefaults(): void { + process.env.AGENT_DEVICE_STATE_DIR ||= path.join( + os.homedir(), + 'Library/Caches/expo-simulator-startup/agent-device' + ); + process.env.AGENT_DEVICE_RETAIN_ARTIFACTS ||= '1'; + process.env.AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS ||= '0'; + process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS ||= '0'; + process.env.AGENT_DEVICE_IOS_RUNNER_DETACH ||= '0'; +} + +export function startAgentDevicePreparation(udid: string, signal: AbortSignal): ChildProcessHandle { + signal.throwIfAborted(); + return commands.start('bunx', [ + `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`, + 'prepare', + 'ios-runner', + '--platform', + 'ios', + '--udid', + udid, + '--daemon-server-mode', + 'http', + '--daemon-transport', + 'http', + '--timeout', + '240000', + '--json', + ]); +} + +export async function stopAgentDeviceDaemonAsync(): Promise { + await commands.runAsync('bunx', [ + `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`, + 'daemon', + 'stop', + '--clean', + '--state-dir', + process.env.AGENT_DEVICE_STATE_DIR!, + ]); +} diff --git a/packages/simulator-launcher/src/utils/argent.ts b/packages/simulator-launcher/src/utils/argent.ts new file mode 100644 index 0000000000..205d8c1698 --- /dev/null +++ b/packages/simulator-launcher/src/utils/argent.ts @@ -0,0 +1,63 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { type ChildProcessHandle, commands } from './processes'; + +export function setArgentEnvironmentDefaults(): void { + process.env.ARGENT_EVENT_LOG ||= path.join(os.homedir(), '.argent/tool-server-events.jsonl'); + process.env.ARGENT_EMULATOR_NO_WINDOW ||= '1'; +} + +export async function prepareArgentEnvironmentAsync(signal: AbortSignal): Promise { + const argentPackage = `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`; + + signal.throwIfAborted(); + try { + await commands.runAsync('ffmpeg', ['-version'], signal).catch(async error => { + if (error.code !== 'ENOENT') { + throw error; + } + signal.throwIfAborted(); + await commands.runAsync( + '/usr/bin/env', + ['HOMEBREW_NO_AUTO_UPDATE=1', 'brew', 'install', 'ffmpeg'], + signal + ); + signal.throwIfAborted(); + await commands.runAsync('ffmpeg', ['-version'], signal); + }); + } catch (error) { + signal.throwIfAborted(); + process.stderr.write(`Could not prepare ffmpeg; video tools may be unavailable: ${error}\n`); + } + commands.throwIfCommandCloseTimedOut(); + + for (const [verb, flag] of [ + ['enable', 'artifacts-list-endpoint'], + ['enable', 'tool-server-event-log'], + ['disable', 'video-watermark'], + ]) { + signal.throwIfAborted(); + await commands.runAsync('bunx', [argentPackage, verb, flag], signal).catch(error => { + if (verb !== 'disable') { + throw error; + } + }); + } + signal.throwIfAborted(); +} + +export function startArgentServer(signal: AbortSignal): ChildProcessHandle { + signal.throwIfAborted(); + return commands.start('bunx', [ + `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`, + 'server', + 'start', + '--host', + '127.0.0.1', + '--port', + '3201', + '--idle-timeout', + '0', + ]); +} diff --git a/packages/simulator-launcher/src/utils/expoGo.ts b/packages/simulator-launcher/src/utils/expoGo.ts new file mode 100644 index 0000000000..02efd068e7 --- /dev/null +++ b/packages/simulator-launcher/src/utils/expoGo.ts @@ -0,0 +1,130 @@ +import { createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Writable } from 'node:stream'; +import { z } from 'zod'; + +import { commands } from './processes'; + +const VERSIONS_URL = 'https://api.expo.dev/v2/versions'; +const HEADERS = { Accept: 'application/json', 'User-Agent': 'expo-simulator-launcher' }; +const VersionsSchema = z.object({ + sdkVersions: z.record( + z.string(), + z.object({ + releaseNoteUrl: z.string().optional(), + isBeta: z.boolean().optional(), + iosClientUrl: z.string().optional(), + }) + ), +}); + +/** Warm only this initial device. Claim prevents new installs; a started install must finish. */ +export function startExpoGoPreinstall( + udid: string, + boot: Promise, + signal: AbortSignal +): { + claimAsync(): Promise; + stopAsync(): Promise; + resultPromise: Promise; +} { + const downloadController = new AbortController(); + const downloadSignal = AbortSignal.any([ + signal, + downloadController.signal, + AbortSignal.timeout(120_000), + ]); + let installation: Promise | undefined; + let failure: unknown; + void boot.catch(() => {}); + const warmup = (async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-expo-go-')); + try { + const response = await fetch(VERSIONS_URL, { headers: HEADERS, signal: downloadSignal }); + if (!response.ok) { + throw new Error(`Expo Go versions returned HTTP ${response.status}.`); + } + const { sdkVersions } = VersionsSchema.parse(await response.json()); + // Same latest stable release policy as expo.dev/go (GoScene/utils.ts). + const sdk = Object.keys(sdkVersions) + .filter(sdk => /\b(\d*)\.0\.0/.test(sdk)) + .slice(-10) + .reverse() + .find(sdk => sdkVersions[sdk].releaseNoteUrl && !sdkVersions[sdk].isBeta); + const url = sdk && sdkVersions[sdk].iosClientUrl; + if (!url || new URL(url).protocol !== 'https:') { + throw new Error('No stable Expo Go Simulator archive is available.'); + } + const archive = await fetch(url, { headers: HEADERS, signal: downloadSignal }); + if (!archive.ok || !archive.body) { + throw new Error(`Expo Go archive returned HTTP ${archive.status}.`); + } + const archivePath = path.join(directory, 'expo-go.tar.gz'); + await archive.body.pipeTo(Writable.toWeb(createWriteStream(archivePath)), { + signal: downloadSignal, + }); + downloadSignal.throwIfAborted(); + // Wait for the owned extractor to close, including on bad archives and claim/stop. + const appPath = path.join(directory, 'ExpoGo.app'); + await fs.mkdir(appPath); + await commands.runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]); + downloadSignal.throwIfAborted(); + await fs.access(path.join(appPath, 'Info.plist')); + await boot; + downloadSignal.throwIfAborted(); + // No await between this latch and launch. Claim cannot race a not-yet-started install. + process.stderr.write(`Preinstalling Expo Go for SDK ${sdk} on Simulator ${udid}.\n`); + installation = commands.runAsync('xcrun', ['simctl', 'install', udid, appPath]).then( + () => { + process.stderr.write('Expo Go preinstall finished.\n'); + }, + error => { + commands.throwIfCommandCloseTimedOut(); + if (error.code === null) { + throw error; + } + process.stderr.write( + `Could not preinstall Expo Go; the job will install its requested app: ${error}\n` + ); + } + ); + await installation; + } catch (error) { + commands.throwIfCommandCloseTimedOut(); + // A signal exit is not evidence that the native installation transaction finished. + if (installation) { + throw error; + } + if (!signal.aborted && !downloadController.signal.aborted) { + process.stderr.write( + `Could not preinstall Expo Go; the job will install its requested app: ${error}\n` + ); + } + } finally { + // A rejected command with unproved close must not race removal or permit a stopped ack. + commands.throwIfCommandCloseTimedOut(); + await fs.rm(directory, { recursive: true, force: true }); + } + })(); + void warmup.catch(error => { + failure = error; + }); + return { + resultPromise: warmup, + async claimAsync() { + downloadController.abort(); + await installation; + if (failure) { + throw failure; + } + commands.throwIfCommandCloseTimedOut(); + }, + async stopAsync() { + downloadController.abort(); + await warmup; + commands.throwIfCommandCloseTimedOut(); + }, + }; +} diff --git a/packages/simulator-launcher/src/utils/installedLauncher.ts b/packages/simulator-launcher/src/utils/installedLauncher.ts new file mode 100644 index 0000000000..eca44e262f --- /dev/null +++ b/packages/simulator-launcher/src/utils/installedLauncher.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; + +import { commands } from './processes'; +import { getInstalledLauncherDirectory } from './state'; + +// Run the bundle copied by start, forwarding signals and waiting for the command to finish. +export async function runInstalledLauncherCommandAsync(args: string[]): Promise { + const childProcessHandle = commands.start(process.execPath, [ + path.join(getInstalledLauncherDirectory(), 'run'), + ...args, + ]); + const forwardSignal = (signal: NodeJS.Signals): void => { + childProcessHandle.child.kill(signal); + }; + process.on('SIGTERM', forwardSignal); + process.on('SIGINT', forwardSignal); + try { + await childProcessHandle.resultPromise; + return 0; + } finally { + process.off('SIGTERM', forwardSignal); + process.off('SIGINT', forwardSignal); + } +} diff --git a/packages/simulator-launcher/src/utils/processes.ts b/packages/simulator-launcher/src/utils/processes.ts new file mode 100644 index 0000000000..d3a3062eba --- /dev/null +++ b/packages/simulator-launcher/src/utils/processes.ts @@ -0,0 +1,172 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; + +export type ChildProcessHandle = { child: ChildProcess; resultPromise: Promise }; +let commandCloseDrainError: Error | undefined; + +export const commands = { + start( + file: string, + args: string[], + signal?: AbortSignal, + captureStdout = false + ): ChildProcessHandle { + const child = spawn(file, args, { + stdio: captureStdout ? ['ignore', 'pipe', 'inherit'] : 'inherit', + signal, + }); + let stdout = ''; + let error: Error | undefined; + let closeDrainTimer: NodeJS.Timeout | undefined; + const resultPromise = new Promise((resolve, reject) => { + const startCloseDrainTimeout = (): void => { + closeDrainTimer ??= setTimeout(() => { + commandCloseDrainError ||= new Error( + `${file} did not finish closing within 130 seconds after exit or error; Simulator cleanup could not be confirmed.`, + { + cause: error, + } + ); + try { + child.kill('SIGKILL'); + } catch { + /* Teardown remains failed. */ + } + child.stdout?.destroy(); + child.unref(); + reject(error ?? commandCloseDrainError); + }, 130_000); + }; + child.stdout?.on('data', data => { + stdout += data; + }); + child.once('error', failure => { + error = failure; + startCloseDrainTimeout(); + }); + child.once('exit', (code, signal) => { + if (code !== 0) { + error ||= Object.assign(new Error(`${file} exited with ${signal ?? code}.`), { code }); + } + startCloseDrainTimeout(); + }); + child.once('close', () => { + clearTimeout(closeDrainTimer); + error ? reject(error) : resolve(stdout); + }); + }); + void resultPromise.catch(() => {}); // Observe long-lived launches before startup joins or cleanup await them. + return { child, resultPromise }; + }, + async runAsync(file: string, args: string[], signal?: AbortSignal): Promise { + return await commands.start(file, args, signal, /* captureStdout */ true).resultPromise; + }, + throwIfCommandCloseTimedOut(): void { + if (commandCloseDrainError) { + throw commandCloseDrainError; + } + }, +}; + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + return false; + } + throw error; + } +} + +async function stopProcessTreeAsync( + pid: number, + deadlineMs: number, + ownedChildProcess?: ChildProcess +): Promise { + const children = await commands.runAsync('pgrep', ['-P', String(pid)]).catch(error => { + if (error.code !== 1) { + throw error; + } + return ''; + }); + if ( + ownedChildProcess && + (ownedChildProcess.exitCode != null || ownedChildProcess.signalCode != null) + ) { + throw new Error( + 'A Simulator service launcher exited before its child processes could be identified; cleanup could not be confirmed.' + ); + } + for (const child of children.trim().split(/\s+/).filter(Boolean)) { + await stopProcessTreeAsync(Number(child), deadlineMs); + } + if ( + ownedChildProcess && + (ownedChildProcess.exitCode != null || ownedChildProcess.signalCode != null) + ) { + // Stopping a descendant can make its wrapper exit normally. Never probe that PID again. + if (ownedChildProcess.exitCode === 0) { + return; + } + throw new Error( + 'A Simulator service launcher exited unexpectedly while its child processes were stopping; cleanup could not be confirmed.' + ); + } + if (!isProcessRunning(pid)) { + return; + } + try { + if (ownedChildProcess) { + if (!ownedChildProcess.kill('SIGTERM')) { + throw new Error( + 'A Simulator service launcher exited before it could be stopped safely; cleanup could not be confirmed.' + ); + } + } else { + process.kill(pid, 'SIGTERM'); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + return; + } + while (isProcessRunning(pid)) { + const state = await commands + .runAsync('ps', ['-p', String(pid), '-o', 'state=']) + .catch(error => { + if (error.code === 1 && !isProcessRunning(pid)) { + return ''; + } + throw error; + }); + if (state.trim().startsWith('Z') || !isProcessRunning(pid)) { + return; + } + if (Date.now() >= deadlineMs) { + throw new Error( + `Simulator service process ${pid} is still running; cleanup could not be confirmed.` + ); + } + await sleepAsync(100); + } +} + +export async function stopProcessAndDescendantsAsync( + processHandle: ChildProcessHandle, + allowCompletedProcess = false +): Promise { + const { child } = processHandle; + if (child.pid && child.exitCode == null && child.signalCode == null) { + await stopProcessTreeAsync(child.pid, Date.now() + 130_000, child); + } else if (child.pid && !allowCompletedProcess) { + // Never inspect a reused wrapper PID or infer that its descendants have also exited. + throw new Error( + 'A Simulator service exited before its child processes could be stopped; cleanup could not be confirmed.' + ); + } + await processHandle.resultPromise.catch(() => {}); + commands.throwIfCommandCloseTimedOut(); +} diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts new file mode 100644 index 0000000000..cde3089ee1 --- /dev/null +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -0,0 +1,28 @@ +import { type ChildProcessHandle, commands } from './processes'; + +export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHandle { + signal.throwIfAborted(); + return commands.start('bunx', [ + `@expo/serve-sim@${process.env.SERVE_SIM_VERSION || 'latest'}`, + udid, + '--host', + '127.0.0.1', + '--port', + '3200', + '--require-token', + '--transport', + 'webrtc', + '--webrtc-codec', + 'h264', + '--max-dimension', + '1600', + '--mjpeg-quality', + '0.55', + '--video-bitrate', + '10000000', + '--video-fps', + '60', + '--disable-share', + '--quiet', + ]); +} diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts new file mode 100644 index 0000000000..294f5a0c63 --- /dev/null +++ b/packages/simulator-launcher/src/utils/state.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; +import { z } from 'zod'; + +export const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json'; + +const NonBlankStringSchema = z.string().regex(/\S/, 'Must not be blank.'); +export const SimulatorDeviceSchema = z.object({ + udid: NonBlankStringSchema, + name: NonBlankStringSchema, +}); +export const ClaimInputSchema = z.object({ + engine: z.enum(['serve-sim', 'agent-device', 'argent']), + requestedDevice: NonBlankStringSchema.optional(), +}); +export const WarmSessionInfoSchema = z.object({ + device: SimulatorDeviceSchema, + controllerPid: z.number().int().gt(1), + controllerStartTime: NonBlankStringSchema, + agentDeviceStateDir: NonBlankStringSchema, + argentEventLogPath: NonBlankStringSchema, +}); +export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({ + bootUuid: NonBlankStringSchema, + status: z.enum(['starting', 'ready', 'claiming', 'claimed', 'stopping', 'disabled']), + ...ClaimInputSchema.partial().shape, + error: z.string().optional(), +}); +export type LauncherState = z.infer; +export type WarmSessionInfo = z.infer; +export type SimulatorDevice = z.infer; + +export function getInstalledLauncherDirectory(): string { + return path.join(os.homedir(), '.expo/simulator-tools'); +} + +export async function readLauncherStateAsync(bootUuid: string): Promise { + let contents: string; + try { + contents = await fs.readFile(LAUNCHER_STATE_FILE, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + const state = LauncherStateSchema.parse(JSON.parse(contents)); + return state.bootUuid === bootUuid ? state : undefined; +} + +/** Serialize only state I/O. The callback is synchronous; native work belongs outside this lock. */ +export async function updateLauncherStateAsync( + bootUuid: string, + update: (state: LauncherState | undefined) => LauncherState, + signal?: AbortSignal +): Promise { + const lock = `${LAUNCHER_STATE_FILE}.${bootUuid}.lock`; + const deadline = Date.now() + 5_000; + for (;;) { + signal?.throwIfAborted(); + try { + await fs.mkdir(lock); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + if (Date.now() >= deadline) { + throw new Error( + 'Simulator state is locked by another command; cleanup cannot be confirmed.' + ); + } + await sleepAsync(50, undefined, { signal }); + } + } + const temporaryPath = `${LAUNCHER_STATE_FILE}.${randomUUID()}.tmp`; + try { + const state = LauncherStateSchema.parse(update(await readLauncherStateAsync(bootUuid))); + if (state.bootUuid !== bootUuid) { + throw new Error('Simulator state belongs to a different VM boot.'); + } + await fs.writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { flag: 'wx' }); + signal?.throwIfAborted(); + await fs.rename(temporaryPath, LAUNCHER_STATE_FILE); + return state; + } finally { + try { + await fs.rm(temporaryPath, { force: true }); + } finally { + await fs.rmdir(lock); + } + } +} + +export async function waitForLauncherStateAsync( + bootUuid: string, + isComplete: (state: LauncherState | undefined) => boolean, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const state = await readLauncherStateAsync(bootUuid); + if (isComplete(state)) { + return state; + } + if (Date.now() >= deadline) { + throw new Error( + 'Timed out waiting for the Simulator launcher; cleanup may still be running.' + ); + } + await sleepAsync(100); + } +} diff --git a/packages/simulator-launcher/tsconfig.json b/packages/simulator-launcher/tsconfig.json new file mode 100644 index 0000000000..b331298a1c --- /dev/null +++ b/packages/simulator-launcher/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node", "jest"], + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 3649647def..4772f98961 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3122,6 +3122,22 @@ __metadata: languageName: node linkType: hard +"@expo/simulator-launcher@workspace:packages/simulator-launcher": + version: 0.0.0-use.local + resolution: "@expo/simulator-launcher@workspace:packages/simulator-launcher" + dependencies: + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:22.20.5" + "@vercel/ncc": "npm:^0.38.1" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.4.12" + typescript: "npm:^6.0.3" + zod: "npm:^4.3.5" + bin: + simulator-launcher: bin/simulator-launcher + languageName: unknown + linkType: soft + "@expo/spawn-async@npm:1.7.2, @expo/spawn-async@npm:^1.7.2": version: 1.7.2 resolution: "@expo/spawn-async@npm:1.7.2" From 93fc3584b4b6acfec801298217fa5a34e1e848dc Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 24 Oct 2026 11:47:09 -0700 Subject: [PATCH 02/42] [simulator-launcher] Claim warm sessions once serve-sim is ready Report ready after boot and a ready serve-sim. A claim waits only for its requested tool and stops the others, even mid-startup. Replacement boots the new device while the old one is released. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 +- .../src/__tests__/main.test.ts | 2 + .../src/__tests__/simulatorSession.test.ts | 174 +++++++++++++++- .../simulator-launcher/src/commands/start.ts | 193 ++++++++++++------ .../simulator-launcher/src/utils/serveSim.ts | 48 +++++ 5 files changed, 348 insertions(+), 71 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index d3975bc14e..f112a27e76 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -2,7 +2,7 @@ '@expo/simulator-launcher': minor --- -Add start, stop and claim commands for warm Simulator sessions. Claiming reuses the warm device or replaces it with the requested device after cleanup. +Add start, stop and claim commands for warm Simulator sessions. A session is ready once its device boots and serve-sim answers. A claim waits only for its requested tool and stops the others, even mid-startup. Replacing the device boots the requested one while the old one is cleaned up. Start unused-tool shutdown during claim without delaying handoff. Drain it during terminal cleanup; a shutdown failure prevents a stopped acknowledgement. diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index e2e6add6bc..ece344d0b6 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -10,6 +10,7 @@ import { mainAsync } from '../main'; import { startExpoGoPreinstall } from '../utils/expoGo'; import { type ChildProcessHandle, commands } from '../utils/processes'; import * as processes from '../utils/processes'; +import * as serveSim from '../utils/serveSim'; import { LAUNCHER_STATE_FILE, readLauncherStateAsync, @@ -128,6 +129,7 @@ beforeEach(async () => { return job; }); jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue(); + jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue(); }); afterEach(async () => { if (active.length) { diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index b505c29d24..55e4aa1b01 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1,5 +1,7 @@ import { type ChildProcess } from 'node:child_process'; +import fs from 'node:fs/promises'; import os from 'node:os'; +import path from 'node:path'; import { createWarmSimulatorSession } from '../commands/start'; import { stopServicesAndShutdownSimulatorAsync } from '../commands/stop'; @@ -85,18 +87,18 @@ afterEach(() => { process.env = environment; }); -it('creates an idle owner synchronously and stops services acquired before preparation fails', async () => { +it('creates an idle owner synchronously and stops services acquired before a tool fails', async () => { const argentPreparation = deferred(); const failure = new Error('Argent preparation failed'); jest.mocked(prepareArgentEnvironmentAsync).mockReturnValueOnce(argentPreparation.promise); const session = createSession(); expect(commands.runAsync).not.toHaveBeenCalled(); - const preparing = session.prepareAsync(); - const rejected = expect(preparing).rejects.toBe(failure); try { - await until(() => jest.mocked(startAgentDevicePreparation).mock.calls.length > 0); + // serve-sim readiness makes the session claimable while the tools are still starting. + await session.prepareAsync(); + expect(startAgentDevicePreparation).toHaveBeenCalledTimes(1); argentPreparation.reject(failure); - await rejected; + await until(() => onPreparationFailure.mock.calls.length > 0); expect(onPreparationFailure).toHaveBeenCalledWith(failure); expect(onBackgroundFailure).not.toHaveBeenCalled(); await session.stopAsync(); @@ -108,7 +110,113 @@ it('creates an idle owner synchronously and stops services acquired before prepa expect(startArgentServer).not.toHaveBeenCalled(); } finally { argentPreparation.reject(failure); - await preparing.catch(() => {}); + } +}); + +it('serve-sim claim during agent-device prepare stops it and returns without waiting', async () => { + const preparing = deferred(); + const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; + jest.mocked(startAgentDevicePreparation).mockReturnValueOnce(prepare); + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async job => { + if (job === prepare) { + preparing.reject(new Error('stopped')); + } + }); + const session = createSession(); + try { + await session.prepareAsync(); + expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ + udid: 'PHONE', + name: 'iPhone 17', + }); + await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(argent); + expect(onPreparationFailure).not.toHaveBeenCalled(); + await session.stopAsync(); + expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( + new Set([serveSim]), + 'PHONE', + undefined + ); + } finally { + preparing.reject(new Error('stopped')); + } +}); + +it('claim waits only for the requested tool and stops the other one mid-start', async () => { + const preparing = deferred(); + const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; + const argentConfig = deferred(); + jest.mocked(startAgentDevicePreparation).mockReturnValueOnce(prepare); + jest.mocked(prepareArgentEnvironmentAsync).mockReturnValueOnce(argentConfig.promise); + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async job => { + if (job === prepare) { + preparing.reject(new Error('stopped')); + } + }); + const session = createSession(); + try { + await session.prepareAsync(); + let claimed = false; + const claiming = session.claimAsync({ engine: 'argent' }).then(device => { + claimed = true; + return device; + }); + await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); + expect(claimed).toBe(false); + expect(startArgentServer).not.toHaveBeenCalled(); + argentConfig.resolve(); + expect(await claiming).toMatchObject({ udid: 'PHONE' }); + expect(startArgentServer).toHaveBeenCalledTimes(1); + expect(onPreparationFailure).not.toHaveBeenCalled(); + } finally { + preparing.reject(new Error('stopped')); + argentConfig.resolve(); + } +}); + +it('replacement boots the new device while the old one is released, then starts only the requested tool', async () => { + const release = deferred(); + jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => + args.includes('list') + ? JSON.stringify({ + devices: { + ios: [ + { udid: 'PHONE', name: 'iPhone 17' }, + { udid: 'PRO', name: 'iPhone 17 Pro' }, + ], + }, + }) + : 'native-start-time' + ); + jest.mocked(stopServicesAndShutdownSimulatorAsync).mockReturnValueOnce(release.promise); + const session = createSession(); + try { + await session.prepareAsync(); + jest.mocked(startAgentDevicePreparation).mockClear(); + jest.mocked(startArgentServer).mockClear(); + jest.mocked(startServeSim).mockClear(); + const claiming = session.claimAsync({ engine: 'agent-device', requestedDevice: 'PRO' }); + await until(() => + jest + .mocked(commands.runAsync) + .mock.calls.some(([, args]) => args.includes('bootstatus') && args.includes('PRO')) + ); + expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( + expect.any(Set), + 'PHONE', + undefined + ); + expect(startServeSim).not.toHaveBeenCalled(); + expect(startAgentDevicePreparation).not.toHaveBeenCalled(); + release.resolve(); + expect(await claiming).toEqual({ udid: 'PRO', name: 'iPhone 17 Pro' }); + expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); + expect(startAgentDevicePreparation).toHaveBeenCalledWith('PRO', expect.anything()); + expect(startArgentServer).not.toHaveBeenCalled(); + } finally { + release.resolve(); } }); @@ -123,7 +231,9 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain resultPromise: expoCleanup.promise, }; jest.mocked(startExpoGoPreinstall).mockReturnValueOnce(preinstall); - jest.mocked(stopProcessAndDescendantsAsync).mockReturnValueOnce(unusedArgent.promise); + jest + .mocked(stopProcessAndDescendantsAsync) + .mockImplementation(async job => (job === argent ? await unusedArgent.promise : undefined)); jest.mocked(stopAgentDeviceDaemonAsync).mockReturnValueOnce(unusedAgent.promise); const session = createSession(); expect(await session.prepareAsync()).toMatchObject({ @@ -151,9 +261,9 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain expoCleanup.resolve(); await rejected; expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( - new Set([serveSim, agent, argent]), + new Set([serveSim, argent]), 'PHONE', - agent + undefined ); } finally { unusedArgent.reject(failure); @@ -162,3 +272,49 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain await stopping.catch(() => {}); } }); + +describe('waitForServeSimReadyAsync', () => { + const { waitForServeSimReadyAsync } = + jest.requireActual('../utils/serveSim'); + + it('waits for the state file and a ready /readyz answer for this device', async () => { + const tmpdir = await fs.mkdtemp( + path.join(jest.requireActual('node:os').tmpdir(), 'serve-sim-ready-') + ); + jest.spyOn(os, 'tmpdir').mockReturnValue(tmpdir); + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'starting' }))) + .mockResolvedValue(new Response(JSON.stringify({ status: 'ready', device: 'phone' }))); + try { + const running = { + child: { pid: 10 } as ChildProcess, + resultPromise: new Promise(() => {}), + }; + const waiting = waitForServeSimReadyAsync('PHONE', running, controller.signal); + await new Promise(resolve => setTimeout(resolve, 300)); + expect(fetchSpy).not.toHaveBeenCalled(); + await fs.mkdir(path.join(tmpdir, 'serve-sim')); + await fs.writeFile( + path.join(tmpdir, 'serve-sim', 'server-PHONE.json'), + JSON.stringify({ device: 'PHONE', url: 'http://127.0.0.1:3200', token: 'secret' }) + ); + await waiting; + expect(fetchSpy).toHaveBeenLastCalledWith('http://127.0.0.1:3200/readyz', { + headers: { Authorization: 'Bearer secret' }, + signal: expect.any(AbortSignal), + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + } finally { + await fs.rm(tmpdir, { recursive: true, force: true }); + } + }); + + it('fails when serve-sim exits before it is ready', async () => { + jest.spyOn(os, 'tmpdir').mockReturnValue('/missing-serve-sim-fixture'); + const exited = { child: { pid: 10 } as ChildProcess, resultPromise: Promise.resolve('') }; + await expect(waitForServeSimReadyAsync('PHONE', exited, controller.signal)).rejects.toThrow( + 'serve-sim exited before PHONE became ready.' + ); + }); +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index cba3c3f771..5a940fbf3d 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -20,7 +20,7 @@ import { commands, stopProcessAndDescendantsAsync, } from '../utils/processes'; -import { startServeSim } from '../utils/serveSim'; +import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; import { ClaimInputSchema, type LauncherState, @@ -209,6 +209,9 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise }; + /** Own resources before preparation starts so every partial attempt can be stopped. */ export function createWarmSimulatorSession({ signal, @@ -222,8 +225,9 @@ export function createWarmSimulatorSession({ onBackgroundFailure(error: unknown): void; }): WarmSimulatorSession { const serviceProcesses = new Set(); - // Track the attempted device for cleanup; publish currentDevice only after startup succeeds. - let ownedSimulatorUdid: string | undefined; + // Track attempted devices for cleanup; publish currentDevice only after startup succeeds. + const ownedSimulatorUdids = new Set(); + const toolStartups = new Map(); let currentDevice: SimulatorDevice; let availableDevices: SimulatorDevice[]; let agentDevicePrepareProcess: ChildProcessHandle | undefined; @@ -273,51 +277,96 @@ export function createWarmSimulatorSession({ } } - async function bootSimulatorAndStartServicesAsync(device: SimulatorDevice): Promise { + /** Starts a tool after `servicesAllowed`; a failure before claim fails the warm session closed. */ + function startTool(tool: WarmTool, udid: string, servicesAllowed: Promise): void { + const controller = new AbortController(); + const toolSignal = AbortSignal.any([signal, controller.signal]); + const aborted = new Promise((_resolve, reject) => { + const abort = (): void => reject(toolSignal.reason); + toolSignal.addEventListener('abort', abort, { once: true }); + if (toolSignal.aborted) { + abort(); + } + }); + void aborted.catch(() => {}); + const ready = + tool === 'agent-device' + ? servicesAllowed.then(async () => { + agentDevicePrepareProcess = startAgentDevicePreparation(udid, toolSignal); + serviceProcesses.add(agentDevicePrepareProcess); + await Promise.race([agentDevicePrepareProcess.resultPromise, aborted]); + }) + : Promise.all([servicesAllowed, prepareArgentEnvironmentAsync(toolSignal)]).then(() => { + argentProcess = startArgentServer(toolSignal); + serviceProcesses.add(argentProcess); + }); + void ready.catch(error => { + if (!controller.signal.aborted) { + onPreparationFailure(error); + } + }); + toolStartups.set(tool, { controller, ready }); + } + + /** Stops a tool even while it is still starting, then releases its native state. */ + async function stopToolAsync(tool: WarmTool): Promise { + const startup = toolStartups.get(tool); + toolStartups.delete(tool); + startup?.controller.abort(); + await startup?.ready.catch(() => {}); + if (tool === 'agent-device') { + if (agentDevicePrepareProcess) { + await stopProcessAndDescendantsAsync(agentDevicePrepareProcess, true); + serviceProcesses.delete(agentDevicePrepareProcess); + agentDevicePrepareProcess = undefined; + } + await stopAgentDeviceDaemonAsync(); + } else if (argentProcess) { + await stopProcessAndDescendantsAsync(argentProcess); + serviceProcesses.delete(argentProcess); + argentProcess = undefined; + } + } + + /** + * Boots the device, then starts serve-sim and the given tools once `servicesAllowed` settles. + * Resolves when serve-sim is ready; tools keep starting and report through their startups. + */ + async function bootSimulatorAndStartServicesAsync( + device: SimulatorDevice, + tools: WarmTool[], + servicesAllowed: Promise = Promise.resolve() + ): Promise { await throwIfStoppingAsync(); - ownedSimulatorUdid = device.udid; + const udid = device.udid; + ownedSimulatorUdids.add(udid); const simulatorBootPromise = commands - .runAsync('xcrun', ['simctl', 'bootstatus', ownedSimulatorUdid, '-b'], signal) + .runAsync('xcrun', ['simctl', 'bootstatus', udid, '-b'], signal) .catch(async () => { signal.throwIfAborted(); - await commands.runAsync('xcrun', ['simctl', 'bootstatus', ownedSimulatorUdid!], signal); + await commands.runAsync('xcrun', ['simctl', 'bootstatus', udid], signal); }); if (!expoGoPreinstall) { - expoGoPreinstall = startExpoGoPreinstall(device.udid, simulatorBootPromise, signal); + expoGoPreinstall = startExpoGoPreinstall(udid, simulatorBootPromise, signal); void expoGoPreinstall.resultPromise.catch(onBackgroundFailure); } - const argentPreparationPromise = prepareArgentEnvironmentAsync(signal); - const serveSimPromise = simulatorBootPromise.then(() => { - serviceProcesses.add(startServeSim(ownedSimulatorUdid!, signal)); - }); - const agentDevicePromise = simulatorBootPromise.then(async () => { - agentDevicePrepareProcess = startAgentDevicePreparation(ownedSimulatorUdid!, signal); - serviceProcesses.add(agentDevicePrepareProcess); - await Promise.race([ - agentDevicePrepareProcess.resultPromise, - new Promise((_resolve, reject) => { - const aborted = (): void => reject(signal.reason); - signal.addEventListener('abort', aborted, { once: true }); - if (signal.aborted) { - aborted(); - } - }), - ]); - }); - const argentPromise = Promise.all([simulatorBootPromise, argentPreparationPromise]).then(() => { - argentProcess = startArgentServer(signal); - serviceProcesses.add(argentProcess); + const servicesReady = Promise.all([simulatorBootPromise, servicesAllowed]); + for (const tool of tools) { + startTool(tool, udid, servicesReady); + } + const serveSimPromise = servicesReady.then(async () => { + const serveSim = startServeSim(udid, signal); + serviceProcesses.add(serveSim); + await waitForServeSimReadyAsync(udid, serveSim, signal); }); try { - await Promise.all([serveSimPromise, agentDevicePromise, argentPromise]); + await serveSimPromise; } catch (error) { onPreparationFailure(error); await Promise.allSettled([ simulatorBootPromise, - argentPreparationPromise, - serveSimPromise, - agentDevicePromise, - argentPromise, + servicesAllowed, + ...[...toolStartups.values()].map(startup => startup.ready), ]); throw error; } @@ -337,7 +386,8 @@ export function createWarmSimulatorSession({ 'Could not read the Simulator launcher process start time; the warm session cannot be verified.' ); } - await bootSimulatorAndStartServicesAsync(selectedDevice); + // Claimable once serve-sim is ready; the tools keep warming until a claim selects one. + await bootSimulatorAndStartServicesAsync(selectedDevice, ['agent-device', 'argent']); currentDevice = selectedDevice; return { device: currentDevice, @@ -356,31 +406,35 @@ export function createWarmSimulatorSession({ request.requestedDevice.toLowerCase() === currentDevice.udid.toLowerCase() ? currentDevice : await resolveSimulatorDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); + const requestedTool = (['agent-device', 'argent'] as const).find( + tool => tool === request.engine + ); if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) { - // Replacement drains the old native resources without completing terminal shutdown. - await stopServicesAndShutdownSimulatorAsync( - serviceProcesses, - ownedSimulatorUdid, - agentDevicePrepareProcess + // Release the old device while the new one boots; services start after the release. + const previousUdid = currentDevice.udid; + const release = (async () => { + await Promise.all([...toolStartups.keys()].map(tool => stopToolAsync(tool))); + await stopServicesAndShutdownSimulatorAsync( + serviceProcesses, + previousUdid, + agentDevicePrepareProcess + ); + ownedSimulatorUdids.delete(previousUdid); + agentDevicePrepareProcess = undefined; + argentProcess = undefined; + await throwIfStoppingAsync(); + })(); + await bootSimulatorAndStartServicesAsync( + device, + requestedTool ? [requestedTool] : [], + release ); - ownedSimulatorUdid = undefined; - agentDevicePrepareProcess = undefined; - argentProcess = undefined; - await throwIfStoppingAsync(); - await bootSimulatorAndStartServicesAsync(device); currentDevice = device; } - const unusedTools: Promise[] = []; - if (request.engine !== 'argent') { - unusedTools.push( - stopProcessAndDescendantsAsync(argentProcess!).then(() => { - serviceProcesses.delete(argentProcess!); - }) - ); - } - if (request.engine !== 'agent-device') { - unusedTools.push(stopAgentDeviceDaemonAsync()); - } + // Unselected tools stop now, even mid-startup, so they do not compete with the job. + const unusedTools = [...toolStartups.keys()] + .filter(tool => tool !== requestedTool) + .map(tool => stopToolAsync(tool)); unusedToolsCleanup = Promise.allSettled( unusedTools.map(cleanup => cleanup.catch(error => { @@ -389,6 +443,9 @@ export function createWarmSimulatorSession({ }) ) ); + if (requestedTool) { + await toolStartups.get(requestedTool)!.ready; + } return currentDevice; }, @@ -398,14 +455,28 @@ export function createWarmSimulatorSession({ unusedToolsCleanup, Promise.allSettled([expoGoPreinstall?.stopAsync()]), ]); - await stopServicesAndShutdownSimulatorAsync( - serviceProcesses, - ownedSimulatorUdid, - agentDevicePrepareProcess - ); + for (const startup of toolStartups.values()) { + startup.controller.abort(); + } + await Promise.allSettled([...toolStartups.values()].map(startup => startup.ready)); + const cleanupErrors: unknown[] = []; + for (const udid of ownedSimulatorUdids.size ? [...ownedSimulatorUdids] : [undefined]) { + try { + await stopServicesAndShutdownSimulatorAsync( + serviceProcesses, + udid, + agentDevicePrepareProcess + ); + } catch (error) { + cleanupErrors.push(error); + } + } const warmupErrors = [...unusedToolsResults, ...expoGoResults].flatMap(result => result.status === 'rejected' ? [result.reason] : [] ); + if (cleanupErrors.length) { + throw cleanupErrors[0]; + } if (warmupErrors.length) { throw new AggregateError( warmupErrors, diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index cde3089ee1..dd32228994 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -1,5 +1,14 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; +import { z } from 'zod'; + import { type ChildProcessHandle, commands } from './processes'; +const SERVE_SIM_READY_TIMEOUT_MS = 120_000; +const ServeSimStateSchema = z.object({ device: z.string(), url: z.string(), token: z.string() }); + export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHandle { signal.throwIfAborted(); return commands.start('bunx', [ @@ -26,3 +35,42 @@ export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHa '--quiet', ]); } + +/** A job reads serve-sim's state and calls /readyz as soon as its claim returns. */ +export async function waitForServeSimReadyAsync( + udid: string, + server: ChildProcessHandle, + signal: AbortSignal +): Promise { + let exited = false; + void server.resultPromise.finally(() => { + exited = true; + }); + const stateFile = path.join(os.tmpdir(), 'serve-sim', `server-${udid}.json`); + const deadline = Date.now() + SERVE_SIM_READY_TIMEOUT_MS; + for (;;) { + signal.throwIfAborted(); + if (exited) { + throw new Error(`serve-sim exited before ${udid} became ready.`); + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for serve-sim on ${udid} to become ready.`); + } + try { + const state = ServeSimStateSchema.parse(JSON.parse(await fs.readFile(stateFile, 'utf8'))); + const response = await fetch(`${state.url}/readyz`, { + headers: { Authorization: `Bearer ${state.token}` }, + signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]), + }); + const ready = response.ok + ? ((await response.json()) as { status?: string; device?: string }) + : {}; + if (ready.status === 'ready' && ready.device?.toUpperCase() === udid.toUpperCase()) { + return; + } + } catch { + // serve-sim writes its state and answers /readyz only once it is listening. + } + await sleepAsync(250, undefined, { signal }); + } +} From 1fad3cfab14f73b79d0e6fdc7b5f645991399c25 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 24 Oct 2026 11:50:26 -0700 Subject: [PATCH 03/42] [simulator-launcher] Disable apsd during warmup Disable apsd after each warm device boots, before it is reported ready, so a job does not do it while it installs and launches its app. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 + .../src/__tests__/main.test.ts | 9 ++- .../src/__tests__/simulatorSession.test.ts | 79 ++++++++++++++++--- .../simulator-launcher/src/commands/start.ts | 49 +++++++++++- 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index f112a27e76..ec25d0f94d 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -7,3 +7,5 @@ Add start, stop and claim commands for warm Simulator sessions. A session is rea Start unused-tool shutdown during claim without delaying handoff. Drain it during terminal cleanup; a shutdown failure prevents a stopped acknowledgement. Preinstall the latest stable Expo Go during initial warmup without delaying the candidate. Claim prevents late warmup installs and waits for one already in progress before handing the device to the job. + +Disable apsd on each warm device before reporting it ready. diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index ece344d0b6..3983a9dfbf 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -110,9 +110,12 @@ beforeEach(async () => { if (args.includes('lstart=')) { return 'Thu Oct 22 10:00:00 2026'; } - if (args.includes('list')) { + if (args.includes('devices')) { return JSON.stringify(deviceList); } + if (args.includes('launchctl') && args.includes('list')) { + throw Object.assign(new Error('apsd is not loaded'), { code: 113 }); + } return ''; }); jest.spyOn(commands, 'start').mockImplementation(() => { @@ -327,7 +330,7 @@ for (const selector of [undefined, 'phone', 'iPhone 17 Pro']) { }); expect(result.agentDeviceStateDir).toBeTruthy(); expect( - jest.mocked(commands.runAsync).mock.calls.filter(([, args]) => args.includes('list')) + jest.mocked(commands.runAsync).mock.calls.filter(([, args]) => args.includes('devices')) ).toHaveLength(1); if (replacement) { const calls = jest.mocked(commands.runAsync).mock.calls; @@ -346,7 +349,7 @@ test('a device snapshot miss refreshes once to select a newly available device', let lists = 0; const original = jest.mocked(commands.runAsync).getMockImplementation()!; jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => - args.includes('list') + args.includes('devices') ? Promise.resolve( JSON.stringify({ devices: { diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 55e4aa1b01..4dc47bff58 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -66,13 +66,14 @@ beforeEach(() => { controller = new AbortController(); onPreparationFailure = jest.fn(error => controller.abort(error)); onBackgroundFailure = jest.fn(error => controller.abort(error)); - jest - .mocked(commands.runAsync) - .mockImplementation(async (_file, args) => - args.includes('list') - ? JSON.stringify({ devices: { ios: [{ udid: 'PHONE', name: 'iPhone 17' }] } }) - : 'native-start-time' - ); + jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => { + if (args.includes('launchctl') && args.includes('list')) { + throw Object.assign(new Error('apsd is not loaded'), { code: 113 }); + } + return args.includes('devices') + ? JSON.stringify({ devices: { ios: [{ udid: 'PHONE', name: 'iPhone 17' }] } }) + : 'native-start-time'; + }); jest.mocked(startServeSim).mockReturnValue(serveSim); jest.mocked(startAgentDevicePreparation).mockReturnValue(agent); jest.mocked(startArgentServer).mockReturnValue(argent); @@ -113,6 +114,58 @@ it('creates an idle owner synchronously and stops services acquired before a too } }); +it('disables apsd after boot and before the device is reported ready', async () => { + const apsdBootout = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('bootout') && args.includes('user/foreground/com.apple.apsd') + ? apsdBootout.promise + : original(file, args, signal) + ); + const session = createSession(); + let ready = false; + const preparing = session.prepareAsync().then(info => { + ready = true; + return info; + }); + try { + await until(() => + jest.mocked(commands.runAsync).mock.calls.some(([, args]) => args.includes('bootout')) + ); + const calls = jest.mocked(commands.runAsync).mock.calls.map(([, args]) => args.join(' ')); + expect(calls.findIndex(call => call.includes('bootstatus PHONE'))).toBeLessThan( + calls.indexOf('simctl spawn PHONE launchctl disable user/foreground/com.apple.apsd') + ); + await new Promise(resolve => setImmediate(resolve)); + expect(ready).toBe(false); + apsdBootout.resolve(''); + await preparing; + expect(calls).toContain('simctl spawn PHONE launchctl disable user/foreground/com.apple.apsd'); + } finally { + apsdBootout.resolve(''); + } +}); + +it('keeps the warm session when apsd cannot be disabled', async () => { + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation(async (file, args, signal) => + args.includes('launchctl') && args.includes('disable') + ? Promise.reject(new Error('launchctl unavailable')) + : original(file, args, signal) + ); + const stderr = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + const session = createSession(); + expect(await session.prepareAsync()).toMatchObject({ device: { udid: 'PHONE' } }); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('Could not disable apsd on PHONE; continuing') + ); + expect(onPreparationFailure).not.toHaveBeenCalled(); +}); + it('serve-sim claim during agent-device prepare stops it and returns without waiting', async () => { const preparing = deferred(); const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; @@ -178,8 +231,9 @@ it('claim waits only for the requested tool and stops the other one mid-start', it('replacement boots the new device while the old one is released, then starts only the requested tool', async () => { const release = deferred(); - jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => - args.includes('list') + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => + args.includes('devices') ? JSON.stringify({ devices: { ios: [ @@ -188,7 +242,7 @@ it('replacement boots the new device while the old one is released, then starts ], }, }) - : 'native-start-time' + : original(file, args, signal) ); jest.mocked(stopServicesAndShutdownSimulatorAsync).mockReturnValueOnce(release.promise); const session = createSession(); @@ -213,6 +267,11 @@ it('replacement boots the new device while the old one is released, then starts release.resolve(); expect(await claiming).toEqual({ udid: 'PRO', name: 'iPhone 17 Pro' }); expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); + expect(commands.runAsync).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'spawn', 'PRO', 'launchctl', 'disable', 'user/foreground/com.apple.apsd'], + controller.signal + ); expect(startAgentDevicePreparation).toHaveBeenCalledWith('PRO', expect.anything()); expect(startArgentServer).not.toHaveBeenCalled(); } finally { diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 5a940fbf3d..e991ee7344 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -210,6 +210,45 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise { + let lastError: unknown; + for (const domain of ['user/foreground', 'system']) { + const service = `${domain}/com.apple.apsd`; + try { + await commands.runAsync( + 'xcrun', + ['simctl', 'spawn', udid, 'launchctl', 'disable', service], + signal + ); + // bootout fails when apsd is already gone; the list check below decides. + await commands + .runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'bootout', service], signal) + .catch(error => { + lastError = error; + }); + const loaded = await commands + .runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list', 'com.apple.apsd'], signal) + .then( + () => true, + error => { + if (error.code === 113) { + return false; + } + throw error; + } + ); + if (!loaded) { + return; + } + } catch (error) { + signal.throwIfAborted(); + lastError = error; + } + } + throw lastError ?? new Error('apsd is still loaded in the Simulator.'); +} type WarmToolStartup = { controller: AbortController; ready: Promise }; /** Own resources before preparation starts so every partial attempt can be stopped. */ @@ -359,12 +398,20 @@ export function createWarmSimulatorSession({ serviceProcesses.add(serveSim); await waitForServeSimReadyAsync(udid, serveSim, signal); }); + // The job launches apps right after its claim, so apsd is settled before the device is ready. + const apsdPromise = simulatorBootPromise.then(() => + disableApsdAsync(udid, signal).catch(error => { + signal.throwIfAborted(); + process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`); + }) + ); try { - await serveSimPromise; + await Promise.all([serveSimPromise, apsdPromise]); } catch (error) { onPreparationFailure(error); await Promise.allSettled([ simulatorBootPromise, + apsdPromise, servicesAllowed, ...[...toolStartups.values()].map(startup => startup.ready), ]); From 0cba53cd3e288ec06651936fea60379a0c651d56 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 24 Oct 2026 11:54:03 -0700 Subject: [PATCH 04/42] [simulator-launcher] Stop installing ffmpeg during warmup The VM image provides ffmpeg. Warmup only checks for it and warns when it is missing, instead of running Homebrew. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 ++ .../src/__tests__/simulatorSession.test.ts | 27 +++++++++++++++++++ .../simulator-launcher/src/utils/argent.ts | 21 +++------------ 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index ec25d0f94d..5e80a6e517 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -9,3 +9,5 @@ Start unused-tool shutdown during claim without delaying handoff. Drain it durin Preinstall the latest stable Expo Go during initial warmup without delaying the candidate. Claim prevents late warmup installs and waits for one already in progress before handing the device to the job. Disable apsd on each warm device before reporting it ready. + +Check for ffmpeg without installing it; the VM image provides it. diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 4dc47bff58..470b35c4bc 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -377,3 +377,30 @@ describe('waitForServeSimReadyAsync', () => { ); }); }); + +describe('prepareArgentEnvironmentAsync', () => { + const { prepareArgentEnvironmentAsync: prepareActual } = + jest.requireActual('../utils/argent'); + + it('warns without installing ffmpeg when the image does not provide it', async () => { + jest.mocked(commands.runAsync).mockImplementation(async file => { + if (file === 'ffmpeg') { + throw Object.assign(new Error('spawn ffmpeg ENOENT'), { code: 'ENOENT' }); + } + return ''; + }); + const stderr = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + await prepareActual(controller.signal); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('ffmpeg is unavailable')); + expect( + jest + .mocked(commands.runAsync) + .mock.calls.some(([file, args]) => file.includes('brew') || args.includes('brew')) + ).toBe(false); + expect(commands.runAsync).toHaveBeenCalledWith( + 'bunx', + [expect.stringMatching(/^@swmansion\/argent@/), 'enable', 'artifacts-list-endpoint'], + controller.signal + ); + }); +}); diff --git a/packages/simulator-launcher/src/utils/argent.ts b/packages/simulator-launcher/src/utils/argent.ts index 205d8c1698..94bdf31086 100644 --- a/packages/simulator-launcher/src/utils/argent.ts +++ b/packages/simulator-launcher/src/utils/argent.ts @@ -12,24 +12,11 @@ export async function prepareArgentEnvironmentAsync(signal: AbortSignal): Promis const argentPackage = `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`; signal.throwIfAborted(); - try { - await commands.runAsync('ffmpeg', ['-version'], signal).catch(async error => { - if (error.code !== 'ENOENT') { - throw error; - } - signal.throwIfAborted(); - await commands.runAsync( - '/usr/bin/env', - ['HOMEBREW_NO_AUTO_UPDATE=1', 'brew', 'install', 'ffmpeg'], - signal - ); - signal.throwIfAborted(); - await commands.runAsync('ffmpeg', ['-version'], signal); - }); - } catch (error) { + // The VM image provides ffmpeg; installing it here would delay every warm session. + await commands.runAsync('ffmpeg', ['-version'], signal).catch(error => { signal.throwIfAborted(); - process.stderr.write(`Could not prepare ffmpeg; video tools may be unavailable: ${error}\n`); - } + process.stderr.write(`ffmpeg is unavailable; Argent video tools may not work: ${error}\n`); + }); commands.throwIfCommandCloseTimedOut(); for (const [verb, flag] of [ From f9ee3cbc6b7f67c7815aaa78273212403757bd90 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 10:36:19 -0700 Subject: [PATCH 05/42] [simulator-launcher] Start replacement services without waiting for shutdown Start serve-sim as its own process group. In replacement, stop the unclaimed serve-sim by signalling that group, so the children it respawns stop too and no stale PID is signalled. Start the new serve-sim once the group is gone and its port is free, and shut the old device down in the background. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 +- packages/simulator-launcher/jest.config.ts | 1 + .../src/__tests__/fixturePids.ts | 13 +++ .../src/__tests__/main.test.ts | 7 +- .../src/__tests__/processes.test.ts | 86 ++++++++++++++++++- .../src/__tests__/simulatorSession.test.ts | 70 ++++++++++++--- .../simulator-launcher/src/commands/start.ts | 41 ++++++--- .../simulator-launcher/src/utils/processes.ts | 45 +++++++++- .../simulator-launcher/src/utils/serveSim.ts | 83 +++++++++++++----- 9 files changed, 297 insertions(+), 51 deletions(-) create mode 100644 packages/simulator-launcher/src/__tests__/fixturePids.ts diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 5e80a6e517..8b9c11b385 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -2,7 +2,7 @@ '@expo/simulator-launcher': minor --- -Add start, stop and claim commands for warm Simulator sessions. A session is ready once its device boots and serve-sim answers. A claim waits only for its requested tool and stops the others, even mid-startup. Replacing the device boots the requested one while the old one is cleaned up. +Add start, stop and claim commands for warm Simulator sessions. A session is ready once its device boots and serve-sim answers. A claim waits only for its requested tool and stops the others, even mid-startup. Replacing the device boots the requested one while the old serve-sim and tools stop; new services start once they are gone, and the old device shuts down in the background. Start unused-tool shutdown during claim without delaying handoff. Drain it during terminal cleanup; a shutdown failure prevents a stopped acknowledgement. diff --git a/packages/simulator-launcher/jest.config.ts b/packages/simulator-launcher/jest.config.ts index 9d280c0859..799e8c4411 100644 --- a/packages/simulator-launcher/jest.config.ts +++ b/packages/simulator-launcher/jest.config.ts @@ -6,6 +6,7 @@ const config: Config.InitialOptions = { ...baseConfig, rootDir: 'src', testMatch: ['**/__tests__/*.test.ts'], + setupFiles: ['/__tests__/fixturePids.ts'], clearMocks: true, }; diff --git a/packages/simulator-launcher/src/__tests__/fixturePids.ts b/packages/simulator-launcher/src/__tests__/fixturePids.ts new file mode 100644 index 0000000000..993c6c207d --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/fixturePids.ts @@ -0,0 +1,13 @@ +// Test fixtures use these PIDs for fake child processes. Fail fast instead of letting a test +// reach the real process.kill with one of them, where it could signal an unrelated process. +const FIXTURE_PIDS = new Set([10, 11, 12, 20, 30, 40, 41]); +const isFixturePid = (pid: number): boolean => + FIXTURE_PIDS.has(Math.abs(pid)) || (Math.abs(pid) >= 90000 && Math.abs(pid) < 90100); + +const realKill = process.kill.bind(process); +process.kill = ((pid: number, signal?: string | number) => { + if (isFixturePid(pid)) { + throw new Error(`A test tried to signal fixture PID ${pid} through the real process.kill.`); + } + return realKill(pid, signal); +}) as typeof process.kill; diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 3983a9dfbf..e4f378033e 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -132,6 +132,8 @@ beforeEach(async () => { return job; }); jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue(); + // Fixture PIDs are fake; never signal them. + jest.spyOn(processes, 'terminateProcessGroupAsync').mockResolvedValue(); jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue(); }); afterEach(async () => { @@ -334,9 +336,10 @@ for (const selector of [undefined, 'phone', 'iPhone 17 Pro']) { ).toHaveLength(1); if (replacement) { const calls = jest.mocked(commands.runAsync).mock.calls; + // The new device boots while the old one is released; the old one still shuts down. expect( - calls.findIndex(([, args]) => args.join(' ') === 'simctl shutdown PHONE') - ).toBeLessThan(calls.findIndex(([, args]) => args.join(' ') === 'simctl bootstatus PRO -b')); + calls.findIndex(([, args]) => args.join(' ') === 'simctl bootstatus PRO -b') + ).toBeLessThan(calls.findIndex(([, args]) => args.join(' ') === 'simctl shutdown PHONE')); expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); } await expect(claimAsync(boot, 'argent')).rejects.toThrow('available'); diff --git a/packages/simulator-launcher/src/__tests__/processes.test.ts b/packages/simulator-launcher/src/__tests__/processes.test.ts index 7ba3816506..553b9b6c44 100644 --- a/packages/simulator-launcher/src/__tests__/processes.test.ts +++ b/packages/simulator-launcher/src/__tests__/processes.test.ts @@ -5,12 +5,17 @@ import { PassThrough } from 'node:stream'; jest.mock('node:child_process', () => ({ spawn: jest.fn() })); let commands: typeof import('../utils/processes').commands; let stopProcessAndDescendantsAsync: typeof import('../utils/processes').stopProcessAndDescendantsAsync; +let terminateProcessGroupAsync: typeof import('../utils/processes').terminateProcessGroupAsync; let child: ChildProcess; beforeEach(() => { jest.useFakeTimers(); jest.isolateModules(() => { - ({ commands, stopProcessAndDescendantsAsync } = require('../utils/processes')); + ({ + commands, + stopProcessAndDescendantsAsync, + terminateProcessGroupAsync, + } = require('../utils/processes')); }); child = Object.assign(new EventEmitter(), { pid: 90001, @@ -284,3 +289,82 @@ test('normal wrapper exit after draining does not bypass a missing close timeout child.emit('close', 0, null); expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing'); }); + +describe('terminateProcessGroupAsync', () => { + /** Models the group led by 90001; `members` are its live processes. */ + function group(members: Set, { ignoresTerm = false, survivesKill = false } = {}) { + const signals: [number, string | number | undefined][] = []; + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid !== -90001) { + throw new Error(`Only the process group may be signalled, not ${pid}.`); + } + if (members.size === 0) { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + } + if (signal) { + signals.push([pid, signal]); + } + if ((signal === 'SIGTERM' && !ignoresTerm) || (signal === 'SIGKILL' && !survivesKill)) { + members.clear(); + } + return true; + }); + return signals; + } + + it('signals the whole group once and waits for it to empty', async () => { + const signals = group(new Set([90001, 90002, 90003])); + await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }); + expect(signals).toEqual([[-90001, 'SIGTERM']]); + }); + + it('kills a group that ignores TERM after the grace period', async () => { + jest.useRealTimers(); + const signals = group(new Set([90001, 90004]), { ignoresTerm: true }); + await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50); + expect(signals).toEqual([ + [-90001, 'SIGTERM'], + [-90001, 'SIGKILL'], + ]); + }); + + it('fails when the group survives SIGKILL', async () => { + jest.useRealTimers(); + group(new Set([90001, 90004]), { ignoresTerm: true, survivesKill: true }); + const stopping = terminateProcessGroupAsync( + { child, resultPromise: Promise.resolve('') }, + 50 + ).catch(error => error); + expect((await stopping).message).toContain('process group 90001 is still running'); + }); + + it('never signals a reused PID after its group has exited', async () => { + jest.useRealTimers(); + const members = new Set([90001, 90002]); + const signals = group(members); + const groupKill = jest.mocked(process.kill).getMockImplementation()!; + // After TERM empties the group, 90001's number belongs to an unrelated live process. + jest + .mocked(process.kill) + .mockImplementation((pid, signal) => (pid === 90001 ? true : groupKill(pid, signal))); + await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50); + expect(signals).toEqual([[-90001, 'SIGTERM']]); + expect(jest.mocked(process.kill).mock.calls.some(([pid]) => pid > 0)).toBe(false); + }); +}); + +test('serve-sim leads its own process group', () => { + const { startServeSim } = require('../utils/serveSim'); + startServeSim('PHONE', new AbortController().signal); + expect(spawn).toHaveBeenCalledWith( + 'bunx', + expect.arrayContaining(['PHONE', '--port', '3200']), + expect.objectContaining({ detached: true }) + ); +}); + +test('the fixture PID guard keeps tests away from the real process.kill', () => { + expect(() => process.kill(90002, 0)).toThrow('fixture PID 90002'); + expect(() => process.kill(-90001, 'SIGKILL')).toThrow('fixture PID -90001'); + expect(() => process.kill(11, 0)).toThrow('fixture PID 11'); +}); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 470b35c4bc..c752291b6a 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -12,8 +12,9 @@ import { type ChildProcessHandle, commands, stopProcessAndDescendantsAsync, + terminateProcessGroupAsync, } from '../utils/processes'; -import { startServeSim } from '../utils/serveSim'; +import { startServeSim, waitForServeSimPortFreeAsync } from '../utils/serveSim'; jest.mock('../commands/stop'); jest.mock('../utils/agentDevice'); @@ -229,8 +230,9 @@ it('claim waits only for the requested tool and stops the other one mid-start', } }); -it('replacement boots the new device while the old one is released, then starts only the requested tool', async () => { - const release = deferred(); +it('replacement starts new services once the old serve-sim exits, without waiting for the old device shutdown', async () => { + const serveSimStop = deferred(); + const oldShutdown = deferred(); const original = jest.mocked(commands.runAsync).getMockImplementation()!; jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => args.includes('devices') @@ -242,9 +244,11 @@ it('replacement boots the new device while the old one is released, then starts ], }, }) - : original(file, args, signal) + : args.join(' ') === 'simctl shutdown PHONE' + ? oldShutdown.promise + : original(file, args, signal) ); - jest.mocked(stopServicesAndShutdownSimulatorAsync).mockReturnValueOnce(release.promise); + jest.mocked(terminateProcessGroupAsync).mockReturnValueOnce(serveSimStop.promise); const session = createSession(); try { await session.prepareAsync(); @@ -257,15 +261,14 @@ it('replacement boots the new device while the old one is released, then starts .mocked(commands.runAsync) .mock.calls.some(([, args]) => args.includes('bootstatus') && args.includes('PRO')) ); - expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( - expect.any(Set), - 'PHONE', - undefined - ); + expect(terminateProcessGroupAsync).toHaveBeenCalledWith(serveSim); expect(startServeSim).not.toHaveBeenCalled(); expect(startAgentDevicePreparation).not.toHaveBeenCalled(); - release.resolve(); + serveSimStop.resolve(); + // The claim returns while the old device is still shutting down. expect(await claiming).toEqual({ udid: 'PRO', name: 'iPhone 17 Pro' }); + expect(waitForServeSimPortFreeAsync).toHaveBeenCalled(); + expect(commands.runAsync).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'PHONE']); expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); expect(commands.runAsync).toHaveBeenCalledWith( 'xcrun', @@ -274,11 +277,54 @@ it('replacement boots the new device while the old one is released, then starts ); expect(startAgentDevicePreparation).toHaveBeenCalledWith('PRO', expect.anything()); expect(startArgentServer).not.toHaveBeenCalled(); + // Terminal cleanup waits for the background shutdown, then owns only the new device. + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + oldShutdown.resolve(''); + await stopping; + expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledTimes(1); + expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( + expect.any(Set), + 'PRO', + agent + ); } finally { - release.resolve(); + serveSimStop.resolve(); + oldShutdown.resolve(''); } }); +it('keeps a replaced device owned when its background shutdown fails', async () => { + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => + args.includes('devices') + ? JSON.stringify({ + devices: { + ios: [ + { udid: 'PHONE', name: 'iPhone 17' }, + { udid: 'PRO', name: 'iPhone 17 Pro' }, + ], + }, + }) + : args.join(' ') === 'simctl shutdown PHONE' + ? Promise.reject(new Error('shutdown failed')) + : original(file, args, signal) + ); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PRO' }); + await session.stopAsync(); + expect( + jest.mocked(stopServicesAndShutdownSimulatorAsync).mock.calls.map(([, udid]) => udid) + ).toEqual(['PHONE', 'PRO']); + expect(onBackgroundFailure).not.toHaveBeenCalled(); +}); + it('drains unused tools and the whole Expo Go task before native cleanup, retaining their failure', async () => { const unusedAgent = deferred(); const unusedArgent = deferred(); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index e991ee7344..13a4b9a1d5 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -19,8 +19,13 @@ import { type ChildProcessHandle, commands, stopProcessAndDescendantsAsync, + terminateProcessGroupAsync, } from '../utils/processes'; -import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; +import { + startServeSim, + waitForServeSimPortFreeAsync, + waitForServeSimReadyAsync, +} from '../utils/serveSim'; import { ClaimInputSchema, type LauncherState, @@ -273,6 +278,9 @@ export function createWarmSimulatorSession({ let argentProcess: ChildProcessHandle | undefined; let expoGoPreinstall: ReturnType | undefined; let unusedToolsCleanup: Promise[]> = Promise.resolve([]); + let serveSimProcess: ChildProcessHandle | undefined; + // A replaced device shuts down in the background; terminal cleanup retries it if needed. + let previousDeviceShutdown: Promise = Promise.resolve(); async function listAvailableSimulatorsAsync(): Promise { const { devices } = SimulatorListSchema.parse( @@ -395,6 +403,7 @@ export function createWarmSimulatorSession({ } const serveSimPromise = servicesReady.then(async () => { const serveSim = startServeSim(udid, signal); + serveSimProcess = serveSim; serviceProcesses.add(serveSim); await waitForServeSimReadyAsync(udid, serveSim, signal); }); @@ -457,20 +466,29 @@ export function createWarmSimulatorSession({ tool => tool === request.engine ); if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) { - // Release the old device while the new one boots; services start after the release. + // The new device boots while the old services stop. The old serve-sim is unclaimed and + // has nothing to finalize, so its process group stops without a grace wait. The new services start once + // the old ones are gone; the old device shuts down in the background and stays owned. const previousUdid = currentDevice.udid; + const previousServeSim = serveSimProcess; const release = (async () => { - await Promise.all([...toolStartups.keys()].map(tool => stopToolAsync(tool))); - await stopServicesAndShutdownSimulatorAsync( - serviceProcesses, - previousUdid, - agentDevicePrepareProcess - ); - ownedSimulatorUdids.delete(previousUdid); - agentDevicePrepareProcess = undefined; - argentProcess = undefined; + await Promise.all([ + ...[...toolStartups.keys()].map(tool => stopToolAsync(tool)), + (async () => { + if (previousServeSim) { + await terminateProcessGroupAsync(previousServeSim); + serviceProcesses.delete(previousServeSim); + } + })(), + ]); + await waitForServeSimPortFreeAsync(signal); await throwIfStoppingAsync(); })(); + previousDeviceShutdown = release.then(async () => { + await commands.runAsync('xcrun', ['simctl', 'shutdown', previousUdid]); + ownedSimulatorUdids.delete(previousUdid); + }); + void previousDeviceShutdown.catch(() => {}); await bootSimulatorAndStartServicesAsync( device, requestedTool ? [requestedTool] : [], @@ -506,6 +524,7 @@ export function createWarmSimulatorSession({ startup.controller.abort(); } await Promise.allSettled([...toolStartups.values()].map(startup => startup.ready)); + await previousDeviceShutdown.catch(() => {}); const cleanupErrors: unknown[] = []; for (const udid of ownedSimulatorUdids.size ? [...ownedSimulatorUdids] : [undefined]) { try { diff --git a/packages/simulator-launcher/src/utils/processes.ts b/packages/simulator-launcher/src/utils/processes.ts index d3a3062eba..cc463e0f25 100644 --- a/packages/simulator-launcher/src/utils/processes.ts +++ b/packages/simulator-launcher/src/utils/processes.ts @@ -9,11 +9,14 @@ export const commands = { file: string, args: string[], signal?: AbortSignal, - captureStdout = false + captureStdout = false, + /** Lead a new process group, so terminateProcessGroupAsync can signal the whole tree. */ + detached = false ): ChildProcessHandle { const child = spawn(file, args, { stdio: captureStdout ? ['ignore', 'pipe', 'inherit'] : 'inherit', signal, + detached, }); let stdout = ''; let error: Error | undefined; @@ -170,3 +173,43 @@ export async function stopProcessAndDescendantsAsync( await processHandle.resultPromise.catch(() => {}); commands.throwIfCommandCloseTimedOut(); } + +/** + * Stops a process group that has nothing to finalize. The handle must lead its own group + * (`commands.start(..., detached)`), so children spawned or respawned during the stop are + * signalled too, and the group ID cannot be reused while any member is alive. + */ +export async function terminateProcessGroupAsync( + processHandle: ChildProcessHandle, + graceMs = 5_000 +): Promise { + const groupId = processHandle.child.pid; + if (!groupId) { + throw new Error('A Simulator service has no process group; cleanup could not be confirmed.'); + } + const isGroupRunning = (): boolean => isProcessRunning(-groupId); + for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + if (!isGroupRunning()) { + break; + } + try { + process.kill(-groupId, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + break; + } + const deadline = Date.now() + graceMs; + while (isGroupRunning() && Date.now() < deadline) { + await sleepAsync(100); + } + } + if (isGroupRunning()) { + throw new Error( + `Simulator service process group ${groupId} is still running; cleanup could not be confirmed.` + ); + } + await processHandle.resultPromise.catch(() => {}); + commands.throwIfCommandCloseTimedOut(); +} diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index dd32228994..fce70eefdb 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleepAsync } from 'node:timers/promises'; @@ -6,34 +7,42 @@ import { z } from 'zod'; import { type ChildProcessHandle, commands } from './processes'; +const SERVE_SIM_PORT = 3200; const SERVE_SIM_READY_TIMEOUT_MS = 120_000; const ServeSimStateSchema = z.object({ device: z.string(), url: z.string(), token: z.string() }); export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHandle { signal.throwIfAborted(); - return commands.start('bunx', [ - `@expo/serve-sim@${process.env.SERVE_SIM_VERSION || 'latest'}`, - udid, - '--host', - '127.0.0.1', - '--port', - '3200', - '--require-token', - '--transport', - 'webrtc', - '--webrtc-codec', - 'h264', - '--max-dimension', - '1600', - '--mjpeg-quality', - '0.55', - '--video-bitrate', - '10000000', - '--video-fps', - '60', - '--disable-share', - '--quiet', - ]); + return commands.start( + 'bunx', + [ + `@expo/serve-sim@${process.env.SERVE_SIM_VERSION || 'latest'}`, + udid, + '--host', + '127.0.0.1', + '--port', + String(SERVE_SIM_PORT), + '--require-token', + '--transport', + 'webrtc', + '--webrtc-codec', + 'h264', + '--max-dimension', + '1600', + '--mjpeg-quality', + '0.55', + '--video-bitrate', + '10000000', + '--video-fps', + '60', + '--disable-share', + '--quiet', + ], + undefined, + /* captureStdout */ false, + // Its own group lets replacement stop serve-sim and the children it respawns together. + /* detached */ true + ); } /** A job reads serve-sim's state and calls /readyz as soon as its claim returns. */ @@ -74,3 +83,31 @@ export async function waitForServeSimReadyAsync( await sleepAsync(250, undefined, { signal }); } } + +/** A replacement serve-sim can bind its port once nothing accepts connections on it. */ +export async function waitForServeSimPortFreeAsync( + signal: AbortSignal, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + signal.throwIfAborted(); + const listening = await new Promise(resolve => { + const socket = net.connect({ host: '127.0.0.1', port: SERVE_SIM_PORT }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); + if (!listening) { + return; + } + if (Date.now() >= deadline) { + throw new Error( + `Port ${SERVE_SIM_PORT} is still in use; the previous serve-sim did not stop.` + ); + } + await sleepAsync(100, undefined, { signal }); + } +} From 6c38a43a938799b7312efe7e1f90ad0dfc63a11d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:30:44 -0700 Subject: [PATCH 06/42] [simulator-launcher] Report the preinstalled Expo Go in the claim record Read the bundle identifier and versions from the Expo Go app the launcher installed, and include them in the claim record for the same device. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 + .../src/__tests__/expoGo.test.ts | 45 ++++++++++++++++++ .../src/__tests__/main.test.ts | 20 ++++++-- .../src/__tests__/simulatorSession.test.ts | 47 +++++++++++++++---- .../simulator-launcher/src/commands/start.ts | 23 ++++++--- .../simulator-launcher/src/utils/expoGo.ts | 23 ++++++++- .../simulator-launcher/src/utils/state.ts | 9 ++++ 7 files changed, 149 insertions(+), 20 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 8b9c11b385..591b20f1d1 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -11,3 +11,5 @@ Preinstall the latest stable Expo Go during initial warmup without delaying the Disable apsd on each warm device before reporting it ready. Check for ffmpeg without installing it; the VM image provides it. + +Record the preinstalled Expo Go identity in the claim record, so the job can skip a matching install without asking the Simulator. diff --git a/packages/simulator-launcher/src/__tests__/expoGo.test.ts b/packages/simulator-launcher/src/__tests__/expoGo.test.ts index 11ffb52898..c282e45be4 100644 --- a/packages/simulator-launcher/src/__tests__/expoGo.test.ts +++ b/packages/simulator-launcher/src/__tests__/expoGo.test.ts @@ -395,3 +395,48 @@ it.each(['traversal', 'malformed'] as const)( expect(await fs.readdir(directory)).toEqual([]); } ); + +it('claim returns the identity read from the Expo Go it installed', async () => { + const plist: Record = { + CFBundleIdentifier: 'host.exp.Exponent', + CFBundleShortVersionString: '58.0.7', + CFBundleVersion: '58.0.7', + }; + jest + .mocked(commands.runAsync) + .mockImplementation(async (file, args, signal) => + file === '/usr/bin/tar' + ? await runCommandAsync(file, args, signal) + : file === 'plutil' + ? `${plist[args[1]]}\n` + : '' + ); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + await warmup.resultPromise; + expect(await warmup.claimAsync()).toEqual({ + udid: 'UDID', + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.7', + version: '58.0.7', + }); + const plistPath = jest + .mocked(commands.runAsync) + .mock.calls.find(([file]) => file === 'plutil')![1]; + expect(path.basename(path.dirname(plistPath[5]))).toBe('ExpoGo.app'); +}); + +it('claim returns no identity when the preinstall fails', async () => { + jest + .mocked(commands.runAsync) + .mockImplementation(async (file, args, signal) => + file === '/usr/bin/tar' + ? await runCommandAsync(file, args, signal) + : file === 'xcrun' + ? Promise.reject(Object.assign(new Error('install failed'), { code: 1 })) + : '' + ); + warmup = startExpoGoPreinstall('UDID', Promise.resolve(), controller.signal); + await warmup.resultPromise; + expect(await warmup.claimAsync()).toBeUndefined(); + expect(jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'plutil')).toBe(false); +}); diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index e4f378033e..37d5ac44c6 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -100,7 +100,7 @@ beforeEach(async () => { .mockReset() .mockReturnValue({ resultPromise: Promise.resolve(), - claimAsync: jest.fn(async () => {}), + claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(async () => {}), }); jest.spyOn(commands, 'runAsync').mockImplementation(async (file, args) => { @@ -436,7 +436,13 @@ test('stop during replacement prevents a late claimed result and drains newly ow }); test('claim joins an already-started Expo Go install and terminal stop drains the whole task', async () => { - const install = deferred(); + const preinstalledExpoGo = { + udid: 'PHONE', + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.7', + version: '58.0.7', + }; + const install = deferred(); const cleanup = deferred(); jest.mocked(startExpoGoPreinstall).mockReturnValue({ resultPromise: cleanup.promise, @@ -450,8 +456,14 @@ test('claim joins an already-started Expo Go install and terminal stop drains th try { await status('claiming'); expect((await readLauncherStateAsync(boot))?.status).not.toBe('claimed'); - install.resolve(); + install.resolve(preinstalledExpoGo); await claiming; + // The claim record tells the job which Expo Go is already installed on this device. + expect(await readLauncherStateAsync(boot)).toMatchObject({ + status: 'claimed', + device: { udid: 'PHONE' }, + preinstalledExpoGo, + }); const stopping = stopAsync(boot); await status('stopping'); expect((await readLauncherStateAsync(boot))?.status).not.toBe('disabled'); @@ -459,7 +471,7 @@ test('claim joins an already-started Expo Go install and terminal stop drains th await stopping; await completion; } finally { - install.resolve(); + install.resolve(preinstalledExpoGo); cleanup.resolve(); } }); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index c752291b6a..3fb28186f7 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -79,7 +79,7 @@ beforeEach(() => { jest.mocked(startAgentDevicePreparation).mockReturnValue(agent); jest.mocked(startArgentServer).mockReturnValue(argent); jest.mocked(startExpoGoPreinstall).mockReturnValue({ - claimAsync: jest.fn(async () => {}), + claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(async () => {}), resultPromise: Promise.resolve(), }); @@ -180,8 +180,7 @@ it('serve-sim claim during agent-device prepare stops it and returns without wai try { await session.prepareAsync(); expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ - udid: 'PHONE', - name: 'iPhone 17', + device: { udid: 'PHONE', name: 'iPhone 17' }, }); await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true); @@ -221,7 +220,7 @@ it('claim waits only for the requested tool and stops the other one mid-start', expect(claimed).toBe(false); expect(startArgentServer).not.toHaveBeenCalled(); argentConfig.resolve(); - expect(await claiming).toMatchObject({ udid: 'PHONE' }); + expect(await claiming).toMatchObject({ device: { udid: 'PHONE' } }); expect(startArgentServer).toHaveBeenCalledTimes(1); expect(onPreparationFailure).not.toHaveBeenCalled(); } finally { @@ -266,7 +265,7 @@ it('replacement starts new services once the old serve-sim exits, without waitin expect(startAgentDevicePreparation).not.toHaveBeenCalled(); serveSimStop.resolve(); // The claim returns while the old device is still shutting down. - expect(await claiming).toEqual({ udid: 'PRO', name: 'iPhone 17 Pro' }); + expect(await claiming).toEqual({ device: { udid: 'PRO', name: 'iPhone 17 Pro' } }); expect(waitForServeSimPortFreeAsync).toHaveBeenCalled(); expect(commands.runAsync).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'PHONE']); expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); @@ -331,7 +330,7 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain const expoCleanup = deferred(); const failure = new Error('Argent stop failed'); const preinstall = { - claimAsync: jest.fn(async () => {}), + claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(() => expoCleanup.promise), resultPromise: expoCleanup.promise, }; @@ -349,8 +348,7 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain argentEventLogPath: '/isolated-session-fixture/events', }); expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ - udid: 'PHONE', - name: 'iPhone 17', + device: { udid: 'PHONE', name: 'iPhone 17' }, }); expect(preinstall.claimAsync).toHaveBeenCalledTimes(1); const stopping = session.stopAsync(); @@ -450,3 +448,36 @@ describe('prepareArgentEnvironmentAsync', () => { ); }); }); + +it('reports the preinstalled Expo Go only for the device it was installed on', async () => { + const preinstalledExpoGo = { + udid: 'PHONE', + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.7', + version: '58.0.7', + }; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => + args.includes('devices') + ? JSON.stringify({ + devices: { + ios: [ + { udid: 'PHONE', name: 'iPhone 17' }, + { udid: 'PRO', name: 'iPhone 17 Pro' }, + ], + }, + }) + : original(file, args, signal) + ); + for (const requestedDevice of [undefined, 'PRO']) { + jest.mocked(startExpoGoPreinstall).mockReturnValueOnce({ + claimAsync: jest.fn(async () => preinstalledExpoGo), + stopAsync: jest.fn(async () => {}), + resultPromise: Promise.resolve(), + }); + const session = createSession(); + await session.prepareAsync(); + const claimed = await session.claimAsync({ engine: 'serve-sim', requestedDevice }); + expect(claimed.preinstalledExpoGo).toEqual(requestedDevice ? undefined : preinstalledExpoGo); + } +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 13a4b9a1d5..e53e77ffd1 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -29,6 +29,7 @@ import { import { ClaimInputSchema, type LauncherState, + type PreinstalledApp, type SimulatorDevice, SimulatorDeviceSchema, type WarmSessionInfo, @@ -39,7 +40,9 @@ import { type WarmSimulatorSession = { prepareAsync(): Promise; - claimAsync(request: z.infer): Promise; + claimAsync( + request: z.infer + ): Promise<{ device: SimulatorDevice; preinstalledExpoGo?: PreinstalledApp }>; stopAsync(): Promise; }; @@ -157,7 +160,7 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise { @@ -168,7 +171,12 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise): Promise { - await expoGoPreinstall?.claimAsync(); + async claimAsync(request: z.infer) { + const preinstalledExpoGo = await expoGoPreinstall?.claimAsync(); await throwIfStoppingAsync(); const device = !request.requestedDevice || @@ -511,7 +519,10 @@ export function createWarmSimulatorSession({ if (requestedTool) { await toolStartups.get(requestedTool)!.ready; } - return currentDevice; + // A replacement device has no preinstalled Expo Go. + return preinstalledExpoGo?.udid === currentDevice.udid + ? { device: currentDevice, preinstalledExpoGo } + : { device: currentDevice }; }, /** Drain all owned work. Rejection means terminal cleanup cannot be acknowledged. */ diff --git a/packages/simulator-launcher/src/utils/expoGo.ts b/packages/simulator-launcher/src/utils/expoGo.ts index 02efd068e7..d3b0ac3f66 100644 --- a/packages/simulator-launcher/src/utils/expoGo.ts +++ b/packages/simulator-launcher/src/utils/expoGo.ts @@ -6,6 +6,7 @@ import { Writable } from 'node:stream'; import { z } from 'zod'; import { commands } from './processes'; +import { type PreinstalledApp } from './state'; const VERSIONS_URL = 'https://api.expo.dev/v2/versions'; const HEADERS = { Accept: 'application/json', 'User-Agent': 'expo-simulator-launcher' }; @@ -26,7 +27,8 @@ export function startExpoGoPreinstall( boot: Promise, signal: AbortSignal ): { - claimAsync(): Promise; + /** Resolves with the installed Expo Go, or undefined when the preinstall did not finish. */ + claimAsync(): Promise; stopAsync(): Promise; resultPromise: Promise; } { @@ -37,6 +39,7 @@ export function startExpoGoPreinstall( AbortSignal.timeout(120_000), ]); let installation: Promise | undefined; + let installed: PreinstalledApp | undefined; let failure: unknown; void boot.catch(() => {}); const warmup = (async () => { @@ -77,8 +80,13 @@ export function startExpoGoPreinstall( // No await between this latch and launch. Claim cannot race a not-yet-started install. process.stderr.write(`Preinstalling Expo Go for SDK ${sdk} on Simulator ${udid}.\n`); installation = commands.runAsync('xcrun', ['simctl', 'install', udid, appPath]).then( - () => { + async () => { process.stderr.write('Expo Go preinstall finished.\n'); + // The job compares its app with this record instead of asking the Simulator. + installed = await readPreinstalledAppAsync(udid, appPath).catch(error => { + process.stderr.write(`Could not read the preinstalled Expo Go version: ${error}\n`); + return undefined; + }); }, error => { commands.throwIfCommandCloseTimedOut(); @@ -120,6 +128,7 @@ export function startExpoGoPreinstall( throw failure; } commands.throwIfCommandCloseTimedOut(); + return installed; }, async stopAsync() { downloadController.abort(); @@ -128,3 +137,13 @@ export function startExpoGoPreinstall( }, }; } + +async function readPreinstalledAppAsync(udid: string, appPath: string): Promise { + const infoPlistPath = path.join(appPath, 'Info.plist'); + const [bundleIdentifier, shortVersion, version] = await Promise.all( + ['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key => + (await commands.runAsync('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath])).trim() + ) + ); + return { udid, bundleIdentifier, shortVersion, version }; +} diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index 294f5a0c63..4d98abf1fe 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -16,6 +16,13 @@ export const ClaimInputSchema = z.object({ engine: z.enum(['serve-sim', 'agent-device', 'argent']), requestedDevice: NonBlankStringSchema.optional(), }); +/** The Expo Go this launcher installed on `udid` and never launched. */ +export const PreinstalledAppSchema = z.object({ + udid: NonBlankStringSchema, + bundleIdentifier: NonBlankStringSchema, + shortVersion: z.string(), + version: z.string(), +}); export const WarmSessionInfoSchema = z.object({ device: SimulatorDeviceSchema, controllerPid: z.number().int().gt(1), @@ -27,11 +34,13 @@ export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({ bootUuid: NonBlankStringSchema, status: z.enum(['starting', 'ready', 'claiming', 'claimed', 'stopping', 'disabled']), ...ClaimInputSchema.partial().shape, + preinstalledExpoGo: PreinstalledAppSchema.optional(), error: z.string().optional(), }); export type LauncherState = z.infer; export type WarmSessionInfo = z.infer; export type SimulatorDevice = z.infer; +export type PreinstalledApp = z.infer; export function getInstalledLauncherDirectory(): string { return path.join(os.homedir(), '.expo/simulator-tools'); From 5c600e3e69b0ce312483d063515dfd921e06e080 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:31:32 -0700 Subject: [PATCH 07/42] [simulator-launcher] Handle a failed serve-sim exit while waiting for readiness Observe both outcomes of the serve-sim process. A rejected exit no longer becomes an unhandled rejection that ends the launcher. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/simulatorSession.test.ts | 19 +++++++++++++++++++ .../simulator-launcher/src/utils/serveSim.ts | 6 ++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 3fb28186f7..32ecdee60d 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -413,6 +413,25 @@ describe('waitForServeSimReadyAsync', () => { } }); + it('fails without an unhandled rejection when serve-sim exits with an error', async () => { + jest.spyOn(os, 'tmpdir').mockReturnValue('/missing-serve-sim-fixture'); + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + try { + const crashed = { + child: { pid: 10 } as ChildProcess, + resultPromise: Promise.reject(new Error('bunx exited with SIGTERM.')), + }; + await expect(waitForServeSimReadyAsync('PHONE', crashed, controller.signal)).rejects.toThrow( + 'serve-sim exited before PHONE became ready.' + ); + await new Promise(resolve => setImmediate(resolve)); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); + it('fails when serve-sim exits before it is ready', async () => { jest.spyOn(os, 'tmpdir').mockReturnValue('/missing-serve-sim-fixture'); const exited = { child: { pid: 10 } as ChildProcess, resultPromise: Promise.resolve('') }; diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index fce70eefdb..433af6d649 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -52,9 +52,11 @@ export async function waitForServeSimReadyAsync( signal: AbortSignal ): Promise { let exited = false; - void server.resultPromise.finally(() => { + // Handle both outcomes: a rejection here would otherwise be unhandled and end the launcher. + const markExited = (): void => { exited = true; - }); + }; + void server.resultPromise.then(markExited, markExited); const stateFile = path.join(os.tmpdir(), 'serve-sim', `server-${udid}.json`); const deadline = Date.now() + SERVE_SIM_READY_TIMEOUT_MS; for (;;) { From 64cddb025e14a13ffde1be8e52bf8f61cbaf8850 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:34:02 -0700 Subject: [PATCH 08/42] [simulator-launcher] Read serve-sim readiness from its configured state Use SERVE_SIM_STATE_DIR when it is set, as serve-sim does. Drop the bearer header from /readyz, which serve-sim does not gate. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/simulatorSession.test.ts | 26 ++++++++++++++++++- .../simulator-launcher/src/utils/serveSim.ts | 5 ++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 32ecdee60d..3f1e54ad7c 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -404,7 +404,6 @@ describe('waitForServeSimReadyAsync', () => { ); await waiting; expect(fetchSpy).toHaveBeenLastCalledWith('http://127.0.0.1:3200/readyz', { - headers: { Authorization: 'Bearer secret' }, signal: expect.any(AbortSignal), }); expect(fetchSpy).toHaveBeenCalledTimes(2); @@ -413,6 +412,31 @@ describe('waitForServeSimReadyAsync', () => { } }); + it('reads serve-sim state from SERVE_SIM_STATE_DIR when it is set', async () => { + const stateDir = await fs.mkdtemp( + path.join(jest.requireActual('node:os').tmpdir(), 'serve-sim-state-') + ); + process.env.SERVE_SIM_STATE_DIR = stateDir; + jest.spyOn(os, 'tmpdir').mockReturnValue('/missing-serve-sim-fixture'); + jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ status: 'ready', device: 'PHONE' }))); + try { + await fs.writeFile( + path.join(stateDir, 'server-PHONE.json'), + JSON.stringify({ device: 'PHONE', url: 'http://127.0.0.1:3200', token: 'secret' }) + ); + const running = { + child: { pid: 10 } as ChildProcess, + resultPromise: new Promise(() => {}), + }; + await waitForServeSimReadyAsync('PHONE', running, controller.signal); + expect(fetch).toHaveBeenCalledTimes(1); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it('fails without an unhandled rejection when serve-sim exits with an error', async () => { jest.spyOn(os, 'tmpdir').mockReturnValue('/missing-serve-sim-fixture'); const unhandled = jest.fn(); diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index 433af6d649..a4aa2c1f79 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -9,6 +9,7 @@ import { type ChildProcessHandle, commands } from './processes'; const SERVE_SIM_PORT = 3200; const SERVE_SIM_READY_TIMEOUT_MS = 120_000; +// The job needs the token from this file; /readyz itself is not token-gated. const ServeSimStateSchema = z.object({ device: z.string(), url: z.string(), token: z.string() }); export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHandle { @@ -57,7 +58,8 @@ export async function waitForServeSimReadyAsync( exited = true; }; void server.resultPromise.then(markExited, markExited); - const stateFile = path.join(os.tmpdir(), 'serve-sim', `server-${udid}.json`); + const stateDirectory = process.env.SERVE_SIM_STATE_DIR || path.join(os.tmpdir(), 'serve-sim'); + const stateFile = path.join(stateDirectory, `server-${udid}.json`); const deadline = Date.now() + SERVE_SIM_READY_TIMEOUT_MS; for (;;) { signal.throwIfAborted(); @@ -70,7 +72,6 @@ export async function waitForServeSimReadyAsync( try { const state = ServeSimStateSchema.parse(JSON.parse(await fs.readFile(stateFile, 'utf8'))); const response = await fetch(`${state.url}/readyz`, { - headers: { Authorization: `Bearer ${state.token}` }, signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]), }); const ready = response.ok From f8f672b3de4ed090f776dec73edcb87bb47fb583 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:34:41 -0700 Subject: [PATCH 09/42] [simulator-launcher] Settle every old stop before failing a replacement A fast tool-stop failure no longer rejects the release while the old serve-sim is still stopping, so cleanup cannot race that stop. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/simulatorSession.test.ts | 43 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 7 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 3f1e54ad7c..77777f80d1 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -298,6 +298,49 @@ it('replacement starts new services once the old serve-sim exits, without waitin } }); +it('fails a replacement only after every old stop settles', async () => { + const serveSimStop = deferred(); + const daemonFailure = new Error('daemon stop failed'); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => + args.includes('devices') + ? JSON.stringify({ + devices: { + ios: [ + { udid: 'PHONE', name: 'iPhone 17' }, + { udid: 'PRO', name: 'iPhone 17 Pro' }, + ], + }, + }) + : original(file, args, signal) + ); + jest.mocked(terminateProcessGroupAsync).mockReturnValueOnce(serveSimStop.promise); + jest.mocked(stopAgentDeviceDaemonAsync).mockRejectedValueOnce(daemonFailure); + const session = createSession(); + try { + await session.prepareAsync(); + let settled = false; + const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PRO' }).then( + () => { + settled = true; + }, + error => { + settled = true; + return error; + } + ); + await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); + await new Promise(resolve => setImmediate(resolve)); + // The old serve-sim is still stopping, so the replacement has not failed yet. + expect(settled).toBe(false); + expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + serveSimStop.resolve(); + expect(await claiming).toBe(daemonFailure); + } finally { + serveSimStop.resolve(); + } +}); + it('keeps a replaced device owned when its background shutdown fails', async () => { const original = jest.mocked(commands.runAsync).getMockImplementation()!; jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index e53e77ffd1..7ce9b8e9ef 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -480,7 +480,8 @@ export function createWarmSimulatorSession({ const previousUdid = currentDevice.udid; const previousServeSim = serveSimProcess; const release = (async () => { - await Promise.all([ + // Settle every stop before failing, so cleanup never races a stop still in flight. + const stops = await Promise.allSettled([ ...[...toolStartups.keys()].map(tool => stopToolAsync(tool)), (async () => { if (previousServeSim) { @@ -489,6 +490,10 @@ export function createWarmSimulatorSession({ } })(), ]); + const failed = stops.find(stop => stop.status === 'rejected'); + if (failed?.status === 'rejected') { + throw failed.reason; + } await waitForServeSimPortFreeAsync(signal); await throwIfStoppingAsync(); })(); From 7bd9f73dc91d4c017f35ba75071ea870b881ef15 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:36:43 -0700 Subject: [PATCH 10/42] [simulator-launcher] Treat an already shut down Simulator as cleaned up A retried shutdown of a device that is already shut down failed forever. Check the device state when simctl shutdown fails. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/main.test.ts | 21 +++++++++++++- .../src/__tests__/simulatorSession.test.ts | 16 +++++------ .../simulator-launcher/src/commands/start.ts | 4 +-- .../simulator-launcher/src/commands/stop.ts | 28 +++++++++++++++++-- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 37d5ac44c6..c7566d5746 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { claimAsync } from '../commands/claim'; import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start'; -import { stopAsync } from '../commands/stop'; +import { shutdownSimulatorAsync, stopAsync } from '../commands/stop'; import { mainAsync } from '../main'; import { startExpoGoPreinstall } from '../utils/expoGo'; import { type ChildProcessHandle, commands } from '../utils/processes'; @@ -543,3 +543,22 @@ test.each(['stop', 'claim'])( } } ); + +test.each([ + ['Shutdown', true], + ['Booted', false], +])('a failed shutdown of a device that is %s counts as done: %s', async (state, done) => { + const failure = Object.assign(new Error('xcrun exited with 149.'), { code: 149 }); + jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => { + if (args.includes('shutdown')) { + throw failure; + } + return JSON.stringify({ devices: { ios: [{ udid: 'OLD', state }] } }); + }); + const shutdown = shutdownSimulatorAsync('OLD'); + if (done) { + await expect(shutdown).resolves.toBeUndefined(); + } else { + await expect(shutdown).rejects.toBe(failure); + } +}); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 77777f80d1..8ff8d8b305 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { createWarmSimulatorSession } from '../commands/start'; -import { stopServicesAndShutdownSimulatorAsync } from '../commands/stop'; +import { shutdownSimulatorAsync, stopServicesAndShutdownSimulatorAsync } from '../commands/stop'; import { startAgentDevicePreparation, stopAgentDeviceDaemonAsync } from '../utils/agentDevice'; import { prepareArgentEnvironmentAsync, startArgentServer } from '../utils/argent'; import { startExpoGoPreinstall } from '../utils/expoGo'; @@ -243,10 +243,11 @@ it('replacement starts new services once the old serve-sim exits, without waitin ], }, }) - : args.join(' ') === 'simctl shutdown PHONE' - ? oldShutdown.promise - : original(file, args, signal) + : original(file, args, signal) ); + jest.mocked(shutdownSimulatorAsync).mockImplementation(async () => { + await oldShutdown.promise; + }); jest.mocked(terminateProcessGroupAsync).mockReturnValueOnce(serveSimStop.promise); const session = createSession(); try { @@ -267,7 +268,7 @@ it('replacement starts new services once the old serve-sim exits, without waitin // The claim returns while the old device is still shutting down. expect(await claiming).toEqual({ device: { udid: 'PRO', name: 'iPhone 17 Pro' } }); expect(waitForServeSimPortFreeAsync).toHaveBeenCalled(); - expect(commands.runAsync).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'PHONE']); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); expect(commands.runAsync).toHaveBeenCalledWith( 'xcrun', @@ -353,10 +354,9 @@ it('keeps a replaced device owned when its background shutdown fails', async () ], }, }) - : args.join(' ') === 'simctl shutdown PHONE' - ? Promise.reject(new Error('shutdown failed')) - : original(file, args, signal) + : original(file, args, signal) ); + jest.mocked(shutdownSimulatorAsync).mockRejectedValueOnce(new Error('shutdown failed')); const session = createSession(); await session.prepareAsync(); await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PRO' }); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 7ce9b8e9ef..bbc3dc6564 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { setTimeout as sleepAsync } from 'node:timers/promises'; import { z } from 'zod'; -import { stopServicesAndShutdownSimulatorAsync } from './stop'; +import { shutdownSimulatorAsync, stopServicesAndShutdownSimulatorAsync } from './stop'; import { setAgentDeviceEnvironmentDefaults, startAgentDevicePreparation, @@ -498,7 +498,7 @@ export function createWarmSimulatorSession({ await throwIfStoppingAsync(); })(); previousDeviceShutdown = release.then(async () => { - await commands.runAsync('xcrun', ['simctl', 'shutdown', previousUdid]); + await shutdownSimulatorAsync(previousUdid); ownedSimulatorUdids.delete(previousUdid); }); void previousDeviceShutdown.catch(() => {}); diff --git a/packages/simulator-launcher/src/commands/stop.ts b/packages/simulator-launcher/src/commands/stop.ts index bf6ec8fac5..2d7d71d161 100644 --- a/packages/simulator-launcher/src/commands/stop.ts +++ b/packages/simulator-launcher/src/commands/stop.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { stopAgentDeviceDaemonAsync } from '../utils/agentDevice'; import { type ChildProcessHandle, @@ -6,6 +8,28 @@ import { } from '../utils/processes'; import { updateLauncherStateAsync, waitForLauncherStateAsync } from '../utils/state'; +const SimulatorStatesSchema = z.object({ + devices: z.record(z.string(), z.array(z.object({ udid: z.string(), state: z.string() }))), +}); + +/** `simctl shutdown` fails for a device that is already shut down; that device is done. */ +export async function shutdownSimulatorAsync(udid: string): Promise { + try { + await commands.runAsync('xcrun', ['simctl', 'shutdown', udid]); + } catch (error) { + const { devices } = SimulatorStatesSchema.parse( + JSON.parse(await commands.runAsync('xcrun', ['simctl', 'list', 'devices', '--json'])) + ); + if ( + Object.values(devices) + .flat() + .find(device => device.udid === udid)?.state !== 'Shutdown' + ) { + throw error; + } + } +} + export async function stopAsync(bootUuid: string): Promise { const state = await updateLauncherStateAsync(bootUuid, current => !current || current.status === 'disabled' @@ -34,9 +58,7 @@ export async function stopServicesAndShutdownSimulatorAsync( } if (simulatorUdid) { await stopAgentDeviceDaemonAsync().catch(error => cleanupErrors.push(error)); - await commands - .runAsync('xcrun', ['simctl', 'shutdown', simulatorUdid]) - .catch(error => cleanupErrors.push(error)); + await shutdownSimulatorAsync(simulatorUdid).catch(error => cleanupErrors.push(error)); } try { commands.throwIfCommandCloseTimedOut(); From 8fef6f5fe19fc61074b4800c963305204077af36 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:38:01 -0700 Subject: [PATCH 11/42] [simulator-launcher] Simplify warm Simulator cleanup Stop services and the agent-device daemon once, then shut down each owned device. Remove tool abort listeners when startup settles, and drop the port wait: the old serve-sim's listener is in the group that was stopped. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/simulatorSession.test.ts | 15 ++++---- .../simulator-launcher/src/commands/start.ts | 37 +++++++------------ .../simulator-launcher/src/commands/stop.ts | 9 +++-- .../simulator-launcher/src/utils/serveSim.ts | 29 --------------- 4 files changed, 26 insertions(+), 64 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 8ff8d8b305..bef8b32e8b 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -14,7 +14,7 @@ import { stopProcessAndDescendantsAsync, terminateProcessGroupAsync, } from '../utils/processes'; -import { startServeSim, waitForServeSimPortFreeAsync } from '../utils/serveSim'; +import { startServeSim } from '../utils/serveSim'; jest.mock('../commands/stop'); jest.mock('../utils/agentDevice'); @@ -106,7 +106,7 @@ it('creates an idle owner synchronously and stops services acquired before a too await session.stopAsync(); expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( new Set([serveSim, agent]), - 'PHONE', + ['PHONE'], agent ); expect(startArgentServer).not.toHaveBeenCalled(); @@ -189,7 +189,7 @@ it('serve-sim claim during agent-device prepare stops it and returns without wai await session.stopAsync(); expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( new Set([serveSim]), - 'PHONE', + ['PHONE'], undefined ); } finally { @@ -267,7 +267,6 @@ it('replacement starts new services once the old serve-sim exits, without waitin serveSimStop.resolve(); // The claim returns while the old device is still shutting down. expect(await claiming).toEqual({ device: { udid: 'PRO', name: 'iPhone 17 Pro' } }); - expect(waitForServeSimPortFreeAsync).toHaveBeenCalled(); expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); expect(commands.runAsync).toHaveBeenCalledWith( @@ -290,7 +289,7 @@ it('replacement starts new services once the old serve-sim exits, without waitin expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledTimes(1); expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( expect.any(Set), - 'PRO', + ['PRO'], agent ); } finally { @@ -362,8 +361,8 @@ it('keeps a replaced device owned when its background shutdown fails', async () await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PRO' }); await session.stopAsync(); expect( - jest.mocked(stopServicesAndShutdownSimulatorAsync).mock.calls.map(([, udid]) => udid) - ).toEqual(['PHONE', 'PRO']); + jest.mocked(stopServicesAndShutdownSimulatorAsync).mock.calls.map(([, udids]) => udids) + ).toEqual([['PHONE', 'PRO']]); expect(onBackgroundFailure).not.toHaveBeenCalled(); }); @@ -408,7 +407,7 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain await rejected; expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( new Set([serveSim, argent]), - 'PHONE', + ['PHONE'], undefined ); } finally { diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index bbc3dc6564..c916e9baac 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -21,11 +21,7 @@ import { stopProcessAndDescendantsAsync, terminateProcessGroupAsync, } from '../utils/processes'; -import { - startServeSim, - waitForServeSimPortFreeAsync, - waitForServeSimReadyAsync, -} from '../utils/serveSim'; +import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; import { ClaimInputSchema, type LauncherState, @@ -336,8 +332,9 @@ export function createWarmSimulatorSession({ function startTool(tool: WarmTool, udid: string, servicesAllowed: Promise): void { const controller = new AbortController(); const toolSignal = AbortSignal.any([signal, controller.signal]); + let abort!: () => void; const aborted = new Promise((_resolve, reject) => { - const abort = (): void => reject(toolSignal.reason); + abort = () => reject(toolSignal.reason); toolSignal.addEventListener('abort', abort, { once: true }); if (toolSignal.aborted) { abort(); @@ -355,6 +352,8 @@ export function createWarmSimulatorSession({ argentProcess = startArgentServer(toolSignal); serviceProcesses.add(argentProcess); }); + const removeAbortListener = (): void => toolSignal.removeEventListener('abort', abort); + void ready.then(removeAbortListener, removeAbortListener); void ready.catch(error => { if (!controller.signal.aborted) { onPreparationFailure(error); @@ -475,8 +474,9 @@ export function createWarmSimulatorSession({ ); if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) { // The new device boots while the old services stop. The old serve-sim is unclaimed and - // has nothing to finalize, so its process group stops without a grace wait. The new services start once - // the old ones are gone; the old device shuts down in the background and stays owned. + // has nothing to finalize, so its process group stops without a grace wait. Its port 3200 + // listener is in that group, so the new serve-sim can bind once the group is gone. The old + // device shuts down in the background and stays owned until it does. const previousUdid = currentDevice.udid; const previousServeSim = serveSimProcess; const release = (async () => { @@ -494,7 +494,6 @@ export function createWarmSimulatorSession({ if (failed?.status === 'rejected') { throw failed.reason; } - await waitForServeSimPortFreeAsync(signal); await throwIfStoppingAsync(); })(); previousDeviceShutdown = release.then(async () => { @@ -541,24 +540,14 @@ export function createWarmSimulatorSession({ } await Promise.allSettled([...toolStartups.values()].map(startup => startup.ready)); await previousDeviceShutdown.catch(() => {}); - const cleanupErrors: unknown[] = []; - for (const udid of ownedSimulatorUdids.size ? [...ownedSimulatorUdids] : [undefined]) { - try { - await stopServicesAndShutdownSimulatorAsync( - serviceProcesses, - udid, - agentDevicePrepareProcess - ); - } catch (error) { - cleanupErrors.push(error); - } - } + await stopServicesAndShutdownSimulatorAsync( + serviceProcesses, + [...ownedSimulatorUdids], + agentDevicePrepareProcess + ); const warmupErrors = [...unusedToolsResults, ...expoGoResults].flatMap(result => result.status === 'rejected' ? [result.reason] : [] ); - if (cleanupErrors.length) { - throw cleanupErrors[0]; - } if (warmupErrors.length) { throw new AggregateError( warmupErrors, diff --git a/packages/simulator-launcher/src/commands/stop.ts b/packages/simulator-launcher/src/commands/stop.ts index 2d7d71d161..ecd6a01af2 100644 --- a/packages/simulator-launcher/src/commands/stop.ts +++ b/packages/simulator-launcher/src/commands/stop.ts @@ -42,9 +42,10 @@ export async function stopAsync(bootUuid: string): Promise { return 0; } +/** Stops the services once, then shuts down every owned device. */ export async function stopServicesAndShutdownSimulatorAsync( serviceProcesses: Set, - simulatorUdid?: string, + simulatorUdids: string[], agentDevicePrepareProcess?: ChildProcessHandle ): Promise { const cleanupErrors: unknown[] = []; @@ -56,9 +57,11 @@ export async function stopServicesAndShutdownSimulatorAsync( .then(() => serviceProcesses.delete(childProcessHandle)) .catch(error => cleanupErrors.push(error)); } - if (simulatorUdid) { + if (simulatorUdids.length) { await stopAgentDeviceDaemonAsync().catch(error => cleanupErrors.push(error)); - await shutdownSimulatorAsync(simulatorUdid).catch(error => cleanupErrors.push(error)); + } + for (const udid of simulatorUdids) { + await shutdownSimulatorAsync(udid).catch(error => cleanupErrors.push(error)); } try { commands.throwIfCommandCloseTimedOut(); diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index a4aa2c1f79..8de3d08812 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -1,5 +1,4 @@ import fs from 'node:fs/promises'; -import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleepAsync } from 'node:timers/promises'; @@ -86,31 +85,3 @@ export async function waitForServeSimReadyAsync( await sleepAsync(250, undefined, { signal }); } } - -/** A replacement serve-sim can bind its port once nothing accepts connections on it. */ -export async function waitForServeSimPortFreeAsync( - signal: AbortSignal, - timeoutMs = 10_000 -): Promise { - const deadline = Date.now() + timeoutMs; - for (;;) { - signal.throwIfAborted(); - const listening = await new Promise(resolve => { - const socket = net.connect({ host: '127.0.0.1', port: SERVE_SIM_PORT }); - socket.once('connect', () => { - socket.destroy(); - resolve(true); - }); - socket.once('error', () => resolve(false)); - }); - if (!listening) { - return; - } - if (Date.now() >= deadline) { - throw new Error( - `Port ${SERVE_SIM_PORT} is still in use; the previous serve-sim did not stop.` - ); - } - await sleepAsync(100, undefined, { signal }); - } -} From c9196e1d49d96e8ef7c40f85181ab1bc0a16149a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:38:19 -0700 Subject: [PATCH 12/42] [simulator-launcher] Update the README for warm claim and cleanup Describe per-engine claims, background replacement shutdown, apsd and ffmpeg handling, and the preinstalled Expo Go record. Co-Authored-By: Claude Opus 5.5 --- packages/simulator-launcher/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index 3ddf9a05ce..400146cc22 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -14,7 +14,8 @@ exec bunx @expo/simulator-launcher@latest start - `start`: boot the selected Simulator and launch the tools in the foreground. - `claim ENGINE [DEVICE]`: hand the warm device to one job. Engines: `serve-sim`, `agent-device`, `argent`. Omit DEVICE to reuse it, or pass an exact name or UDID to select another. - Replacement cleans up the old session before starting the requested device. + A claim waits only for its engine and stops the other tools. Replacement boots the requested + device while the old tools and serve-sim stop; the old device shuts down in the background. - `stop`: disable preparation for this VM boot and wait for owned resources to be cleaned up. ```sh @@ -52,8 +53,11 @@ The startup script owns Node/Bun/PATH setup and these optional overrides: the tools and performs serve-sim's private `--adopt` handoff before app actions. - One start attempt and one accepted claim per VM boot. Stop is permanent for that boot; a failed replacement cannot skip cleanup and start another device. -- Warmup also tries to preinstall the latest stable Expo Go and install missing ffmpeg with Homebrew. - Ordinary failures warn. Claim prevents a late Expo Go install, waiting only if native install already - started; the job's requested app remains authoritative. +- A session is ready once its device has booted, apsd has been disabled and serve-sim answers. +- Warmup also tries to preinstall the latest stable Expo Go. It only checks for ffmpeg, which the VM + image provides. Ordinary failures warn. A failed apsd disable leaves push notifications running; + warm jobs do not retry it, and they do not use push notifications. Claim prevents a late Expo Go + install, waiting only if native install already started, and its record names the preinstalled + Expo Go for the same device; the job's requested app remains authoritative. - Stop has a caller timeout, not a global cleanup deadline. A timeout or cleanup error does not prove teardown completed, and EAS must not start a fallback session until cleanup is confirmed. From 2fce2ffcf42e48957de3c0f86bed82600231e378 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 11:48:27 -0700 Subject: [PATCH 13/42] [simulator-launcher] Simplify apsd disabling and the claim result Route the apsd launchctl calls through one helper and build the claim result in one expression. No behavior change. Co-Authored-By: Claude Opus 5.5 --- .../simulator-launcher/src/commands/start.ts | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index c916e9baac..f958b9ef39 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -222,32 +222,22 @@ type WarmTool = 'agent-device' | 'argent'; /** Push notifications are not used in warm sessions, and apsd's work competes with app installs. */ async function disableApsdAsync(udid: string, signal: AbortSignal): Promise { + const launchctl = (...args: string[]): Promise => + commands.runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', ...args], signal); let lastError: unknown; for (const domain of ['user/foreground', 'system']) { const service = `${domain}/com.apple.apsd`; try { - await commands.runAsync( - 'xcrun', - ['simctl', 'spawn', udid, 'launchctl', 'disable', service], - signal - ); + await launchctl('disable', service); // bootout fails when apsd is already gone; the list check below decides. - await commands - .runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'bootout', service], signal) - .catch(error => { - lastError = error; - }); - const loaded = await commands - .runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list', 'com.apple.apsd'], signal) - .then( - () => true, - error => { - if (error.code === 113) { - return false; - } - throw error; - } - ); + await launchctl('bootout', service).catch(error => { + lastError = error; + }); + // launchctl list exits 113 for a service that is not loaded. + const loaded = await launchctl('list', 'com.apple.apsd').then( + () => true, + error => (error.code === 113 ? false : Promise.reject(error)) + ); if (!loaded) { return; } @@ -524,9 +514,8 @@ export function createWarmSimulatorSession({ await toolStartups.get(requestedTool)!.ready; } // A replacement device has no preinstalled Expo Go. - return preinstalledExpoGo?.udid === currentDevice.udid - ? { device: currentDevice, preinstalledExpoGo } - : { device: currentDevice }; + const preinstalledHere = preinstalledExpoGo?.udid === currentDevice.udid; + return { device: currentDevice, ...(preinstalledHere ? { preinstalledExpoGo } : {}) }; }, /** Drain all owned work. Rejection means terminal cleanup cannot be acknowledged. */ From 96e169d968a9943f54213216b820718d0d69b1a4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 12:06:55 -0700 Subject: [PATCH 14/42] [simulator-launcher] Start Simulator tools after serve-sim is ready Argent and agent-device spawn into the Simulator while serve-sim arms its own launchctl settings; in round 2 that overlap coincided with simctl stalls. Start them once serve-sim is ready; readiness does not move. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 ++ .../src/__tests__/simulatorSession.test.ts | 23 ++++++++++++++++++- .../simulator-launcher/src/commands/start.ts | 20 ++++++++-------- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 591b20f1d1..843ff3d54f 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -13,3 +13,5 @@ Disable apsd on each warm device before reporting it ready. Check for ffmpeg without installing it; the VM image provides it. Record the preinstalled Expo Go identity in the claim record, so the job can skip a matching install without asking the Simulator. + +Start Argent and agent-device in the Simulator only after serve-sim is ready. diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index bef8b32e8b..06bdd2d4ca 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -14,7 +14,7 @@ import { stopProcessAndDescendantsAsync, terminateProcessGroupAsync, } from '../utils/processes'; -import { startServeSim } from '../utils/serveSim'; +import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; jest.mock('../commands/stop'); jest.mock('../utils/agentDevice'); @@ -167,6 +167,27 @@ it('keeps the warm session when apsd cannot be disabled', async () => { expect(onPreparationFailure).not.toHaveBeenCalled(); }); +it('starts tools in the Simulator only after serve-sim is ready', async () => { + const serveSimReady = deferred(); + jest.mocked(waitForServeSimReadyAsync).mockReturnValueOnce(serveSimReady.promise); + const session = createSession(); + const preparing = session.prepareAsync(); + try { + await until(() => jest.mocked(startServeSim).mock.calls.length > 0); + await new Promise(resolve => setImmediate(resolve)); + // Argent's configuration does not touch the Simulator, so it may run early. + expect(prepareArgentEnvironmentAsync).toHaveBeenCalled(); + expect(startAgentDevicePreparation).not.toHaveBeenCalled(); + expect(startArgentServer).not.toHaveBeenCalled(); + serveSimReady.resolve(); + await preparing; + await until(() => jest.mocked(startArgentServer).mock.calls.length > 0); + expect(startAgentDevicePreparation).toHaveBeenCalledWith('PHONE', expect.anything()); + } finally { + serveSimReady.resolve(); + } +}); + it('serve-sim claim during agent-device prepare stops it and returns without waiting', async () => { const preparing = deferred(); const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index f958b9ef39..3a1f10dc2e 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -318,8 +318,8 @@ export function createWarmSimulatorSession({ } } - /** Starts a tool after `servicesAllowed`; a failure before claim fails the warm session closed. */ - function startTool(tool: WarmTool, udid: string, servicesAllowed: Promise): void { + /** Starts a tool after `serveSimReady`; a failure before claim fails the warm session closed. */ + function startTool(tool: WarmTool, udid: string, serveSimReady: Promise): void { const controller = new AbortController(); const toolSignal = AbortSignal.any([signal, controller.signal]); let abort!: () => void; @@ -333,12 +333,12 @@ export function createWarmSimulatorSession({ void aborted.catch(() => {}); const ready = tool === 'agent-device' - ? servicesAllowed.then(async () => { + ? serveSimReady.then(async () => { agentDevicePrepareProcess = startAgentDevicePreparation(udid, toolSignal); serviceProcesses.add(agentDevicePrepareProcess); await Promise.race([agentDevicePrepareProcess.resultPromise, aborted]); }) - : Promise.all([servicesAllowed, prepareArgentEnvironmentAsync(toolSignal)]).then(() => { + : Promise.all([serveSimReady, prepareArgentEnvironmentAsync(toolSignal)]).then(() => { argentProcess = startArgentServer(toolSignal); serviceProcesses.add(argentProcess); }); @@ -373,8 +373,8 @@ export function createWarmSimulatorSession({ } /** - * Boots the device, then starts serve-sim and the given tools once `servicesAllowed` settles. - * Resolves when serve-sim is ready; tools keep starting and report through their startups. + * Boots the device and starts serve-sim once `servicesAllowed` settles, then the given tools once + * serve-sim is ready. Resolves when serve-sim is ready; tools report through their startups. */ async function bootSimulatorAndStartServicesAsync( device: SimulatorDevice, @@ -395,15 +395,17 @@ export function createWarmSimulatorSession({ void expoGoPreinstall.resultPromise.catch(onBackgroundFailure); } const servicesReady = Promise.all([simulatorBootPromise, servicesAllowed]); - for (const tool of tools) { - startTool(tool, udid, servicesReady); - } const serveSimPromise = servicesReady.then(async () => { const serveSim = startServeSim(udid, signal); serveSimProcess = serveSim; serviceProcesses.add(serveSim); await waitForServeSimReadyAsync(udid, serveSim, signal); }); + // Tools spawn into the Simulator; starting them while serve-sim arms its own launchctl + // settings coincided with simctl stalls, so they wait for serve-sim to be ready. + for (const tool of tools) { + startTool(tool, udid, serveSimPromise); + } // The job launches apps right after its claim, so apsd is settled before the device is ready. const apsdPromise = simulatorBootPromise.then(() => disableApsdAsync(udid, signal).catch(error => { From 81a3becf8e221e7b91c3a0fb3fc6cca02e39d932 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 16:31:51 -0700 Subject: [PATCH 15/42] [simulator-launcher] Treat exited processes awaiting reap as stopped macOS answers kill with EPERM for a process group whose only members have exited and await reaping. Check for a live member instead of failing, in both the replacement group stop and the terminal tree stop. A live member that cannot be signalled still fails cleanup. Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 + .../src/__tests__/main.test.ts | 8 +-- .../src/__tests__/processes.test.ts | 60 ++++++++++++++++ .../simulator-launcher/src/utils/processes.ts | 69 +++++++++++++++---- 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 843ff3d54f..001d3aea74 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -15,3 +15,5 @@ Check for ffmpeg without installing it; the VM image provides it. Record the preinstalled Expo Go identity in the claim record, so the job can skip a matching install without asking the Simulator. Start Argent and agent-device in the Simulator only after serve-sim is ready. + +Treat processes that have exited but await reaping as stopped, so a replaced serve-sim no longer fails cleanup with kill EPERM. diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index c7566d5746..aafd1e39f1 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -336,10 +336,10 @@ for (const selector of [undefined, 'phone', 'iPhone 17 Pro']) { ).toHaveLength(1); if (replacement) { const calls = jest.mocked(commands.runAsync).mock.calls; - // The new device boots while the old one is released; the old one still shuts down. - expect( - calls.findIndex(([, args]) => args.join(' ') === 'simctl bootstatus PRO -b') - ).toBeLessThan(calls.findIndex(([, args]) => args.join(' ') === 'simctl shutdown PHONE')); + // The new device boots and the old one still shuts down; their order is concurrent. + expect(calls.map(([, args]) => args.join(' '))).toEqual( + expect.arrayContaining(['simctl bootstatus PRO -b', 'simctl shutdown PHONE']) + ); expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); } await expect(claimAsync(boot, 'argent')).rejects.toThrow('available'); diff --git a/packages/simulator-launcher/src/__tests__/processes.test.ts b/packages/simulator-launcher/src/__tests__/processes.test.ts index 553b9b6c44..9330c3db2c 100644 --- a/packages/simulator-launcher/src/__tests__/processes.test.ts +++ b/packages/simulator-launcher/src/__tests__/processes.test.ts @@ -353,6 +353,66 @@ describe('terminateProcessGroupAsync', () => { }); }); +describe('EPERM from exited processes awaiting reap', () => { + const eperm = (): never => { + throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }); + }; + + it('treats a group with only zombie members as stopped', async () => { + jest.useRealTimers(); + let terminated = false; + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => { + expect(pid).toBe(-90001); + if (signal === 'SIGTERM') { + terminated = true; + return true; + } + // After TERM, the only member left is the leader awaiting reap. + return terminated ? eperm() : true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async file => (file === 'ps' ? ' 90001 Z\n 412 Ss\n' : '')); + await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50); + expect( + jest.mocked(process.kill).mock.calls.filter(([, signal]) => signal === 'SIGKILL') + ).toEqual([]); + }); + + it('still fails closed when a live member cannot be signalled', async () => { + jest.useRealTimers(); + jest.spyOn(process, 'kill').mockImplementation(() => eperm()); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async file => (file === 'ps' ? ' 90001 S\n' : '')); + const stopping = terminateProcessGroupAsync( + { child, resultPromise: Promise.resolve('') }, + 50 + ).catch(error => error); + expect((await stopping).message).toContain('process group 90001 is still running'); + }); + + it('lets the tree stop finish a descendant that exited before its signal', async () => { + jest + .spyOn(process, 'kill') + .mockImplementation((pid, signal) => + pid === 90002 && signal === 'SIGTERM' ? eperm() : pid === 90002 ? eperm() : true + ); + jest.mocked(child.kill).mockImplementation(() => { + Object.defineProperty(child, 'exitCode', { value: 0 }); + return true; + }); + jest + .spyOn(commands, 'runAsync') + .mockImplementation(async (file, args) => + file === 'pgrep' ? (args[1] === '90001' ? '90002\n' : '') : file === 'ps' ? 'Z\n' : '' + ); + await expect( + stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }) + ).resolves.toBeUndefined(); + }); +}); + test('serve-sim leads its own process group', () => { const { startServeSim } = require('../utils/serveSim'); startServeSim('PHONE', new AbortController().signal); diff --git a/packages/simulator-launcher/src/utils/processes.ts b/packages/simulator-launcher/src/utils/processes.ts index cc463e0f25..3313e10ec5 100644 --- a/packages/simulator-launcher/src/utils/processes.ts +++ b/packages/simulator-launcher/src/utils/processes.ts @@ -71,18 +71,66 @@ export const commands = { }, }; +/** EPERM means the process exists but cannot be signalled, as for an exited one awaiting reap. */ function isProcessRunning(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { return false; } + if (code === 'EPERM') { + return true; + } throw error; } } +/** Signals a process; false when it is gone, true when it was signalled or cannot be. */ +function signalProcess(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + return false; + } + if (code !== 'EPERM') { + throw error; + } + } + return true; +} + +/** + * macOS reports EPERM for a group whose only members have exited and await reaping, so EPERM + * is resolved by checking for a member that is not a zombie. + */ +async function isProcessGroupRunningAsync(groupId: number): Promise { + try { + process.kill(-groupId, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + return false; + } + if (code !== 'EPERM') { + throw error; + } + } + const members = await commands.runAsync('ps', ['-A', '-o', 'pgid=,stat=']); + return members + .trim() + .split('\n') + .some(line => { + const [pgid, stat = ''] = line.trim().split(/\s+/); + return Number(pgid) === groupId && !stat.startsWith('Z'); + }); +} + async function stopProcessTreeAsync( pid: number, deadlineMs: number, @@ -127,8 +175,8 @@ async function stopProcessTreeAsync( 'A Simulator service launcher exited before it could be stopped safely; cleanup could not be confirmed.' ); } - } else { - process.kill(pid, 'SIGTERM'); + } else if (!signalProcess(pid, 'SIGTERM')) { + return; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { @@ -187,25 +235,16 @@ export async function terminateProcessGroupAsync( if (!groupId) { throw new Error('A Simulator service has no process group; cleanup could not be confirmed.'); } - const isGroupRunning = (): boolean => isProcessRunning(-groupId); for (const signal of ['SIGTERM', 'SIGKILL'] as const) { - if (!isGroupRunning()) { - break; - } - try { - process.kill(-groupId, signal); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error; - } + if (!(await isProcessGroupRunningAsync(groupId)) || !signalProcess(-groupId, signal)) { break; } const deadline = Date.now() + graceMs; - while (isGroupRunning() && Date.now() < deadline) { + while ((await isProcessGroupRunningAsync(groupId)) && Date.now() < deadline) { await sleepAsync(100); } } - if (isGroupRunning()) { + if (await isProcessGroupRunningAsync(groupId)) { throw new Error( `Simulator service process group ${groupId} is still running; cleanup could not be confirmed.` ); From 026dc781e7f0fbeeedf3a8246930594c57f292b1 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 16:38:05 -0700 Subject: [PATCH 16/42] [simulator-launcher] Probe readiness only at the local serve-sim address Reject serve-sim state whose url is not http://127.0.0.1:3200 instead of fetching it, the same rule the job applies to the warm session. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/simulatorSession.test.ts | 26 +++++++++++++++++ .../simulator-launcher/src/utils/serveSim.ts | 28 +++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 06bdd2d4ca..c16300cd80 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -587,3 +587,29 @@ it('reports the preinstalled Expo Go only for the device it was installed on', a expect(claimed.preinstalledExpoGo).toEqual(requestedDevice ? undefined : preinstalledExpoGo); } }); + +it('rejects serve-sim state that points readiness at another address', async () => { + const { waitForServeSimReadyAsync: waitActual } = + jest.requireActual('../utils/serveSim'); + const stateDir = await fs.mkdtemp( + path.join(jest.requireActual('node:os').tmpdir(), 'serve-sim-state-') + ); + process.env.SERVE_SIM_STATE_DIR = stateDir; + const fetchSpy = jest.spyOn(global, 'fetch'); + try { + await fs.writeFile( + path.join(stateDir, 'server-PHONE.json'), + JSON.stringify({ device: 'PHONE', url: 'http://example.test:3200', token: 'secret' }) + ); + const running = { + child: { pid: 10 } as ChildProcess, + resultPromise: new Promise(() => {}), + }; + await expect(waitActual('PHONE', running, controller.signal)).rejects.toThrow( + 'not http://127.0.0.1:3200' + ); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } +}); diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index 8de3d08812..1f91a8c83d 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { type ChildProcessHandle, commands } from './processes'; const SERVE_SIM_PORT = 3200; +const SERVE_SIM_URL = `http://127.0.0.1:${SERVE_SIM_PORT}`; const SERVE_SIM_READY_TIMEOUT_MS = 120_000; // The job needs the token from this file; /readyz itself is not token-gated. const ServeSimStateSchema = z.object({ device: z.string(), url: z.string(), token: z.string() }); @@ -68,16 +69,25 @@ export async function waitForServeSimReadyAsync( if (Date.now() >= deadline) { throw new Error(`Timed out waiting for serve-sim on ${udid} to become ready.`); } + const state = await fs + .readFile(stateFile, 'utf8') + .then(contents => ServeSimStateSchema.parse(JSON.parse(contents))) + .catch(() => undefined); + // Only the local serve-sim this launcher started may be probed; the job checks the same. + if (state && state.url !== SERVE_SIM_URL) { + throw new Error(`serve-sim on ${udid} reports ${state.url}, not ${SERVE_SIM_URL}.`); + } try { - const state = ServeSimStateSchema.parse(JSON.parse(await fs.readFile(stateFile, 'utf8'))); - const response = await fetch(`${state.url}/readyz`, { - signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]), - }); - const ready = response.ok - ? ((await response.json()) as { status?: string; device?: string }) - : {}; - if (ready.status === 'ready' && ready.device?.toUpperCase() === udid.toUpperCase()) { - return; + if (state) { + const response = await fetch(`${SERVE_SIM_URL}/readyz`, { + signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]), + }); + const ready = response.ok + ? ((await response.json()) as { status?: string; device?: string }) + : {}; + if (ready.status === 'ready' && ready.device?.toUpperCase() === udid.toUpperCase()) { + return; + } } } catch { // serve-sim writes its state and answers /readyz only once it is listening. From 28541461041bc84fbf076066ba90cbaebd940dda Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 16:53:58 -0700 Subject: [PATCH 17/42] [simulator-launcher] Split the warm session into simulator and tool modules Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/expoGo.test.ts | 17 +- .../src/__tests__/helpers.ts | 17 + .../src/__tests__/main.test.ts | 27 +- .../src/__tests__/simulatorSession.test.ts | 199 +++++----- .../src/__tests__/state.test.ts | 8 +- .../src/__tests__/tools.test.ts | 125 +++++++ .../simulator-launcher/src/commands/start.ts | 352 ++++++------------ .../simulator-launcher/src/commands/stop.ts | 64 ---- .../src/utils/agentDevice.ts | 89 +++-- .../simulator-launcher/src/utils/argent.ts | 91 +++-- .../simulator-launcher/src/utils/simulator.ts | 93 +++++ .../simulator-launcher/src/utils/state.ts | 1 + .../simulator-launcher/src/utils/tools.ts | 74 ++++ 13 files changed, 655 insertions(+), 502 deletions(-) create mode 100644 packages/simulator-launcher/src/__tests__/helpers.ts create mode 100644 packages/simulator-launcher/src/__tests__/tools.test.ts create mode 100644 packages/simulator-launcher/src/utils/simulator.ts create mode 100644 packages/simulator-launcher/src/utils/tools.ts diff --git a/packages/simulator-launcher/src/__tests__/expoGo.test.ts b/packages/simulator-launcher/src/__tests__/expoGo.test.ts index c282e45be4..a1ed068b10 100644 --- a/packages/simulator-launcher/src/__tests__/expoGo.test.ts +++ b/packages/simulator-launcher/src/__tests__/expoGo.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { startExpoGoPreinstall } from '../utils/expoGo'; import { commands } from '../utils/processes'; +import { deferred, until } from './helpers'; const runCommandAsync = commands.runAsync.bind(commands); function createArchive(entries: { name: string; contents: Buffer }[]): Buffer { @@ -26,22 +27,6 @@ function installCalls() { return jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'xcrun'); } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -async function until(predicate: () => boolean): Promise { - for (let i = 0; i < 400 && !predicate(); i++) { - await new Promise(resolve => setTimeout(resolve, 5)); - } - expect(predicate()).toBe(true); -} - let directory: string; let archive: Buffer; let controller: AbortController; diff --git a/packages/simulator-launcher/src/__tests__/helpers.ts b/packages/simulator-launcher/src/__tests__/helpers.ts new file mode 100644 index 0000000000..4fda7a50e4 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/helpers.ts @@ -0,0 +1,17 @@ +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +/** Polls `predicate` for up to 2 seconds, then asserts it. */ +export async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 400 && !predicate(); i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + expect(predicate()).toBe(true); +} diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index aafd1e39f1..1fb970a97d 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -5,18 +5,20 @@ import path from 'node:path'; import { claimAsync } from '../commands/claim'; import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start'; -import { shutdownSimulatorAsync, stopAsync } from '../commands/stop'; +import { stopAsync } from '../commands/stop'; import { mainAsync } from '../main'; import { startExpoGoPreinstall } from '../utils/expoGo'; import { type ChildProcessHandle, commands } from '../utils/processes'; import * as processes from '../utils/processes'; import * as serveSim from '../utils/serveSim'; +import { shutdownSimulatorAsync } from '../utils/simulator'; import { LAUNCHER_STATE_FILE, readLauncherStateAsync, updateLauncherStateAsync, waitForLauncherStateAsync, } from '../utils/state'; +import { deferred, until } from './helpers'; jest.mock('../utils/expoGo'); const boot = 'boot-one'; @@ -48,21 +50,6 @@ const remap = (file: unknown): unknown => ? path.join(root, 'control', path.basename(file)) : file; -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -async function until(check: () => boolean): Promise { - for (let i = 0; i < 200 && !check(); i++) { - await new Promise(resolve => setTimeout(resolve, 5)); - } - expect(check()).toBe(true); -} async function status(value: string) { return await waitForLauncherStateAsync(boot, state => state?.status === value, 2_000); } @@ -237,8 +224,8 @@ test('startup returns native identity and effective paths in the single ready re for (const slower of ['boot', 'config']) { test(`serve-sim and agent depend only on boot while ${slower} is pending`, async () => { - const booting = deferred(); - const config = deferred(); + const booting = deferred(); + const config = deferred(); const original = jest.mocked(commands.runAsync).getMockImplementation()!; jest .mocked(commands.runAsync) @@ -409,7 +396,7 @@ test('stop during replacement prevents a late claimed result and drains newly ow const [completion] = await beginSession(); await status('ready'); const entered = deferred(); - const release = deferred(); + const release = deferred(); const original = jest.mocked(commands.runAsync).getMockImplementation()!; jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => { if (args.includes('bootstatus') && args.includes('PRO')) { @@ -513,7 +500,7 @@ test.each(['stop', 'claim'])( const directory = path.join(root, '.expo/simulator-tools'); await fs.mkdir(directory, { recursive: true }); await fs.writeFile(path.join(directory, 'run'), 'installed implementation'); - const completion = deferred(); + const completion = deferred(); const child = { kill: jest.fn() } as unknown as ChildProcess; jest.mocked(commands.start).mockReturnValue({ child, resultPromise: completion.promise }); const term = process.listenerCount('SIGTERM'), diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index c16300cd80..dd3f5fba97 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -4,9 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { createWarmSimulatorSession } from '../commands/start'; -import { shutdownSimulatorAsync, stopServicesAndShutdownSimulatorAsync } from '../commands/stop'; -import { startAgentDevicePreparation, stopAgentDeviceDaemonAsync } from '../utils/agentDevice'; -import { prepareArgentEnvironmentAsync, startArgentServer } from '../utils/argent'; +import { createArgentTool } from '../utils/argent'; import { startExpoGoPreinstall } from '../utils/expoGo'; import { type ChildProcessHandle, @@ -15,28 +13,25 @@ import { terminateProcessGroupAsync, } from '../utils/processes'; import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; +import { shutdownSimulatorAsync } from '../utils/simulator'; +import { deferred, until } from './helpers'; -jest.mock('../commands/stop'); -jest.mock('../utils/agentDevice'); -jest.mock('../utils/argent'); jest.mock('../utils/expoGo'); jest.mock('../utils/processes'); jest.mock('../utils/serveSim'); +jest.mock('../utils/simulator', () => ({ + ...jest.requireActual('../utils/simulator'), + shutdownSimulatorAsync: jest.fn(), +})); -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; +/** Calls that started a tool process whose arguments include `word`, e.g. `prepare` or `server`. */ +function started(word: string) { + return jest.mocked(commands.start).mock.calls.filter(([, args]) => args.includes(word)); } -async function until(predicate: () => boolean): Promise { - for (let i = 0; i < 100 && !predicate(); i++) { - await new Promise(resolve => setImmediate(resolve)); - } - expect(predicate()).toBe(true); +function ran(...words: string[]) { + return jest + .mocked(commands.runAsync) + .mock.calls.filter(([, args]) => words.every(word => args.includes(word))); } function handle(pid: number): ChildProcessHandle { return { child: { pid } as ChildProcess, resultPromise: Promise.resolve('') }; @@ -76,8 +71,11 @@ beforeEach(() => { : 'native-start-time'; }); jest.mocked(startServeSim).mockReturnValue(serveSim); - jest.mocked(startAgentDevicePreparation).mockReturnValue(agent); - jest.mocked(startArgentServer).mockReturnValue(argent); + jest + .mocked(commands.start) + .mockImplementation((_file, args) => (args.includes('prepare') ? agent : argent)); + jest.mocked(shutdownSimulatorAsync).mockResolvedValue(); + jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue(); jest.mocked(startExpoGoPreinstall).mockReturnValue({ claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(async () => {}), @@ -90,26 +88,34 @@ afterEach(() => { }); it('creates an idle owner synchronously and stops services acquired before a tool fails', async () => { - const argentPreparation = deferred(); + const argentPreparation = deferred(); const failure = new Error('Argent preparation failed'); - jest.mocked(prepareArgentEnvironmentAsync).mockReturnValueOnce(argentPreparation.promise); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('artifacts-list-endpoint') + ? argentPreparation.promise + : original(file, args, signal) + ); const session = createSession(); expect(commands.runAsync).not.toHaveBeenCalled(); try { // serve-sim readiness makes the session claimable while the tools are still starting. await session.prepareAsync(); - expect(startAgentDevicePreparation).toHaveBeenCalledTimes(1); + expect(started('prepare')).toHaveLength(1); argentPreparation.reject(failure); await until(() => onPreparationFailure.mock.calls.length > 0); expect(onPreparationFailure).toHaveBeenCalledWith(failure); expect(onBackgroundFailure).not.toHaveBeenCalled(); await session.stopAsync(); - expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( - new Set([serveSim, agent]), - ['PHONE'], - agent - ); - expect(startArgentServer).not.toHaveBeenCalled(); + expect(jest.mocked(stopProcessAndDescendantsAsync).mock.calls).toEqual([ + [serveSim], + [agent, true], + ]); + expect(ran('daemon', 'stop')).toHaveLength(1); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); + expect(started('server')).toEqual([]); } finally { argentPreparation.reject(failure); } @@ -176,13 +182,12 @@ it('starts tools in the Simulator only after serve-sim is ready', async () => { await until(() => jest.mocked(startServeSim).mock.calls.length > 0); await new Promise(resolve => setImmediate(resolve)); // Argent's configuration does not touch the Simulator, so it may run early. - expect(prepareArgentEnvironmentAsync).toHaveBeenCalled(); - expect(startAgentDevicePreparation).not.toHaveBeenCalled(); - expect(startArgentServer).not.toHaveBeenCalled(); + expect(commands.runAsync).toHaveBeenCalledWith('ffmpeg', ['-version'], expect.anything()); + expect(commands.start).not.toHaveBeenCalled(); serveSimReady.resolve(); await preparing; - await until(() => jest.mocked(startArgentServer).mock.calls.length > 0); - expect(startAgentDevicePreparation).toHaveBeenCalledWith('PHONE', expect.anything()); + await until(() => started('server').length > 0); + expect(started('prepare')[0][1]).toContain('PHONE'); } finally { serveSimReady.resolve(); } @@ -191,7 +196,9 @@ it('starts tools in the Simulator only after serve-sim is ready', async () => { it('serve-sim claim during agent-device prepare stops it and returns without waiting', async () => { const preparing = deferred(); const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; - jest.mocked(startAgentDevicePreparation).mockReturnValueOnce(prepare); + jest + .mocked(commands.start) + .mockImplementation((_file, args) => (args.includes('prepare') ? prepare : argent)); jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async job => { if (job === prepare) { preparing.reject(new Error('stopped')); @@ -203,16 +210,15 @@ it('serve-sim claim during agent-device prepare stops it and returns without wai expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ device: { udid: 'PHONE', name: 'iPhone 17' }, }); - await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); + await until(() => ran('daemon', 'stop').length > 0); expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true); expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(argent); expect(onPreparationFailure).not.toHaveBeenCalled(); + jest.mocked(stopProcessAndDescendantsAsync).mockClear(); await session.stopAsync(); - expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( - new Set([serveSim]), - ['PHONE'], - undefined - ); + // Only serve-sim is left; the stopped tools are not stopped again. + expect(jest.mocked(stopProcessAndDescendantsAsync).mock.calls).toEqual([[serveSim]]); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); } finally { preparing.reject(new Error('stopped')); } @@ -221,9 +227,16 @@ it('serve-sim claim during agent-device prepare stops it and returns without wai it('claim waits only for the requested tool and stops the other one mid-start', async () => { const preparing = deferred(); const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; - const argentConfig = deferred(); - jest.mocked(startAgentDevicePreparation).mockReturnValueOnce(prepare); - jest.mocked(prepareArgentEnvironmentAsync).mockReturnValueOnce(argentConfig.promise); + const argentConfig = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('artifacts-list-endpoint') ? argentConfig.promise : original(file, args, signal) + ); + jest + .mocked(commands.start) + .mockImplementation((_file, args) => (args.includes('prepare') ? prepare : argent)); jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async job => { if (job === prepare) { preparing.reject(new Error('stopped')); @@ -237,16 +250,16 @@ it('claim waits only for the requested tool and stops the other one mid-start', claimed = true; return device; }); - await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); + await until(() => ran('daemon', 'stop').length > 0); expect(claimed).toBe(false); - expect(startArgentServer).not.toHaveBeenCalled(); - argentConfig.resolve(); + expect(started('server')).toEqual([]); + argentConfig.resolve(''); expect(await claiming).toMatchObject({ device: { udid: 'PHONE' } }); - expect(startArgentServer).toHaveBeenCalledTimes(1); + expect(started('server')).toHaveLength(1); expect(onPreparationFailure).not.toHaveBeenCalled(); } finally { preparing.reject(new Error('stopped')); - argentConfig.resolve(); + argentConfig.resolve(''); } }); @@ -273,8 +286,7 @@ it('replacement starts new services once the old serve-sim exits, without waitin const session = createSession(); try { await session.prepareAsync(); - jest.mocked(startAgentDevicePreparation).mockClear(); - jest.mocked(startArgentServer).mockClear(); + jest.mocked(commands.start).mockClear(); jest.mocked(startServeSim).mockClear(); const claiming = session.claimAsync({ engine: 'agent-device', requestedDevice: 'PRO' }); await until(() => @@ -284,7 +296,7 @@ it('replacement starts new services once the old serve-sim exits, without waitin ); expect(terminateProcessGroupAsync).toHaveBeenCalledWith(serveSim); expect(startServeSim).not.toHaveBeenCalled(); - expect(startAgentDevicePreparation).not.toHaveBeenCalled(); + expect(commands.start).not.toHaveBeenCalled(); serveSimStop.resolve(); // The claim returns while the old device is still shutting down. expect(await claiming).toEqual({ device: { udid: 'PRO', name: 'iPhone 17 Pro' } }); @@ -295,8 +307,9 @@ it('replacement starts new services once the old serve-sim exits, without waitin ['simctl', 'spawn', 'PRO', 'launchctl', 'disable', 'user/foreground/com.apple.apsd'], controller.signal ); - expect(startAgentDevicePreparation).toHaveBeenCalledWith('PRO', expect.anything()); - expect(startArgentServer).not.toHaveBeenCalled(); + expect(started('prepare')).toHaveLength(1); + expect(started('prepare')[0][1]).toContain('PRO'); + expect(started('server')).toEqual([]); // Terminal cleanup waits for the background shutdown, then owns only the new device. let stopped = false; const stopping = session.stopAsync().then(() => { @@ -304,15 +317,11 @@ it('replacement starts new services once the old serve-sim exits, without waitin }); await new Promise(resolve => setImmediate(resolve)); expect(stopped).toBe(false); - expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + expect(jest.mocked(shutdownSimulatorAsync).mock.calls).toEqual([['PHONE']]); oldShutdown.resolve(''); await stopping; - expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledTimes(1); - expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( - expect.any(Set), - ['PRO'], - agent - ); + expect(jest.mocked(shutdownSimulatorAsync).mock.calls).toEqual([['PHONE'], ['PRO']]); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(agent, true); } finally { serveSimStop.resolve(); oldShutdown.resolve(''); @@ -323,8 +332,11 @@ it('fails a replacement only after every old stop settles', async () => { const serveSimStop = deferred(); const daemonFailure = new Error('daemon stop failed'); const original = jest.mocked(commands.runAsync).getMockImplementation()!; - jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => - args.includes('devices') + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => { + if (args.includes('daemon') && ran('daemon', 'stop').length === 1) { + throw daemonFailure; + } + return args.includes('devices') ? JSON.stringify({ devices: { ios: [ @@ -333,10 +345,9 @@ it('fails a replacement only after every old stop settles', async () => { ], }, }) - : original(file, args, signal) - ); + : original(file, args, signal); + }); jest.mocked(terminateProcessGroupAsync).mockReturnValueOnce(serveSimStop.promise); - jest.mocked(stopAgentDeviceDaemonAsync).mockRejectedValueOnce(daemonFailure); const session = createSession(); try { await session.prepareAsync(); @@ -350,11 +361,11 @@ it('fails a replacement only after every old stop settles', async () => { return error; } ); - await until(() => jest.mocked(stopAgentDeviceDaemonAsync).mock.calls.length > 0); + await until(() => ran('daemon', 'stop').length > 0); await new Promise(resolve => setImmediate(resolve)); // The old serve-sim is still stopping, so the replacement has not failed yet. expect(settled).toBe(false); - expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + expect(shutdownSimulatorAsync).not.toHaveBeenCalled(); serveSimStop.resolve(); expect(await claiming).toBe(daemonFailure); } finally { @@ -381,15 +392,15 @@ it('keeps a replaced device owned when its background shutdown fails', async () await session.prepareAsync(); await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PRO' }); await session.stopAsync(); - expect( - jest.mocked(stopServicesAndShutdownSimulatorAsync).mock.calls.map(([, udids]) => udids) - ).toEqual([['PHONE', 'PRO']]); + // The background shutdown of PHONE failed, so terminal cleanup shuts down both devices. + expect(jest.mocked(shutdownSimulatorAsync).mock.calls).toEqual([['PHONE'], ['PHONE'], ['PRO']]); expect(onBackgroundFailure).not.toHaveBeenCalled(); }); it('drains unused tools and the whole Expo Go task before native cleanup, retaining their failure', async () => { - const unusedAgent = deferred(); + const unusedAgent = deferred(); const unusedArgent = deferred(); + let argentStopped = false; const expoCleanup = deferred(); const failure = new Error('Argent stop failed'); const preinstall = { @@ -398,10 +409,21 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain resultPromise: expoCleanup.promise, }; jest.mocked(startExpoGoPreinstall).mockReturnValueOnce(preinstall); + // The claim-time Argent stop fails; the terminal retry of that stop succeeds. + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async job => { + if (job === argent && !argentStopped) { + argentStopped = true; + await unusedArgent.promise; + } + }); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; jest - .mocked(stopProcessAndDescendantsAsync) - .mockImplementation(async job => (job === argent ? await unusedArgent.promise : undefined)); - jest.mocked(stopAgentDeviceDaemonAsync).mockReturnValueOnce(unusedAgent.promise); + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('daemon') && ran('daemon', 'stop').length === 1 + ? unusedAgent.promise + : original(file, args, signal) + ); const session = createSession(); expect(await session.prepareAsync()).toMatchObject({ device: { udid: 'PHONE' }, @@ -420,20 +442,20 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain unusedArgent.reject(failure); await new Promise(resolve => setImmediate(resolve)); expect(onBackgroundFailure).toHaveBeenCalledWith(failure); - expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); - unusedAgent.resolve(); + expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(serveSim); + unusedAgent.resolve(''); await new Promise(resolve => setImmediate(resolve)); - expect(stopServicesAndShutdownSimulatorAsync).not.toHaveBeenCalled(); + expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(serveSim); expoCleanup.resolve(); await rejected; - expect(stopServicesAndShutdownSimulatorAsync).toHaveBeenCalledWith( - new Set([serveSim, argent]), - ['PHONE'], - undefined - ); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(serveSim); + expect( + jest.mocked(stopProcessAndDescendantsAsync).mock.calls.filter(([job]) => job === argent) + ).toHaveLength(2); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); } finally { unusedArgent.reject(failure); - unusedAgent.resolve(); + unusedAgent.resolve(''); expoCleanup.resolve(); await stopping.catch(() => {}); } @@ -528,10 +550,7 @@ describe('waitForServeSimReadyAsync', () => { }); }); -describe('prepareArgentEnvironmentAsync', () => { - const { prepareArgentEnvironmentAsync: prepareActual } = - jest.requireActual('../utils/argent'); - +describe('Argent preparation', () => { it('warns without installing ffmpeg when the image does not provide it', async () => { jest.mocked(commands.runAsync).mockImplementation(async file => { if (file === 'ffmpeg') { @@ -540,7 +559,7 @@ describe('prepareArgentEnvironmentAsync', () => { return ''; }); const stderr = jest.spyOn(process.stderr, 'write').mockReturnValue(true); - await prepareActual(controller.signal); + await createArgentTool().prepareAsync!(controller.signal); expect(stderr).toHaveBeenCalledWith(expect.stringContaining('ffmpeg is unavailable')); expect( jest diff --git a/packages/simulator-launcher/src/__tests__/state.test.ts b/packages/simulator-launcher/src/__tests__/state.test.ts index 318b3b58a9..f6d31488ee 100644 --- a/packages/simulator-launcher/src/__tests__/state.test.ts +++ b/packages/simulator-launcher/src/__tests__/state.test.ts @@ -10,6 +10,7 @@ import { updateLauncherStateAsync, waitForLauncherStateAsync, } from '../utils/state'; +import { deferred } from './helpers'; const boot = 'boot-one'; let root: string; @@ -36,13 +37,6 @@ const remap = (file: unknown): unknown => ? path.join(root, 'control', path.basename(file)) : file; -function deferred() { - let resolve!: () => void; - const promise = new Promise(yes => { - resolve = yes; - }); - return { promise, resolve }; -} beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-state-')); jest.spyOn(os, 'homedir').mockReturnValue(root); diff --git a/packages/simulator-launcher/src/__tests__/tools.test.ts b/packages/simulator-launcher/src/__tests__/tools.test.ts new file mode 100644 index 0000000000..cf43e4c533 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/tools.test.ts @@ -0,0 +1,125 @@ +import { type ChildProcess } from 'node:child_process'; + +import { createAgentDeviceTool } from '../utils/agentDevice'; +import { commands, stopProcessAndDescendantsAsync } from '../utils/processes'; +import { type WarmTool, startTool } from '../utils/tools'; +import { deferred } from './helpers'; + +jest.mock('../utils/processes'); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(commands.runAsync).mockResolvedValue(''); + jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue(); +}); + +it('prepares at once, starts after serve-sim, and reports a failure before cancellation', async () => { + const serveSimReady = deferred(); + const failure = new Error('start failed'); + const tool: WarmTool = { + name: 'argent', + prepareAsync: jest.fn(async () => {}), + startAsync: jest.fn(async () => { + throw failure; + }), + stopAsync: jest.fn(async () => {}), + }; + const onFailure = jest.fn(); + const started = startTool( + tool, + 'PHONE', + serveSimReady.promise, + new AbortController().signal, + onFailure + ); + expect(tool.prepareAsync).toHaveBeenCalledTimes(1); + await new Promise(resolve => setImmediate(resolve)); + expect(tool.startAsync).not.toHaveBeenCalled(); + serveSimReady.resolve(); + await expect(started.ready).rejects.toBe(failure); + expect(tool.startAsync).toHaveBeenCalledWith('PHONE', expect.any(AbortSignal)); + expect(onFailure).toHaveBeenCalledWith(failure); +}); + +it('stops agent-device mid-prepare without reporting it, and stops the daemon', async () => { + const preparing = deferred(); + const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise }; + jest.mocked(commands.start).mockReturnValue(prepare); + const onFailure = jest.fn(); + const started = startTool( + createAgentDeviceTool(), + 'PHONE', + Promise.resolve(), + new AbortController().signal, + onFailure + ); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(commands.start).toHaveBeenCalledTimes(1); + // The prepare process is still running; the stop does not wait for it to exit by itself. + await started.stopAsync(); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true); + expect(commands.runAsync).toHaveBeenCalledWith( + 'bunx', + expect.arrayContaining(['daemon', 'stop']) + ); + expect(onFailure).not.toHaveBeenCalled(); + } finally { + preparing.resolve(''); + } +}); + +it('stops the agent-device daemon even when the prepare process cannot be stopped', async () => { + const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: Promise.resolve('') }; + const failure = new Error('prepare stop failed'); + jest.mocked(commands.start).mockReturnValue(prepare); + jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(failure); + const tool = createAgentDeviceTool(); + await tool.startAsync('PHONE', new AbortController().signal); + await expect(tool.stopAsync()).rejects.toBe(failure); + expect(commands.runAsync).toHaveBeenCalledWith( + 'bunx', + expect.arrayContaining(['daemon', 'stop']) + ); + // The prepare process stays owned, so a later stop tries it again. + await tool.stopAsync(); + expect(jest.mocked(stopProcessAndDescendantsAsync).mock.calls).toEqual([ + [prepare, true], + [prepare, true], + ]); +}); + +it('reports a synchronous prepare throw through ready instead of throwing from startTool', async () => { + const failure = new Error('prepare threw'); + const tool: WarmTool = { + name: 'argent', + prepareAsync: () => { + throw failure; + }, + startAsync: jest.fn(async () => {}), + stopAsync: jest.fn(async () => {}), + }; + const onFailure = jest.fn(); + const started = startTool( + tool, + 'PHONE', + Promise.resolve(), + new AbortController().signal, + onFailure + ); + await expect(started.ready).rejects.toBe(failure); + expect(tool.startAsync).not.toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalledWith(failure); +}); + +it('keeps both errors when the agent-device prepare and daemon stops fail', async () => { + const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: Promise.resolve('') }; + const prepareFailure = new Error('prepare stop failed'); + const daemonFailure = new Error('daemon stop failed'); + jest.mocked(commands.start).mockReturnValue(prepare); + jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(prepareFailure); + jest.mocked(commands.runAsync).mockRejectedValueOnce(daemonFailure); + const tool = createAgentDeviceTool(); + await tool.startAsync('PHONE', new AbortController().signal); + await expect(tool.stopAsync()).rejects.toMatchObject({ errors: [prepareFailure, daemonFailure] }); +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 3a1f10dc2e..e1b85400af 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -1,19 +1,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { setTimeout as sleepAsync } from 'node:timers/promises'; -import { z } from 'zod'; -import { shutdownSimulatorAsync, stopServicesAndShutdownSimulatorAsync } from './stop'; -import { - setAgentDeviceEnvironmentDefaults, - startAgentDevicePreparation, - stopAgentDeviceDaemonAsync, -} from '../utils/agentDevice'; -import { - prepareArgentEnvironmentAsync, - setArgentEnvironmentDefaults, - startArgentServer, -} from '../utils/argent'; +import { createAgentDeviceTool } from '../utils/agentDevice'; +import { createArgentTool } from '../utils/argent'; import { startExpoGoPreinstall } from '../utils/expoGo'; import { type ChildProcessHandle, @@ -23,31 +13,23 @@ import { } from '../utils/processes'; import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; import { + bootSimulatorAsync, + disableApsdAsync, + listAvailableSimulatorsAsync, + selectSimulators, + shutdownSimulatorAsync, +} from '../utils/simulator'; +import { + type ClaimInput, ClaimInputSchema, type LauncherState, - type PreinstalledApp, type SimulatorDevice, - SimulatorDeviceSchema, type WarmSessionInfo, getInstalledLauncherDirectory, readLauncherStateAsync, updateLauncherStateAsync, } from '../utils/state'; - -type WarmSimulatorSession = { - prepareAsync(): Promise; - claimAsync( - request: z.infer - ): Promise<{ device: SimulatorDevice; preinstalledExpoGo?: PreinstalledApp }>; - stopAsync(): Promise; -}; - -const SimulatorListSchema = z.object({ - devices: z.record( - z.string(), - z.array(SimulatorDeviceSchema.extend({ isAvailable: z.boolean().optional() })) - ), -}); +import { type StartedTool, type WarmTool, startTool } from '../utils/tools'; export async function startAsync(bootUuid: string, sourceBundlePath: string): Promise { const initial = await updateLauncherStateAsync(bootUuid, state => { @@ -218,38 +200,6 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise { - const launchctl = (...args: string[]): Promise => - commands.runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', ...args], signal); - let lastError: unknown; - for (const domain of ['user/foreground', 'system']) { - const service = `${domain}/com.apple.apsd`; - try { - await launchctl('disable', service); - // bootout fails when apsd is already gone; the list check below decides. - await launchctl('bootout', service).catch(error => { - lastError = error; - }); - // launchctl list exits 113 for a service that is not loaded. - const loaded = await launchctl('list', 'com.apple.apsd').then( - () => true, - error => (error.code === 113 ? false : Promise.reject(error)) - ); - if (!loaded) { - return; - } - } catch (error) { - signal.throwIfAborted(); - lastError = error; - } - } - throw lastError ?? new Error('apsd is still loaded in the Simulator.'); -} -type WarmToolStartup = { controller: AbortController; ready: Promise }; - /** Own resources before preparation starts so every partial attempt can be stopped. */ export function createWarmSimulatorSession({ signal, @@ -261,50 +211,26 @@ export function createWarmSimulatorSession({ throwIfStoppingAsync(): Promise; onPreparationFailure(error: unknown): void; onBackgroundFailure(error: unknown): void; -}): WarmSimulatorSession { - const serviceProcesses = new Set(); +}) { + const warmTools = [createAgentDeviceTool(), createArgentTool()]; + const serveSims = new Set(); // Track attempted devices for cleanup; publish currentDevice only after startup succeeds. const ownedSimulatorUdids = new Set(); - const toolStartups = new Map(); + const startedTools = new Map(); let currentDevice: SimulatorDevice; let availableDevices: SimulatorDevice[]; - let agentDevicePrepareProcess: ChildProcessHandle | undefined; - let argentProcess: ChildProcessHandle | undefined; + let serveSim: ChildProcessHandle | undefined; let expoGoPreinstall: ReturnType | undefined; let unusedToolsCleanup: Promise[]> = Promise.resolve([]); - let serveSimProcess: ChildProcessHandle | undefined; // A replaced device shuts down in the background; terminal cleanup retries it if needed. let previousDeviceShutdown: Promise = Promise.resolve(); - async function listAvailableSimulatorsAsync(): Promise { - const { devices } = SimulatorListSchema.parse( - JSON.parse( - await commands.runAsync( - 'xcrun', - ['simctl', 'list', 'devices', 'available', '--json'], - signal - ) - ) - ); - return Object.values(devices) - .flat() - .filter(device => device.isAvailable !== false); - } - - async function resolveSimulatorDeviceAsync( + async function resolveDeviceAsync( selector?: string, refreshOnMiss = false ): Promise { for (let attempt = 0; ; attempt++) { - const matches = selector - ? availableDevices.filter( - device => - device.name === selector || device.udid.toLowerCase() === selector.toLowerCase() - ) - : availableDevices - .filter(device => device.name.startsWith('iPhone')) - .sort((a, b) => a.name.length - b.name.length) - .slice(0, 1); + const matches = selectSimulators(availableDevices, selector); if (matches.length === 1) { return matches[0]; } @@ -314,69 +240,15 @@ export function createWarmSimulatorSession({ ); } await throwIfStoppingAsync(); - availableDevices = await listAvailableSimulatorsAsync(); - } - } - - /** Starts a tool after `serveSimReady`; a failure before claim fails the warm session closed. */ - function startTool(tool: WarmTool, udid: string, serveSimReady: Promise): void { - const controller = new AbortController(); - const toolSignal = AbortSignal.any([signal, controller.signal]); - let abort!: () => void; - const aborted = new Promise((_resolve, reject) => { - abort = () => reject(toolSignal.reason); - toolSignal.addEventListener('abort', abort, { once: true }); - if (toolSignal.aborted) { - abort(); - } - }); - void aborted.catch(() => {}); - const ready = - tool === 'agent-device' - ? serveSimReady.then(async () => { - agentDevicePrepareProcess = startAgentDevicePreparation(udid, toolSignal); - serviceProcesses.add(agentDevicePrepareProcess); - await Promise.race([agentDevicePrepareProcess.resultPromise, aborted]); - }) - : Promise.all([serveSimReady, prepareArgentEnvironmentAsync(toolSignal)]).then(() => { - argentProcess = startArgentServer(toolSignal); - serviceProcesses.add(argentProcess); - }); - const removeAbortListener = (): void => toolSignal.removeEventListener('abort', abort); - void ready.then(removeAbortListener, removeAbortListener); - void ready.catch(error => { - if (!controller.signal.aborted) { - onPreparationFailure(error); - } - }); - toolStartups.set(tool, { controller, ready }); - } - - /** Stops a tool even while it is still starting, then releases its native state. */ - async function stopToolAsync(tool: WarmTool): Promise { - const startup = toolStartups.get(tool); - toolStartups.delete(tool); - startup?.controller.abort(); - await startup?.ready.catch(() => {}); - if (tool === 'agent-device') { - if (agentDevicePrepareProcess) { - await stopProcessAndDescendantsAsync(agentDevicePrepareProcess, true); - serviceProcesses.delete(agentDevicePrepareProcess); - agentDevicePrepareProcess = undefined; - } - await stopAgentDeviceDaemonAsync(); - } else if (argentProcess) { - await stopProcessAndDescendantsAsync(argentProcess); - serviceProcesses.delete(argentProcess); - argentProcess = undefined; + availableDevices = await listAvailableSimulatorsAsync(signal); } } /** - * Boots the device and starts serve-sim once `servicesAllowed` settles, then the given tools once - * serve-sim is ready. Resolves when serve-sim is ready; tools report through their startups. + * Boots the device and starts serve-sim once `servicesAllowed` settles, then `tools` once + * serve-sim is ready. Resolves when serve-sim is ready; a tool failure goes to onPreparationFailure. */ - async function bootSimulatorAndStartServicesAsync( + async function startDeviceAsync( device: SimulatorDevice, tools: WarmTool[], servicesAllowed: Promise = Promise.resolve() @@ -384,55 +256,115 @@ export function createWarmSimulatorSession({ await throwIfStoppingAsync(); const udid = device.udid; ownedSimulatorUdids.add(udid); - const simulatorBootPromise = commands - .runAsync('xcrun', ['simctl', 'bootstatus', udid, '-b'], signal) - .catch(async () => { - signal.throwIfAborted(); - await commands.runAsync('xcrun', ['simctl', 'bootstatus', udid], signal); - }); + const boot = bootSimulatorAsync(udid, signal); if (!expoGoPreinstall) { - expoGoPreinstall = startExpoGoPreinstall(udid, simulatorBootPromise, signal); + expoGoPreinstall = startExpoGoPreinstall(udid, boot, signal); void expoGoPreinstall.resultPromise.catch(onBackgroundFailure); } - const servicesReady = Promise.all([simulatorBootPromise, servicesAllowed]); - const serveSimPromise = servicesReady.then(async () => { - const serveSim = startServeSim(udid, signal); - serveSimProcess = serveSim; - serviceProcesses.add(serveSim); - await waitForServeSimReadyAsync(udid, serveSim, signal); + const serveSimReady = Promise.all([boot, servicesAllowed]).then(async () => { + const handle = startServeSim(udid, signal); + serveSim = handle; + serveSims.add(handle); + await waitForServeSimReadyAsync(udid, handle, signal); }); // Tools spawn into the Simulator; starting them while serve-sim arms its own launchctl // settings coincided with simctl stalls, so they wait for serve-sim to be ready. for (const tool of tools) { - startTool(tool, udid, serveSimPromise); + startedTools.set(tool, startTool(tool, udid, serveSimReady, signal, onPreparationFailure)); } // The job launches apps right after its claim, so apsd is settled before the device is ready. - const apsdPromise = simulatorBootPromise.then(() => + const apsd = boot.then(() => disableApsdAsync(udid, signal).catch(error => { signal.throwIfAborted(); process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`); }) ); try { - await Promise.all([serveSimPromise, apsdPromise]); + await Promise.all([serveSimReady, apsd]); } catch (error) { onPreparationFailure(error); await Promise.allSettled([ - simulatorBootPromise, - apsdPromise, + boot, + apsd, servicesAllowed, - ...[...toolStartups.values()].map(startup => startup.ready), + ...[...startedTools.values()].map(tool => tool.ready), ]); throw error; } } + /** + * The new device boots while the old services stop. The old serve-sim is unclaimed and has + * nothing to finalize, so its process group stops without a grace wait. Its port 3200 listener + * is in that group, so the new serve-sim can bind once the group is gone. The old device shuts + * down in the background and stays owned until it does. + */ + async function replaceDeviceAsync( + device: SimulatorDevice, + requestedTool?: WarmTool + ): Promise { + const previousUdid = currentDevice.udid; + const previousServeSim = serveSim; + const previousTools = [...startedTools.values()]; + startedTools.clear(); + const release = (async () => { + // Settle every stop before failing, so cleanup never races a stop still in flight. + const stops = await Promise.allSettled([ + ...previousTools.map(tool => tool.stopAsync()), + (async () => { + if (previousServeSim) { + await terminateProcessGroupAsync(previousServeSim); + serveSims.delete(previousServeSim); + } + })(), + ]); + const failed = stops.find(stop => stop.status === 'rejected'); + if (failed?.status === 'rejected') { + throw failed.reason; + } + await throwIfStoppingAsync(); + })(); + previousDeviceShutdown = release.then(async () => { + await shutdownSimulatorAsync(previousUdid); + ownedSimulatorUdids.delete(previousUdid); + }); + void previousDeviceShutdown.catch(() => {}); + await startDeviceAsync(device, requestedTool ? [requestedTool] : [], release); + currentDevice = device; + } + + /** Stops serve-sim and the tools, then shuts down every owned device. */ + async function stopServicesAndDevicesAsync(): Promise { + const cleanupErrors: unknown[] = []; + const attempt = (step: Promise) => step.catch(error => cleanupErrors.push(error)); + for (const handle of serveSims) { + await attempt(stopProcessAndDescendantsAsync(handle)); + } + if (ownedSimulatorUdids.size) { + for (const tool of warmTools) { + await attempt(tool.stopAsync()); + } + } + for (const udid of ownedSimulatorUdids) { + await attempt(shutdownSimulatorAsync(udid)); + } + try { + commands.throwIfCommandCloseTimedOut(); + } catch (error) { + cleanupErrors.push(error); + } + if (cleanupErrors.length) { + throw new AggregateError( + cleanupErrors, + 'Warm Simulator cleanup could not be confirmed; services or the device may still be running.' + ); + } + } + return { async prepareAsync(): Promise { - availableDevices = await listAvailableSimulatorsAsync(); - const selectedDevice = await resolveSimulatorDeviceAsync(process.env.EXPO_SIMULATOR_DEVICE); - setArgentEnvironmentDefaults(); - setAgentDeviceEnvironmentDefaults(); + availableDevices = await listAvailableSimulatorsAsync(signal); + const selectedDevice = await resolveDeviceAsync(process.env.EXPO_SIMULATOR_DEVICE); const controllerStartTime = ( await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) ).trim(); @@ -442,7 +374,7 @@ export function createWarmSimulatorSession({ ); } // Claimable once serve-sim is ready; the tools keep warming until a claim selects one. - await bootSimulatorAndStartServicesAsync(selectedDevice, ['agent-device', 'argent']); + await startDeviceAsync(selectedDevice, warmTools); currentDevice = selectedDevice; return { device: currentDevice, @@ -453,67 +385,32 @@ export function createWarmSimulatorSession({ }; }, - async claimAsync(request: z.infer) { + async claimAsync(request: ClaimInput) { const preinstalledExpoGo = await expoGoPreinstall?.claimAsync(); await throwIfStoppingAsync(); const device = !request.requestedDevice || request.requestedDevice.toLowerCase() === currentDevice.udid.toLowerCase() ? currentDevice - : await resolveSimulatorDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); - const requestedTool = (['agent-device', 'argent'] as const).find( - tool => tool === request.engine - ); + : await resolveDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); + const requestedTool = warmTools.find(tool => tool.name === request.engine); if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) { - // The new device boots while the old services stop. The old serve-sim is unclaimed and - // has nothing to finalize, so its process group stops without a grace wait. Its port 3200 - // listener is in that group, so the new serve-sim can bind once the group is gone. The old - // device shuts down in the background and stays owned until it does. - const previousUdid = currentDevice.udid; - const previousServeSim = serveSimProcess; - const release = (async () => { - // Settle every stop before failing, so cleanup never races a stop still in flight. - const stops = await Promise.allSettled([ - ...[...toolStartups.keys()].map(tool => stopToolAsync(tool)), - (async () => { - if (previousServeSim) { - await terminateProcessGroupAsync(previousServeSim); - serviceProcesses.delete(previousServeSim); - } - })(), - ]); - const failed = stops.find(stop => stop.status === 'rejected'); - if (failed?.status === 'rejected') { - throw failed.reason; - } - await throwIfStoppingAsync(); - })(); - previousDeviceShutdown = release.then(async () => { - await shutdownSimulatorAsync(previousUdid); - ownedSimulatorUdids.delete(previousUdid); - }); - void previousDeviceShutdown.catch(() => {}); - await bootSimulatorAndStartServicesAsync( - device, - requestedTool ? [requestedTool] : [], - release - ); - currentDevice = device; + await replaceDeviceAsync(device, requestedTool); } // Unselected tools stop now, even mid-startup, so they do not compete with the job. - const unusedTools = [...toolStartups.keys()] - .filter(tool => tool !== requestedTool) - .map(tool => stopToolAsync(tool)); unusedToolsCleanup = Promise.allSettled( - unusedTools.map(cleanup => - cleanup.catch(error => { - onBackgroundFailure(error); - throw error; + [...startedTools] + .filter(([tool]) => tool !== requestedTool) + .map(([tool, started]) => { + startedTools.delete(tool); + return started.stopAsync().catch(error => { + onBackgroundFailure(error); + throw error; + }); }) - ) ); if (requestedTool) { - await toolStartups.get(requestedTool)!.ready; + await startedTools.get(requestedTool)!.ready; } // A replacement device has no preinstalled Expo Go. const preinstalledHere = preinstalledExpoGo?.udid === currentDevice.udid; @@ -526,16 +423,9 @@ export function createWarmSimulatorSession({ unusedToolsCleanup, Promise.allSettled([expoGoPreinstall?.stopAsync()]), ]); - for (const startup of toolStartups.values()) { - startup.controller.abort(); - } - await Promise.allSettled([...toolStartups.values()].map(startup => startup.ready)); + await Promise.all([...startedTools.values()].map(tool => tool.cancelAsync())); await previousDeviceShutdown.catch(() => {}); - await stopServicesAndShutdownSimulatorAsync( - serviceProcesses, - [...ownedSimulatorUdids], - agentDevicePrepareProcess - ); + await stopServicesAndDevicesAsync(); const warmupErrors = [...unusedToolsResults, ...expoGoResults].flatMap(result => result.status === 'rejected' ? [result.reason] : [] ); diff --git a/packages/simulator-launcher/src/commands/stop.ts b/packages/simulator-launcher/src/commands/stop.ts index ecd6a01af2..24f250adbb 100644 --- a/packages/simulator-launcher/src/commands/stop.ts +++ b/packages/simulator-launcher/src/commands/stop.ts @@ -1,35 +1,5 @@ -import { z } from 'zod'; - -import { stopAgentDeviceDaemonAsync } from '../utils/agentDevice'; -import { - type ChildProcessHandle, - commands, - stopProcessAndDescendantsAsync, -} from '../utils/processes'; import { updateLauncherStateAsync, waitForLauncherStateAsync } from '../utils/state'; -const SimulatorStatesSchema = z.object({ - devices: z.record(z.string(), z.array(z.object({ udid: z.string(), state: z.string() }))), -}); - -/** `simctl shutdown` fails for a device that is already shut down; that device is done. */ -export async function shutdownSimulatorAsync(udid: string): Promise { - try { - await commands.runAsync('xcrun', ['simctl', 'shutdown', udid]); - } catch (error) { - const { devices } = SimulatorStatesSchema.parse( - JSON.parse(await commands.runAsync('xcrun', ['simctl', 'list', 'devices', '--json'])) - ); - if ( - Object.values(devices) - .flat() - .find(device => device.udid === udid)?.state !== 'Shutdown' - ) { - throw error; - } - } -} - export async function stopAsync(bootUuid: string): Promise { const state = await updateLauncherStateAsync(bootUuid, current => !current || current.status === 'disabled' @@ -41,37 +11,3 @@ export async function stopAsync(bootUuid: string): Promise { } return 0; } - -/** Stops the services once, then shuts down every owned device. */ -export async function stopServicesAndShutdownSimulatorAsync( - serviceProcesses: Set, - simulatorUdids: string[], - agentDevicePrepareProcess?: ChildProcessHandle -): Promise { - const cleanupErrors: unknown[] = []; - for (const childProcessHandle of serviceProcesses) { - await stopProcessAndDescendantsAsync( - childProcessHandle, - /* allowCompletedProcess */ childProcessHandle === agentDevicePrepareProcess - ) - .then(() => serviceProcesses.delete(childProcessHandle)) - .catch(error => cleanupErrors.push(error)); - } - if (simulatorUdids.length) { - await stopAgentDeviceDaemonAsync().catch(error => cleanupErrors.push(error)); - } - for (const udid of simulatorUdids) { - await shutdownSimulatorAsync(udid).catch(error => cleanupErrors.push(error)); - } - try { - commands.throwIfCommandCloseTimedOut(); - } catch (error) { - cleanupErrors.push(error); - } - if (cleanupErrors.length) { - throw new AggregateError( - cleanupErrors, - 'Warm Simulator cleanup could not be confirmed; services or the device may still be running.' - ); - } -} diff --git a/packages/simulator-launcher/src/utils/agentDevice.ts b/packages/simulator-launcher/src/utils/agentDevice.ts index 67e5d0b1cf..4ea7b156a8 100644 --- a/packages/simulator-launcher/src/utils/agentDevice.ts +++ b/packages/simulator-launcher/src/utils/agentDevice.ts @@ -1,9 +1,13 @@ import os from 'node:os'; import path from 'node:path'; -import { type ChildProcessHandle, commands } from './processes'; +import { type ChildProcessHandle, commands, stopProcessAndDescendantsAsync } from './processes'; +import { type WarmTool, untilAbortedAsync } from './tools'; -export function setAgentDeviceEnvironmentDefaults(): void { +const packageSpec = (): string => `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`; + +/** `prepare ios-runner` builds and starts the runner and its daemon; it exits once both are up. */ +export function createAgentDeviceTool(): WarmTool { process.env.AGENT_DEVICE_STATE_DIR ||= path.join( os.homedir(), 'Library/Caches/expo-simulator-startup/agent-device' @@ -12,35 +16,54 @@ export function setAgentDeviceEnvironmentDefaults(): void { process.env.AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS ||= '0'; process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS ||= '0'; process.env.AGENT_DEVICE_IOS_RUNNER_DETACH ||= '0'; -} - -export function startAgentDevicePreparation(udid: string, signal: AbortSignal): ChildProcessHandle { - signal.throwIfAborted(); - return commands.start('bunx', [ - `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`, - 'prepare', - 'ios-runner', - '--platform', - 'ios', - '--udid', - udid, - '--daemon-server-mode', - 'http', - '--daemon-transport', - 'http', - '--timeout', - '240000', - '--json', - ]); -} - -export async function stopAgentDeviceDaemonAsync(): Promise { - await commands.runAsync('bunx', [ - `agent-device@${process.env.AGENT_DEVICE_VERSION || 'latest'}`, - 'daemon', - 'stop', - '--clean', - '--state-dir', - process.env.AGENT_DEVICE_STATE_DIR!, - ]); + let prepareProcess: ChildProcessHandle | undefined; + return { + name: 'agent-device', + async startAsync(udid, signal) { + signal.throwIfAborted(); + prepareProcess = commands.start('bunx', [ + packageSpec(), + 'prepare', + 'ios-runner', + '--platform', + 'ios', + '--udid', + udid, + '--daemon-server-mode', + 'http', + '--daemon-transport', + 'http', + '--timeout', + '240000', + '--json', + ]); + await untilAbortedAsync(prepareProcess.resultPromise, signal); + }, + async stopAsync() { + const errors: unknown[] = []; + if (prepareProcess) { + await stopProcessAndDescendantsAsync(prepareProcess, /* allowCompletedProcess */ true).then( + () => (prepareProcess = undefined), + error => errors.push(error) + ); + } + // The daemon outlives the prepare process, so it stops even if the prepare stop failed. + await commands + .runAsync('bunx', [ + packageSpec(), + 'daemon', + 'stop', + '--clean', + '--state-dir', + process.env.AGENT_DEVICE_STATE_DIR!, + ]) + .catch(error => errors.push(error)); + if (errors.length > 1) { + throw new AggregateError(errors, 'agent-device could not be stopped.'); + } + if (errors.length) { + throw errors[0]; + } + }, + }; } diff --git a/packages/simulator-launcher/src/utils/argent.ts b/packages/simulator-launcher/src/utils/argent.ts index 94bdf31086..645e48459f 100644 --- a/packages/simulator-launcher/src/utils/argent.ts +++ b/packages/simulator-launcher/src/utils/argent.ts @@ -1,50 +1,59 @@ import os from 'node:os'; import path from 'node:path'; -import { type ChildProcessHandle, commands } from './processes'; +import { type ChildProcessHandle, commands, stopProcessAndDescendantsAsync } from './processes'; +import { type WarmTool } from './tools'; -export function setArgentEnvironmentDefaults(): void { +const packageSpec = (): string => `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`; + +export function createArgentTool(): WarmTool { process.env.ARGENT_EVENT_LOG ||= path.join(os.homedir(), '.argent/tool-server-events.jsonl'); process.env.ARGENT_EMULATOR_NO_WINDOW ||= '1'; -} - -export async function prepareArgentEnvironmentAsync(signal: AbortSignal): Promise { - const argentPackage = `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`; - - signal.throwIfAborted(); - // The VM image provides ffmpeg; installing it here would delay every warm session. - await commands.runAsync('ffmpeg', ['-version'], signal).catch(error => { - signal.throwIfAborted(); - process.stderr.write(`ffmpeg is unavailable; Argent video tools may not work: ${error}\n`); - }); - commands.throwIfCommandCloseTimedOut(); + let serverProcess: ChildProcessHandle | undefined; + return { + name: 'argent', + async prepareAsync(signal) { + signal.throwIfAborted(); + // The VM image provides ffmpeg; installing it here would delay every warm session. + await commands.runAsync('ffmpeg', ['-version'], signal).catch(error => { + signal.throwIfAborted(); + process.stderr.write(`ffmpeg is unavailable; Argent video tools may not work: ${error}\n`); + }); + commands.throwIfCommandCloseTimedOut(); - for (const [verb, flag] of [ - ['enable', 'artifacts-list-endpoint'], - ['enable', 'tool-server-event-log'], - ['disable', 'video-watermark'], - ]) { - signal.throwIfAborted(); - await commands.runAsync('bunx', [argentPackage, verb, flag], signal).catch(error => { - if (verb !== 'disable') { - throw error; + for (const [verb, flag] of [ + ['enable', 'artifacts-list-endpoint'], + ['enable', 'tool-server-event-log'], + ['disable', 'video-watermark'], + ]) { + signal.throwIfAborted(); + await commands.runAsync('bunx', [packageSpec(), verb, flag], signal).catch(error => { + if (verb !== 'disable') { + throw error; + } + }); } - }); - } - signal.throwIfAborted(); -} - -export function startArgentServer(signal: AbortSignal): ChildProcessHandle { - signal.throwIfAborted(); - return commands.start('bunx', [ - `@swmansion/argent@${process.env.ARGENT_VERSION || 'latest'}`, - 'server', - 'start', - '--host', - '127.0.0.1', - '--port', - '3201', - '--idle-timeout', - '0', - ]); + signal.throwIfAborted(); + }, + async startAsync(_udid, signal) { + signal.throwIfAborted(); + serverProcess = commands.start('bunx', [ + packageSpec(), + 'server', + 'start', + '--host', + '127.0.0.1', + '--port', + '3201', + '--idle-timeout', + '0', + ]); + }, + async stopAsync() { + if (serverProcess) { + await stopProcessAndDescendantsAsync(serverProcess); + serverProcess = undefined; + } + }, + }; } diff --git a/packages/simulator-launcher/src/utils/simulator.ts b/packages/simulator-launcher/src/utils/simulator.ts new file mode 100644 index 0000000000..60fdf27c81 --- /dev/null +++ b/packages/simulator-launcher/src/utils/simulator.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { commands } from './processes'; +import { type SimulatorDevice, SimulatorDeviceSchema } from './state'; + +const AvailableSimulatorsSchema = z.object({ + devices: z.record( + z.string(), + z.array(SimulatorDeviceSchema.extend({ isAvailable: z.boolean().optional() })) + ), +}); +const SimulatorStatesSchema = z.object({ + devices: z.record(z.string(), z.array(z.object({ udid: z.string(), state: z.string() }))), +}); + +export async function listAvailableSimulatorsAsync( + signal: AbortSignal +): Promise { + const { devices } = AvailableSimulatorsSchema.parse( + JSON.parse( + await commands.runAsync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], signal) + ) + ); + return Object.values(devices) + .flat() + .filter(device => device.isAvailable !== false); +} + +/** Devices matching an exact name or UDID, or else the available iPhone with the shortest name. */ +export function selectSimulators(devices: SimulatorDevice[], selector?: string): SimulatorDevice[] { + return selector + ? devices.filter( + device => device.name === selector || device.udid.toLowerCase() === selector.toLowerCase() + ) + : devices + .filter(device => device.name.startsWith('iPhone')) + .sort((a, b) => a.name.length - b.name.length) + .slice(0, 1); +} + +export async function bootSimulatorAsync(udid: string, signal: AbortSignal): Promise { + await commands.runAsync('xcrun', ['simctl', 'bootstatus', udid, '-b'], signal).catch(async () => { + signal.throwIfAborted(); + await commands.runAsync('xcrun', ['simctl', 'bootstatus', udid], signal); + }); +} + +/** `simctl shutdown` fails for a device that is already shut down; that device is done. */ +export async function shutdownSimulatorAsync(udid: string): Promise { + try { + await commands.runAsync('xcrun', ['simctl', 'shutdown', udid]); + } catch (error) { + const { devices } = SimulatorStatesSchema.parse( + JSON.parse(await commands.runAsync('xcrun', ['simctl', 'list', 'devices', '--json'])) + ); + if ( + Object.values(devices) + .flat() + .find(device => device.udid === udid)?.state !== 'Shutdown' + ) { + throw error; + } + } +} + +/** Push notifications are not used in warm sessions, and apsd's work competes with app installs. */ +export async function disableApsdAsync(udid: string, signal: AbortSignal): Promise { + const launchctl = (...args: string[]): Promise => + commands.runAsync('xcrun', ['simctl', 'spawn', udid, 'launchctl', ...args], signal); + let lastError: unknown; + for (const domain of ['user/foreground', 'system']) { + const service = `${domain}/com.apple.apsd`; + try { + await launchctl('disable', service); + // bootout fails when apsd is already gone; the list check below decides. + await launchctl('bootout', service).catch(error => { + lastError = error; + }); + // launchctl list exits 113 for a service that is not loaded. + const loaded = await launchctl('list', 'com.apple.apsd').then( + () => true, + error => (error.code === 113 ? false : Promise.reject(error)) + ); + if (!loaded) { + return; + } + } catch (error) { + signal.throwIfAborted(); + lastError = error; + } + } + throw lastError ?? new Error('apsd is still loaded in the Simulator.'); +} diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index 4d98abf1fe..2d33e8aec6 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -37,6 +37,7 @@ export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({ preinstalledExpoGo: PreinstalledAppSchema.optional(), error: z.string().optional(), }); +export type ClaimInput = z.infer; export type LauncherState = z.infer; export type WarmSessionInfo = z.infer; export type SimulatorDevice = z.infer; diff --git a/packages/simulator-launcher/src/utils/tools.ts b/packages/simulator-launcher/src/utils/tools.ts new file mode 100644 index 0000000000..76b9822920 --- /dev/null +++ b/packages/simulator-launcher/src/utils/tools.ts @@ -0,0 +1,74 @@ +import { type ClaimInput } from './state'; + +/** A tool the warm session can hand to one job. */ +export type WarmTool = { + name: Exclude; + /** Work without Simulator access, such as configuration; runs as soon as the tool starts. */ + prepareAsync?(signal: AbortSignal): Promise; + /** + * Runs once serve-sim is ready, since it spawns into the Simulator; resolves when ready. It must + * settle promptly once `signal` aborts: claim-time stops wait for it. + */ + startAsync(udid: string, signal: AbortSignal): Promise; + /** + * Stops the tool and its native state after its startup settled. A handle whose stop failed stays + * owned and is never restarted, so a later stop retries it. + */ + stopAsync(): Promise; +}; + +export type StartedTool = { + ready: Promise; + /** Cancels a startup that is still running, without stopping the tool. */ + cancelAsync(): Promise; + stopAsync(): Promise; +}; + +/** Starts `tool` once serve-sim is ready. A failure before cancellation goes to `onFailure`. */ +export function startTool( + tool: WarmTool, + udid: string, + serveSimReady: Promise, + signal: AbortSignal, + onFailure: (error: unknown) => void +): StartedTool { + const controller = new AbortController(); + const toolSignal = AbortSignal.any([signal, controller.signal]); + // The executor catches a synchronous throw, so the caller's cleanup still runs. + const prepared = new Promise(resolve => resolve(tool.prepareAsync?.(toolSignal))); + const ready = Promise.all([serveSimReady, prepared]).then(() => + tool.startAsync(udid, toolSignal) + ); + void ready.catch(error => { + if (!controller.signal.aborted) { + onFailure(error); + } + }); + const cancelAsync = async (): Promise => { + controller.abort(); + await ready.catch(() => {}); + }; + return { + ready, + cancelAsync, + async stopAsync() { + await cancelAsync(); + await tool.stopAsync(); + }, + }; +} + +/** Rejects once `signal` aborts, so a stop never waits for `promise` to settle on its own. */ +export async function untilAbortedAsync(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let abort!: () => void; + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + signal.removeEventListener('abort', abort); + } +} From 3b0db0c9ac703641f8a58789afc131caa792d5d7 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 18:56:32 -0700 Subject: [PATCH 18/42] [simulator-launcher] Use the shared warm Simulator terms in the changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 001d3aea74..d2f9a69d81 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -2,18 +2,12 @@ '@expo/simulator-launcher': minor --- -Add start, stop and claim commands for warm Simulator sessions. A session is ready once its device boots and serve-sim answers. A claim waits only for its requested tool and stops the others, even mid-startup. Replacing the device boots the requested one while the old serve-sim and tools stop; new services start once they are gone, and the old device shuts down in the background. +Add `@expo/simulator-launcher` for warm Simulators: `start` runs warmup at VM boot, `claim` hands the warm Simulator to a job, and `stop` ends warmup. -Start unused-tool shutdown during claim without delaying handoff. Drain it during terminal cleanup; a shutdown failure prevents a stopped acknowledgement. +Warmup boots the device, disables apsd, preinstalls the latest stable Expo Go, starts serve-sim, and starts Argent and agent-device after serve-sim is ready. The warm Simulator is ready when the device has booted, apsd is disabled and serve-sim answers. -Preinstall the latest stable Expo Go during initial warmup without delaying the candidate. Claim prevents late warmup installs and waits for one already in progress before handing the device to the job. +A claim waits only for the requested engine and stops the other tools, also while they are still starting. The claim record names the preinstalled Expo Go, so the job can skip a matching install. A claim waits for an Expo Go install that already started and prevents a later one. -Disable apsd on each warm device before reporting it ready. +Replacement boots the requested device while the old tools and serve-sim stop. The old device shuts down in the background. -Check for ffmpeg without installing it; the VM image provides it. - -Record the preinstalled Expo Go identity in the claim record, so the job can skip a matching install without asking the Simulator. - -Start Argent and agent-device in the Simulator only after serve-sim is ready. - -Treat processes that have exited but await reaping as stopped, so a replaced serve-sim no longer fails cleanup with kill EPERM. +`stop` waits for every tool to stop and reports a cleanup failure instead of a confirmed stop. The launcher only checks for ffmpeg, which the VM image provides. Processes that exited but are not yet reaped count as stopped. From 6b6ff97eb2ad0613cdd5a36c7203c6a44677acca Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 19:00:38 -0700 Subject: [PATCH 19/42] [simulator-launcher] Use the shared warm Simulator terms in the README Co-Authored-By: Claude Opus 5.5 --- packages/simulator-launcher/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index 400146cc22..bd144c6e1d 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -1,6 +1,6 @@ # @expo/simulator-launcher -Prepare an iOS Simulator with serve-sim, agent-device and Argent before an EAS job arrives. +Run warmup for an iOS Simulator before an EAS job arrives: boot it and start serve-sim, agent-device and Argent. Requires macOS. Only vm-tools is image-installed; its startup script runs the launcher: ```sh @@ -12,11 +12,11 @@ exec bunx @expo/simulator-launcher@latest start ## Commands - `start`: boot the selected Simulator and launch the tools in the foreground. -- `claim ENGINE [DEVICE]`: hand the warm device to one job. Engines: `serve-sim`, `agent-device`, +- `claim ENGINE [DEVICE]`: hand the warm Simulator to one job. Engines: `serve-sim`, `agent-device`, `argent`. Omit DEVICE to reuse it, or pass an exact name or UDID to select another. A claim waits only for its engine and stops the other tools. Replacement boots the requested device while the old tools and serve-sim stop; the old device shuts down in the background. -- `stop`: disable preparation for this VM boot and wait for owned resources to be cleaned up. +- `stop`: end warmup for this VM boot and wait for owned resources to be cleaned up. ```sh node "$HOME/.expo/simulator-tools/run" claim serve-sim @@ -28,7 +28,7 @@ bunx @expo/simulator-launcher@latest stop Later package `claim`/`stop` commands use that installed bundle, so the same implementation controls this boot even if `latest` changes; the direct Node command works without package resolution. -Before any launcher has downloaded, the package `stop` command still disables later preparation. +Before any launcher has downloaded, the package `stop` command still disables later warmup. Once startup has claimed this boot, stop requires its cleanup confirmation, even if the copy is missing. The launcher has one durable state file: `/tmp/expo-simulator-startup.json`, shared by all commands @@ -40,7 +40,7 @@ This private format requires a fresh VM; there is no live migration. The startup script owns Node/Bun/PATH setup and these optional overrides: -- `EXPO_SIMULATOR_STARTUP=1` enables preparation; `EXPO_SIMULATOR_DEVICE` selects an exact name or UDID. +- `EXPO_SIMULATOR_STARTUP=1` enables warmup; `EXPO_SIMULATOR_DEVICE` selects an exact name or UDID. Without a selector, start uses the shortest available iPhone name. - `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: tool versions, each defaulting to `latest`. - `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations. @@ -49,11 +49,11 @@ The startup script owns Node/Bun/PATH setup and these optional overrides: ## Boundaries -- Preparation is a warmup opportunity, not proof that every service is ready. Build-tools checks - the tools and performs serve-sim's private `--adopt` handoff before app actions. +- Warmup is a chance to save time, not proof that every service is ready. Build-tools checks + the tools and runs serve-sim adoption (`--adopt`) before app actions. - One start attempt and one accepted claim per VM boot. Stop is permanent for that boot; a failed replacement cannot skip cleanup and start another device. -- A session is ready once its device has booted, apsd has been disabled and serve-sim answers. +- A session is ready once its device has booted, the apsd disable attempt has finished and serve-sim answers. - Warmup also tries to preinstall the latest stable Expo Go. It only checks for ffmpeg, which the VM image provides. Ordinary failures warn. A failed apsd disable leaves push notifications running; warm jobs do not retry it, and they do not use push notifications. Claim prevents a late Expo Go From 2d01d9b937c943a6703b03b3b6febee3bbef99fb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 00:18:35 -0700 Subject: [PATCH 20/42] [simulator-launcher] Name the warm session handle type --- packages/simulator-launcher/src/commands/start.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index e1b85400af..b7d5add5df 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -23,6 +23,7 @@ import { type ClaimInput, ClaimInputSchema, type LauncherState, + type PreinstalledApp, type SimulatorDevice, type WarmSessionInfo, getInstalledLauncherDirectory, @@ -31,6 +32,15 @@ import { } from '../utils/state'; import { type StartedTool, type WarmTool, startTool } from '../utils/tools'; +type WarmSimulatorSession = { + prepareAsync(): Promise; + claimAsync(request: ClaimInput): Promise<{ + device: SimulatorDevice; + preinstalledExpoGo?: PreinstalledApp; + }>; + stopAsync(): Promise; +}; + export async function startAsync(bootUuid: string, sourceBundlePath: string): Promise { const initial = await updateLauncherStateAsync(bootUuid, state => { if (state?.status === 'disabled') { @@ -211,7 +221,7 @@ export function createWarmSimulatorSession({ throwIfStoppingAsync(): Promise; onPreparationFailure(error: unknown): void; onBackgroundFailure(error: unknown): void; -}) { +}): WarmSimulatorSession { const warmTools = [createAgentDeviceTool(), createArgentTool()]; const serveSims = new Set(); // Track attempted devices for cleanup; publish currentDevice only after startup succeeds. From 5455ea0e0dffb2a6b43a3b4717797bcd8cf3869e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 14:47:35 -0700 Subject: [PATCH 21/42] [simulator-launcher] Use the package version instead of a copied bundle --- .changeset/simulator-launcher.md | 4 +- packages/simulator-launcher/README.md | 11 +-- .../src/__tests__/main.test.ts | 78 ++----------------- .../simulator-launcher/src/commands/start.ts | 23 +++--- packages/simulator-launcher/src/main.ts | 21 +---- .../src/utils/installedLauncher.ts | 24 ------ .../simulator-launcher/src/utils/state.ts | 7 +- 7 files changed, 23 insertions(+), 145 deletions(-) delete mode 100644 packages/simulator-launcher/src/utils/installedLauncher.ts diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index d2f9a69d81..f893295636 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -4,10 +4,12 @@ Add `@expo/simulator-launcher` for warm Simulators: `start` runs warmup at VM boot, `claim` hands the warm Simulator to a job, and `stop` ends warmup. -Warmup boots the device, disables apsd, preinstalls the latest stable Expo Go, starts serve-sim, and starts Argent and agent-device after serve-sim is ready. The warm Simulator is ready when the device has booted, apsd is disabled and serve-sim answers. +Warmup boots the device, disables apsd, preinstalls the latest stable Expo Go, starts serve-sim, and starts Argent and agent-device after serve-sim is ready. The warm Simulator is ready when the device has booted, the apsd disable attempt has finished and serve-sim answers. A claim waits only for the requested engine and stops the other tools, also while they are still starting. The claim record names the preinstalled Expo Go, so the job can skip a matching install. A claim waits for an Expo Go install that already started and prevents a later one. Replacement boots the requested device while the old tools and serve-sim stop. The old device shuts down in the background. `stop` waits for every tool to stop and reports a cleanup failure instead of a confirmed stop. The launcher only checks for ffmpeg, which the VM image provides. Processes that exited but are not yet reaped count as stopped. + +The launcher records its package version instead of copying its bundle. Build-tools uses that exact version for claim and stop. diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index bd144c6e1d..24d27d82cf 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -19,17 +19,14 @@ exec bunx @expo/simulator-launcher@latest start - `stop`: end warmup for this VM boot and wait for owned resources to be cleaned up. ```sh -node "$HOME/.expo/simulator-tools/run" claim serve-sim +bunx @expo/simulator-launcher@latest claim serve-sim # Returns one JSON record with the device, boot, engine, controller identity and tool paths. bunx @expo/simulator-launcher@latest stop ``` -`start` copies its self-contained JavaScript bundle to `$HOME/.expo/simulator-tools/run`. -Later package `claim`/`stop` commands use that installed bundle, so the same implementation controls -this boot even if `latest` changes; the direct Node command works without package resolution. - -Before any launcher has downloaded, the package `stop` command still disables later warmup. -Once startup has claimed this boot, stop requires its cleanup confirmation, even if the copy is missing. +`start` records its package version in the state file. Build-tools uses that exact version from +Bun's cache for later claim and stop commands. No launcher bundle is copied into the image or home. +Before startup has run, the package `stop` command still disables later warmup for this boot. The launcher has one durable state file: `/tmp/expo-simulator-startup.json`, shared by all commands regardless of HOME/TMPDIR. It contains the lifecycle, claim and native session metadata. A short diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 1fb970a97d..7d8935cbf9 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -134,11 +134,10 @@ afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); }); -test('concurrent starts acquire one session and pin only the winning bundle', async () => { - const copy = jest.spyOn(fs, 'copyFile'); +test('concurrent starts acquire one session and record the package version', async () => { const results: unknown[] = []; const starts = [0, 1].map(() => - startAsync(boot, require.resolve('../main')).then( + startAsync(boot).then( value => results.push(value), error => results.push(error) ) @@ -147,9 +146,8 @@ test('concurrent starts acquire one session and pin only the winning bundle', as await status('ready'); await until(() => results.length === 1); expect(results[0]).toBeInstanceOf(Error); - expect(copy).toHaveBeenCalledTimes(1); - expect(await fs.readFile(path.join(root, '.expo/simulator-tools/run'), 'utf8')).toContain( - 'runCliAsync' + expect((await readLauncherStateAsync(boot))?.launcherVersion).toBe( + require('../../package.json').version ); expect(jobs).toHaveLength(3); await stopAsync(boot); @@ -159,9 +157,7 @@ test('concurrent starts acquire one session and pin only the winning bundle', as test('stop before download makes every later start a resource-free disabled no-op', async () => { expect(await stopAsync(boot)).toBe(0); - const copy = jest.spyOn(fs, 'copyFile'); - expect(await startAsync(boot, 'not-downloaded')).toBe(0); - expect(copy).not.toHaveBeenCalled(); + expect(await startAsync(boot)).toBe(0); expect(commands.runAsync).not.toHaveBeenCalled(); expect(commands.start).not.toHaveBeenCalled(); expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' }); @@ -197,14 +193,6 @@ test('package stop under a different HOME and TMPDIR still waits for the active } }); -test('failed bundle pin stays fail-closed instead of fabricating cleanup', async () => { - jest.spyOn(fs, 'copyFile').mockRejectedValueOnce(new Error('copy failed')); - await expect(startAsync(boot, 'source')).rejects.toThrow('copy failed'); - expect((await readLauncherStateAsync(boot))?.status).toBe('starting'); - await expect(startAsync(boot, 'source')).rejects.toThrow('already'); - expect(commands.start).not.toHaveBeenCalled(); -}); - test('startup returns native identity and effective paths in the single ready record', async () => { const [completion] = await beginSession(); const ready = await status('ready'); @@ -475,62 +463,6 @@ for (const target of ['linux', 'win32']) { }); } -test('package stop and claim use the installed bundle through Node', async () => { - const directory = path.join(root, '.expo/simulator-tools'); - await fs.mkdir(directory, { recursive: true }); - await fs.writeFile(path.join(directory, 'run'), 'installed implementation'); - await mainAsync(['stop']); - await mainAsync(['claim', 'argent', 'iPhone 17 Pro']); - expect(commands.start).toHaveBeenNthCalledWith(1, process.execPath, [ - path.join(directory, 'run'), - 'stop', - ]); - expect(commands.start).toHaveBeenNthCalledWith(2, process.execPath, [ - path.join(directory, 'run'), - 'claim', - 'argent', - 'iPhone 17 Pro', - ]); - expect(commands.runAsync).not.toHaveBeenCalled(); -}); - -test.each(['stop', 'claim'])( - 'installed %s forwards signals, waits for completion and propagates delegated ENOENT', - async command => { - const directory = path.join(root, '.expo/simulator-tools'); - await fs.mkdir(directory, { recursive: true }); - await fs.writeFile(path.join(directory, 'run'), 'installed implementation'); - const completion = deferred(); - const child = { kill: jest.fn() } as unknown as ChildProcess; - jest.mocked(commands.start).mockReturnValue({ child, resultPromise: completion.promise }); - const term = process.listenerCount('SIGTERM'), - int = process.listenerCount('SIGINT'); - let finished = false; - const result = mainAsync([command, 'serve-sim']).catch(error => error); - void result.then(() => { - finished = true; - }); - try { - await until(() => jest.mocked(commands.start).mock.calls.length === 1); - process.emit('SIGTERM', 'SIGTERM'); - process.emit('SIGINT', 'SIGINT'); - expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM'); - expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGINT'); - expect(finished).toBe(false); - const error = Object.assign(new Error('installed command failed'), { code: 'ENOENT' }); - completion.reject(error); - expect(await result).toBe(error); - expect(commands.runAsync).not.toHaveBeenCalled(); - expect(await fs.readdir(directory)).toEqual(['run']); - expect(process.listenerCount('SIGTERM')).toBe(term); - expect(process.listenerCount('SIGINT')).toBe(int); - } finally { - completion.resolve(''); - await result; - } - } -); - test.each([ ['Shutdown', true], ['Booted', false], diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index b7d5add5df..51cdc3895c 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; import { setTimeout as sleepAsync } from 'node:timers/promises'; import { createAgentDeviceTool } from '../utils/agentDevice'; @@ -26,12 +24,13 @@ import { type PreinstalledApp, type SimulatorDevice, type WarmSessionInfo, - getInstalledLauncherDirectory, readLauncherStateAsync, updateLauncherStateAsync, } from '../utils/state'; import { type StartedTool, type WarmTool, startTool } from '../utils/tools'; +const LAUNCHER_VERSION: string = require('../../package.json').version; + type WarmSimulatorSession = { prepareAsync(): Promise; claimAsync(request: ClaimInput): Promise<{ @@ -41,7 +40,7 @@ type WarmSimulatorSession = { stopAsync(): Promise; }; -export async function startAsync(bootUuid: string, sourceBundlePath: string): Promise { +export async function startAsync(bootUuid: string): Promise { const initial = await updateLauncherStateAsync(bootUuid, state => { if (state?.status === 'disabled') { return state; @@ -51,20 +50,16 @@ export async function startAsync(bootUuid: string, sourceBundlePath: string): Pr 'Simulator preparation has already been started or disabled for this VM boot.' ); } - return { bootUuid, status: 'starting', controllerPid: process.pid }; + return { + bootUuid, + status: 'starting', + controllerPid: process.pid, + launcherVersion: LAUNCHER_VERSION, + }; }); if (initial.status === 'disabled') { return 0; } - await fs.mkdir(getInstalledLauncherDirectory(), { recursive: true }); - const installedLauncherPath = path.join(getInstalledLauncherDirectory(), 'run'); - const temporaryPath = `${installedLauncherPath}.${process.pid}`; - try { - await fs.copyFile(sourceBundlePath, temporaryPath); - await fs.rename(temporaryPath, installedLauncherPath); - } finally { - await fs.rm(temporaryPath, { force: true }); - } await runWarmSimulatorSessionAsync(bootUuid); return 0; } diff --git a/packages/simulator-launcher/src/main.ts b/packages/simulator-launcher/src/main.ts index d1efcab61f..89dce6448d 100644 --- a/packages/simulator-launcher/src/main.ts +++ b/packages/simulator-launcher/src/main.ts @@ -1,12 +1,9 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import { claimAsync } from './commands/claim'; import { startAsync } from './commands/start'; import { stopAsync } from './commands/stop'; -import { runInstalledLauncherCommandAsync } from './utils/installedLauncher'; import { commands } from './utils/processes'; -import { getInstalledLauncherDirectory } from './utils/state'; export async function mainAsync(args = process.argv.slice(2)): Promise { if (process.platform !== 'darwin') { @@ -16,22 +13,6 @@ export async function mainAsync(args = process.argv.slice(2)): Promise { if (command !== 'start' && command !== 'stop' && command !== 'claim') { throw new Error('Usage: simulator-launcher '); } - const installedLauncherPath = path.join(getInstalledLauncherDirectory(), 'run'); - const isInstalledLauncher = path.resolve(__filename) === installedLauncherPath; - if ((command === 'stop' || command === 'claim') && !isInstalledLauncher) { - const installedLauncherExists = await fs - .stat(installedLauncherPath) - .then(() => true) - .catch(error => { - if (command !== 'stop' || error.code !== 'ENOENT') { - throw error; - } - return false; - }); - if (installedLauncherExists) { - return await runInstalledLauncherCommandAsync([command, ...args.slice(1)]); - } - } const bootUuid = ( await commands.runAsync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']) ).trim(); @@ -42,7 +23,7 @@ export async function mainAsync(args = process.argv.slice(2)): Promise { } switch (command) { case 'start': - return await startAsync(bootUuid, __filename); + return await startAsync(bootUuid); case 'stop': return await stopAsync(bootUuid); case 'claim': diff --git a/packages/simulator-launcher/src/utils/installedLauncher.ts b/packages/simulator-launcher/src/utils/installedLauncher.ts deleted file mode 100644 index eca44e262f..0000000000 --- a/packages/simulator-launcher/src/utils/installedLauncher.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'node:path'; - -import { commands } from './processes'; -import { getInstalledLauncherDirectory } from './state'; - -// Run the bundle copied by start, forwarding signals and waiting for the command to finish. -export async function runInstalledLauncherCommandAsync(args: string[]): Promise { - const childProcessHandle = commands.start(process.execPath, [ - path.join(getInstalledLauncherDirectory(), 'run'), - ...args, - ]); - const forwardSignal = (signal: NodeJS.Signals): void => { - childProcessHandle.child.kill(signal); - }; - process.on('SIGTERM', forwardSignal); - process.on('SIGINT', forwardSignal); - try { - await childProcessHandle.resultPromise; - return 0; - } finally { - process.off('SIGTERM', forwardSignal); - process.off('SIGINT', forwardSignal); - } -} diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index 2d33e8aec6..531731f2f2 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -1,7 +1,5 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; import { setTimeout as sleepAsync } from 'node:timers/promises'; import { z } from 'zod'; @@ -32,6 +30,7 @@ export const WarmSessionInfoSchema = z.object({ }); export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({ bootUuid: NonBlankStringSchema, + launcherVersion: NonBlankStringSchema.optional(), status: z.enum(['starting', 'ready', 'claiming', 'claimed', 'stopping', 'disabled']), ...ClaimInputSchema.partial().shape, preinstalledExpoGo: PreinstalledAppSchema.optional(), @@ -43,10 +42,6 @@ export type WarmSessionInfo = z.infer; export type SimulatorDevice = z.infer; export type PreinstalledApp = z.infer; -export function getInstalledLauncherDirectory(): string { - return path.join(os.homedir(), '.expo/simulator-tools'); -} - export async function readLauncherStateAsync(bootUuid: string): Promise { let contents: string; try { From e4ae4c4d3751f318467cd241b7cc38ef6aa5a777 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 14:47:36 -0700 Subject: [PATCH 22/42] [simulator-launcher] Accept a list of warm Simulator devices --- .changeset/simulator-launcher.md | 2 ++ .../src/__tests__/simulator.test.ts | 29 ++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 6 +++- .../simulator-launcher/src/utils/simulator.ts | 30 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/simulator-launcher/src/__tests__/simulator.test.ts diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index f893295636..4d71226806 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -13,3 +13,5 @@ Replacement boots the requested device while the old tools and serve-sim stop. T `stop` waits for every tool to stop and reports a cleanup failure instead of a confirmed stop. The launcher only checks for ffmpeg, which the VM image provides. Processes that exited but are not yet reaped count as stopped. The launcher records its package version instead of copying its bundle. Build-tools uses that exact version for claim and stop. + +Accept a comma-separated list of exact Simulator names or UDIDs for warmup. diff --git a/packages/simulator-launcher/src/__tests__/simulator.test.ts b/packages/simulator-launcher/src/__tests__/simulator.test.ts new file mode 100644 index 0000000000..634300f239 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/simulator.test.ts @@ -0,0 +1,29 @@ +import { selectWarmupSimulators } from '../utils/simulator'; + +const devices = [ + { udid: 'DUO', name: 'iPhone Duo' }, + { udid: 'PHONE18', name: 'iPhone 18' }, + { udid: 'PRO18', name: 'iPhone 18 Pro' }, +]; + +test('keeps the shortest available iPhone as the default when unset', () => { + expect(selectWarmupSimulators(devices)).toEqual([devices[1]]); +}); +test('splits and trims a list, preserving its default and accepting case-insensitive UDIDs', () => { + expect(selectWarmupSimulators(devices, ' iPhone 18 , duo ')).toEqual([devices[1], devices[0]]); +}); +test.each(['', 'iPhone Duo,', ',iPhone 18', 'iPhone Duo, ,iPhone 18'])( + 'rejects an empty entry in %j', + selectors => { + expect(() => selectWarmupSimulators(devices, selectors)).toThrow('nonempty'); + } +); +test('rejects a missing or ambiguous device instead of choosing arbitrarily', () => { + expect(() => selectWarmupSimulators(devices, 'missing')).toThrow('found 0'); + expect(() => + selectWarmupSimulators([...devices, { udid: 'OTHER', name: 'iPhone Duo' }], 'iPhone Duo') + ).toThrow('found 2'); +}); +test('rejects two selectors for the same device', () => { + expect(() => selectWarmupSimulators(devices, 'iPhone Duo,duo')).toThrow('more than once'); +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 51cdc3895c..f2a39fa0ed 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -15,6 +15,7 @@ import { disableApsdAsync, listAvailableSimulatorsAsync, selectSimulators, + selectWarmupSimulators, shutdownSimulatorAsync, } from '../utils/simulator'; import { @@ -369,7 +370,10 @@ export function createWarmSimulatorSession({ return { async prepareAsync(): Promise { availableDevices = await listAvailableSimulatorsAsync(signal); - const selectedDevice = await resolveDeviceAsync(process.env.EXPO_SIMULATOR_DEVICE); + const [selectedDevice] = selectWarmupSimulators( + availableDevices, + process.env.EXPO_SIMULATOR_DEVICE + ); const controllerStartTime = ( await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) ).trim(); diff --git a/packages/simulator-launcher/src/utils/simulator.ts b/packages/simulator-launcher/src/utils/simulator.ts index 60fdf27c81..9e2252ac08 100644 --- a/packages/simulator-launcher/src/utils/simulator.ts +++ b/packages/simulator-launcher/src/utils/simulator.ts @@ -38,6 +38,36 @@ export function selectSimulators(devices: SimulatorDevice[], selector?: string): .slice(0, 1); } +/** Resolve every configured warm device before native work; the first is the claim default. */ +export function selectWarmupSimulators( + devices: SimulatorDevice[], + selectors?: string +): SimulatorDevice[] { + const entries = + selectors === undefined ? [undefined] : selectors.split(',').map(value => value.trim()); + const selected = new Set(); + return entries.map(selector => { + if (selector === '') { + throw new Error('EXPO_SIMULATOR_DEVICE must contain nonempty device names or UDIDs.'); + } + const matches = selectSimulators(devices, selector); + if (matches.length !== 1) { + throw new Error( + `Expected one available Simulator for ${selector ?? 'the default iPhone'}, found ${matches.length}. Use an exact device name or UDID.` + ); + } + const device = matches[0]; + const udid = device.udid.toLowerCase(); + if (selected.has(udid)) { + throw new Error( + `Simulator ${device.name} (${device.udid}) appears more than once in EXPO_SIMULATOR_DEVICE.` + ); + } + selected.add(udid); + return device; + }); +} + export async function bootSimulatorAsync(udid: string, signal: AbortSignal): Promise { await commands.runAsync('xcrun', ['simctl', 'bootstatus', udid, '-b'], signal).catch(async () => { signal.throwIfAborted(); From b0e40d91f7d78bef82343b64c7bf2da4d19427f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 14:47:37 -0700 Subject: [PATCH 23/42] [simulator-launcher] Warm more devices after the default is ready --- .changeset/simulator-launcher.md | 2 + .../src/__tests__/main.test.ts | 20 ++ .../src/__tests__/simulatorSession.test.ts | 63 ++++- .../src/__tests__/state.test.ts | 3 +- .../simulator-launcher/src/commands/start.ts | 257 ++++++++++-------- .../simulator-launcher/src/utils/serveSim.ts | 16 +- .../simulator-launcher/src/utils/state.ts | 7 +- 7 files changed, 236 insertions(+), 132 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 4d71226806..f4efc8b766 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -15,3 +15,5 @@ Replacement boots the requested device while the old tools and serve-sim stop. T The launcher records its package version instead of copying its bundle. Build-tools uses that exact version for claim and stop. Accept a comma-separated list of exact Simulator names or UDIDs for warmup. + +Warm additional devices after the default is ready, each with its own serve-sim port. diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 7d8935cbf9..53529310df 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -481,3 +481,23 @@ test.each([ await expect(shutdown).rejects.toBe(failure); } }); + +test('publishes default readiness before starting a second device', async () => { + process.env.EXPO_SIMULATOR_DEVICE = 'iPhone 17,iPhone 17 Pro'; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + let stateAtSecondBoot: string | undefined; + jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => { + if (args.includes('bootstatus') && args.includes('PRO')) + stateAtSecondBoot = (await readLauncherStateAsync(boot))?.status; + return original(file, args, signal); + }); + const [completion] = await beginSession(); + await status('ready'); + await until(() => stateAtSecondBoot !== undefined); + expect(stateAtSecondBoot).toBe('ready'); + expect((await readLauncherStateAsync(boot))?.devices?.map(device => device.port)).toEqual([ + 3200, 3202, + ]); + await stopAsync(boot); + expect(await completion).toBeUndefined(); +}); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index dd3f5fba97..ef8ab2934a 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -208,7 +208,7 @@ it('serve-sim claim during agent-device prepare stops it and returns without wai try { await session.prepareAsync(); expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ - device: { udid: 'PHONE', name: 'iPhone 17' }, + device: { udid: 'PHONE', name: 'iPhone 17', port: 3200 }, }); await until(() => ran('daemon', 'stop').length > 0); expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true); @@ -299,13 +299,13 @@ it('replacement starts new services once the old serve-sim exits, without waitin expect(commands.start).not.toHaveBeenCalled(); serveSimStop.resolve(); // The claim returns while the old device is still shutting down. - expect(await claiming).toEqual({ device: { udid: 'PRO', name: 'iPhone 17 Pro' } }); + expect(await claiming).toEqual({ device: { udid: 'PRO', name: 'iPhone 17 Pro', port: 3200 } }); expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); - expect(startServeSim).toHaveBeenCalledWith('PRO', controller.signal); + expect(startServeSim).toHaveBeenCalledWith('PRO', expect.any(AbortSignal), 3200); expect(commands.runAsync).toHaveBeenCalledWith( 'xcrun', ['simctl', 'spawn', 'PRO', 'launchctl', 'disable', 'user/foreground/com.apple.apsd'], - controller.signal + expect.any(AbortSignal) ); expect(started('prepare')).toHaveLength(1); expect(started('prepare')[0][1]).toContain('PRO'); @@ -433,7 +433,7 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain argentEventLogPath: '/isolated-session-fixture/events', }); expect(await session.claimAsync({ engine: 'serve-sim' })).toEqual({ - device: { udid: 'PHONE', name: 'iPhone 17' }, + device: { udid: 'PHONE', name: 'iPhone 17', port: 3200 }, }); expect(preinstall.claimAsync).toHaveBeenCalledTimes(1); const stopping = session.stopAsync(); @@ -569,7 +569,7 @@ describe('Argent preparation', () => { expect(commands.runAsync).toHaveBeenCalledWith( 'bunx', [expect.stringMatching(/^@swmansion\/argent@/), 'enable', 'artifacts-list-endpoint'], - controller.signal + expect.any(AbortSignal) ); }); }); @@ -632,3 +632,54 @@ it('rejects serve-sim state that points readiness at another address', async () await fs.rm(stateDir, { recursive: true, force: true }); } }); + +it('warms additional devices only after the default is ready, with separate ports and preinstalls', async () => { + process.env.EXPO_SIMULATOR_DEVICE = 'iPhone Duo,iPhone 18'; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => + args.includes('devices') + ? Promise.resolve( + JSON.stringify({ + devices: { + ios: [ + { udid: 'DUO', name: 'iPhone Duo' }, + { udid: 'PHONE18', name: 'iPhone 18' }, + ], + }, + }) + ) + : original(file, args, signal) + ); + const first = handle(201), + second = handle(202); + jest.mocked(startServeSim).mockImplementation(udid => (udid === 'DUO' ? first : second)); + const secondReady = deferred(); + jest + .mocked(waitForServeSimReadyAsync) + .mockImplementation(udid => (udid === 'PHONE18' ? secondReady.promise : Promise.resolve())); + const session = createSession(); + const info = await session.prepareAsync(); + expect(info.device).toEqual({ udid: 'DUO', name: 'iPhone Duo', port: 3200 }); + expect(info.devices).toEqual([ + { udid: 'DUO', name: 'iPhone Duo', port: 3200 }, + { udid: 'PHONE18', name: 'iPhone 18', port: 3202 }, + ]); + expect(startServeSim).toHaveBeenCalledTimes(1); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); + session.startRemainingDevices(); + try { + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(2); + expect(startServeSim).toHaveBeenCalledWith('PHONE18', expect.any(AbortSignal), 3202); + expect(started('prepare')).toHaveLength(1); + expect(started('prepare')[0][1]).toContain('DUO'); + secondReady.resolve(); + await session.stopAsync(); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(first); + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(second); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE18'); + } finally { + secondReady.resolve(); + } +}); diff --git a/packages/simulator-launcher/src/__tests__/state.test.ts b/packages/simulator-launcher/src/__tests__/state.test.ts index f6d31488ee..17379d1af8 100644 --- a/packages/simulator-launcher/src/__tests__/state.test.ts +++ b/packages/simulator-launcher/src/__tests__/state.test.ts @@ -15,7 +15,8 @@ import { deferred } from './helpers'; const boot = 'boot-one'; let root: string; const info = { - device: { udid: 'PHONE', name: 'iPhone 17' }, + device: { udid: 'PHONE', name: 'iPhone 17', port: 3200 }, + devices: [{ udid: 'PHONE', name: 'iPhone 17', port: 3200 }], controllerPid: 9000, controllerStartTime: 'Thu Oct 22 10:00:00 2026', agentDeviceStateDir: '/agent', diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index f2a39fa0ed..72c1df504b 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -25,6 +25,7 @@ import { type PreinstalledApp, type SimulatorDevice, type WarmSessionInfo, + type WarmSimulatorDevice, readLauncherStateAsync, updateLauncherStateAsync, } from '../utils/state'; @@ -34,8 +35,9 @@ const LAUNCHER_VERSION: string = require('../../package.json').version; type WarmSimulatorSession = { prepareAsync(): Promise; + startRemainingDevices(): void; claimAsync(request: ClaimInput): Promise<{ - device: SimulatorDevice; + device: WarmSimulatorDevice; preinstalledExpoGo?: PreinstalledApp; }>; stopAsync(): Promise; @@ -140,6 +142,7 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise; + serveSim?: ChildProcessHandle; + expoGo?: ReturnType; +}; + /** Own resources before preparation starts so every partial attempt can be stopped. */ export function createWarmSimulatorSession({ signal, @@ -220,15 +232,16 @@ export function createWarmSimulatorSession({ }): WarmSimulatorSession { const warmTools = [createAgentDeviceTool(), createArgentTool()]; const serveSims = new Set(); - // Track attempted devices for cleanup; publish currentDevice only after startup succeeds. const ownedSimulatorUdids = new Set(); + const devices = new Map(); const startedTools = new Map(); - let currentDevice: SimulatorDevice; + let warmDevices: WarmSimulatorDevice[]; + let currentDevice: WarmSimulatorDevice; let availableDevices: SimulatorDevice[]; - let serveSim: ChildProcessHandle | undefined; - let expoGoPreinstall: ReturnType | undefined; + let claimed = false; + let stopping = false; let unusedToolsCleanup: Promise[]> = Promise.resolve([]); - // A replaced device shuts down in the background; terminal cleanup retries it if needed. + let unusedDevicesCleanup: Promise[]> = Promise.resolve([]); let previousDeviceShutdown: Promise = Promise.resolve(); async function resolveDeviceAsync( @@ -237,9 +250,7 @@ export function createWarmSimulatorSession({ ): Promise { for (let attempt = 0; ; attempt++) { const matches = selectSimulators(availableDevices, selector); - if (matches.length === 1) { - return matches[0]; - } + if (matches.length === 1) return matches[0]; if (matches.length > 1 || !refreshOnMiss || attempt === 1) { throw new Error( `Could not select one available Simulator for ${selector ?? 'the default iPhone'}. Use an exact available device name or UDID.` @@ -250,110 +261,117 @@ export function createWarmSimulatorSession({ } } - /** - * Boots the device and starts serve-sim once `servicesAllowed` settles, then `tools` once - * serve-sim is ready. Resolves when serve-sim is ready; a tool failure goes to onPreparationFailure. - */ - async function startDeviceAsync( - device: SimulatorDevice, + function startDevice( + device: WarmSimulatorDevice, tools: WarmTool[], + preinstallExpoGo: boolean, servicesAllowed: Promise = Promise.resolve() - ): Promise { - await throwIfStoppingAsync(); - const udid = device.udid; - ownedSimulatorUdids.add(udid); - const boot = bootSimulatorAsync(udid, signal); - if (!expoGoPreinstall) { - expoGoPreinstall = startExpoGoPreinstall(udid, boot, signal); - void expoGoPreinstall.resultPromise.catch(onBackgroundFailure); - } - const serveSimReady = Promise.all([boot, servicesAllowed]).then(async () => { - const handle = startServeSim(udid, signal); - serveSim = handle; - serveSims.add(handle); - await waitForServeSimReadyAsync(udid, handle, signal); - }); - // Tools spawn into the Simulator; starting them while serve-sim arms its own launchctl - // settings coincided with simctl stalls, so they wait for serve-sim to be ready. - for (const tool of tools) { - startedTools.set(tool, startTool(tool, udid, serveSimReady, signal, onPreparationFailure)); - } - // The job launches apps right after its claim, so apsd is settled before the device is ready. - const apsd = boot.then(() => - disableApsdAsync(udid, signal).catch(error => { - signal.throwIfAborted(); - process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`); - }) - ); - try { - await Promise.all([serveSimReady, apsd]); - } catch (error) { - onPreparationFailure(error); - await Promise.allSettled([ - boot, - apsd, - servicesAllowed, - ...[...startedTools.values()].map(tool => tool.ready), - ]); - throw error; + ): WarmDeviceSession { + const session: WarmDeviceSession = { + device, + shutdown: new AbortController(), + ready: Promise.resolve(), + }; + devices.set(device.udid, session); + session.ready = (async () => { + await throwIfStoppingAsync(); + const deviceSignal = AbortSignal.any([signal, session.shutdown.signal]); + deviceSignal.throwIfAborted(); + const udid = device.udid; + ownedSimulatorUdids.add(udid); + const boot = bootSimulatorAsync(udid, deviceSignal); + if (preinstallExpoGo) { + session.expoGo = startExpoGoPreinstall(udid, boot, deviceSignal); + void session.expoGo.resultPromise.catch(onBackgroundFailure); + } + const serveSimReady = Promise.all([boot, servicesAllowed]).then(async () => { + const handle = startServeSim(udid, deviceSignal, device.port); + session.serveSim = handle; + serveSims.add(handle); + await waitForServeSimReadyAsync(udid, handle, deviceSignal, device.port); + }); + for (const tool of tools) { + startedTools.set( + tool, + startTool(tool, udid, serveSimReady, deviceSignal, onPreparationFailure) + ); + } + const apsd = boot.then(() => + disableApsdAsync(udid, deviceSignal).catch(error => { + deviceSignal.throwIfAborted(); + process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`); + }) + ); + try { + await Promise.all([serveSimReady, apsd]); + } catch (error) { + if (!session.shutdown.signal.aborted) onPreparationFailure(error); + await Promise.allSettled([boot, apsd, serveSimReady, servicesAllowed]); + throw error; + } + })(); + void session.ready.catch(() => {}); + return session; + } + + /** Cancel startup, drain it, then release this device's unclaimed server and its port. */ + async function stopDeviceServerAsync(session: WarmDeviceSession): Promise { + session.shutdown.abort(); + await session.ready.catch(() => {}); + if (session.serveSim) { + await terminateProcessGroupAsync(session.serveSim); + serveSims.delete(session.serveSim); } } - /** - * The new device boots while the old services stop. The old serve-sim is unclaimed and has - * nothing to finalize, so its process group stops without a grace wait. Its port 3200 listener - * is in that group, so the new serve-sim can bind once the group is gone. The old device shuts - * down in the background and stays owned until it does. - */ async function replaceDeviceAsync( device: SimulatorDevice, requestedTool?: WarmTool ): Promise { - const previousUdid = currentDevice.udid; - const previousServeSim = serveSim; + const previousDevices = [...devices.values()]; const previousTools = [...startedTools.values()]; startedTools.clear(); const release = (async () => { - // Settle every stop before failing, so cleanup never races a stop still in flight. - const stops = await Promise.allSettled([ + const results = await Promise.allSettled([ ...previousTools.map(tool => tool.stopAsync()), - (async () => { - if (previousServeSim) { - await terminateProcessGroupAsync(previousServeSim); - serveSims.delete(previousServeSim); - } - })(), + ...previousDevices.map(session => stopDeviceServerAsync(session)), ]); - const failed = stops.find(stop => stop.status === 'rejected'); - if (failed?.status === 'rejected') { - throw failed.reason; - } + const failed = results.find(result => result.status === 'rejected'); + if (failed?.status === 'rejected') throw failed.reason; await throwIfStoppingAsync(); })(); previousDeviceShutdown = release.then(async () => { - await shutdownSimulatorAsync(previousUdid); - ownedSimulatorUdids.delete(previousUdid); + const results = await Promise.allSettled( + previousDevices.map(async session => { + await session.expoGo?.stopAsync(); + await shutdownSimulatorAsync(session.device.udid); + ownedSimulatorUdids.delete(session.device.udid); + devices.delete(session.device.udid); + }) + ); + const failed = results.find(result => result.status === 'rejected'); + if (failed?.status === 'rejected') throw failed.reason; }); void previousDeviceShutdown.catch(() => {}); - await startDeviceAsync(device, requestedTool ? [requestedTool] : [], release); - currentDevice = device; + const replacement = startDevice( + { ...device, port: 3200 }, + requestedTool ? [requestedTool] : [], + /* preinstallExpoGo */ false, + release + ); + await replacement.ready; + currentDevice = replacement.device; } - /** Stops serve-sim and the tools, then shuts down every owned device. */ + /** Stops services before shutting down every device whose boot was attempted. */ async function stopServicesAndDevicesAsync(): Promise { const cleanupErrors: unknown[] = []; const attempt = (step: Promise) => step.catch(error => cleanupErrors.push(error)); - for (const handle of serveSims) { - await attempt(stopProcessAndDescendantsAsync(handle)); - } + for (const handle of serveSims) await attempt(stopProcessAndDescendantsAsync(handle)); if (ownedSimulatorUdids.size) { - for (const tool of warmTools) { - await attempt(tool.stopAsync()); - } - } - for (const udid of ownedSimulatorUdids) { - await attempt(shutdownSimulatorAsync(udid)); + for (const tool of warmTools) await attempt(tool.stopAsync()); } + for (const udid of ownedSimulatorUdids) await attempt(shutdownSimulatorAsync(udid)); try { commands.throwIfCommandCloseTimedOut(); } catch (error) { @@ -370,32 +388,43 @@ export function createWarmSimulatorSession({ return { async prepareAsync(): Promise { availableDevices = await listAvailableSimulatorsAsync(signal); - const [selectedDevice] = selectWarmupSimulators( - availableDevices, - process.env.EXPO_SIMULATOR_DEVICE + warmDevices = selectWarmupSimulators(availableDevices, process.env.EXPO_SIMULATOR_DEVICE).map( + (device, index) => ({ ...device, port: 3200 + index * 2 }) ); const controllerStartTime = ( await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) ).trim(); - if (!controllerStartTime) { + if (!controllerStartTime) throw new Error( 'Could not read the Simulator launcher process start time; the warm session cannot be verified.' ); - } - // Claimable once serve-sim is ready; the tools keep warming until a claim selects one. - await startDeviceAsync(selectedDevice, warmTools); - currentDevice = selectedDevice; + const first = startDevice(warmDevices[0], warmTools, /* preinstallExpoGo */ true); + await first.ready; + currentDevice = first.device; return { device: currentDevice, + devices: warmDevices, controllerPid: process.pid, controllerStartTime, agentDeviceStateDir: process.env.AGENT_DEVICE_STATE_DIR!, argentEventLogPath: process.env.ARGENT_EVENT_LOG!, }; }, - + startRemainingDevices(): void { + if (claimed || stopping) return; + for (const device of warmDevices.slice(1)) { + if (!devices.has(device.udid)) startDevice(device, [], /* preinstallExpoGo */ true); + } + }, async claimAsync(request: ClaimInput) { - const preinstalledExpoGo = await expoGoPreinstall?.claimAsync(); + claimed = true; + const preinstalls = new Map( + [...devices].map(([udid, session]) => { + const result = session.expoGo?.claimAsync() ?? Promise.resolve(undefined); + void result.catch(onBackgroundFailure); + return [udid, result]; + }) + ); await throwIfStoppingAsync(); const device = !request.requestedDevice || @@ -403,10 +432,8 @@ export function createWarmSimulatorSession({ ? currentDevice : await resolveDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); const requestedTool = warmTools.find(tool => tool.name === request.engine); - if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) { + if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) await replaceDeviceAsync(device, requestedTool); - } - // Unselected tools stop now, even mid-startup, so they do not compete with the job. unusedToolsCleanup = Promise.allSettled( [...startedTools] .filter(([tool]) => tool !== requestedTool) @@ -418,32 +445,30 @@ export function createWarmSimulatorSession({ }); }) ); - if (requestedTool) { - await startedTools.get(requestedTool)!.ready; - } - // A replacement device has no preinstalled Expo Go. - const preinstalledHere = preinstalledExpoGo?.udid === currentDevice.udid; - return { device: currentDevice, ...(preinstalledHere ? { preinstalledExpoGo } : {}) }; + if (requestedTool) await startedTools.get(requestedTool)!.ready; + const preinstalledExpoGo = await preinstalls.get(currentDevice.udid); + return { device: currentDevice, ...(preinstalledExpoGo ? { preinstalledExpoGo } : {}) }; }, - - /** Drain all owned work. Rejection means terminal cleanup cannot be acknowledged. */ async stopAsync(): Promise { - const [unusedToolsResults, expoGoResults] = await Promise.all([ + stopping = true; + await Promise.all([...startedTools.values()].map(tool => tool.cancelAsync())); + for (const session of devices.values()) session.shutdown.abort(); + const [unusedToolsResults, unusedDeviceResults, expoGoResults] = await Promise.all([ unusedToolsCleanup, - Promise.allSettled([expoGoPreinstall?.stopAsync()]), + unusedDevicesCleanup, + Promise.allSettled([...devices.values()].map(session => session.expoGo?.stopAsync())), ]); - await Promise.all([...startedTools.values()].map(tool => tool.cancelAsync())); + await Promise.allSettled([...devices.values()].map(session => session.ready)); await previousDeviceShutdown.catch(() => {}); await stopServicesAndDevicesAsync(); - const warmupErrors = [...unusedToolsResults, ...expoGoResults].flatMap(result => - result.status === 'rejected' ? [result.reason] : [] + const errors = [...unusedToolsResults, ...unusedDeviceResults, ...expoGoResults].flatMap( + result => (result.status === 'rejected' ? [result.reason] : []) ); - if (warmupErrors.length) { + if (errors.length) throw new AggregateError( - warmupErrors, + errors, 'Warm Simulator background work could not be cleaned up; cleanup remains incomplete.' ); - } }, }; } diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index 1f91a8c83d..3f0365d955 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -6,13 +6,11 @@ import { z } from 'zod'; import { type ChildProcessHandle, commands } from './processes'; -const SERVE_SIM_PORT = 3200; -const SERVE_SIM_URL = `http://127.0.0.1:${SERVE_SIM_PORT}`; const SERVE_SIM_READY_TIMEOUT_MS = 120_000; // The job needs the token from this file; /readyz itself is not token-gated. const ServeSimStateSchema = z.object({ device: z.string(), url: z.string(), token: z.string() }); -export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHandle { +export function startServeSim(udid: string, signal: AbortSignal, port = 3200): ChildProcessHandle { signal.throwIfAborted(); return commands.start( 'bunx', @@ -22,7 +20,7 @@ export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHa '--host', '127.0.0.1', '--port', - String(SERVE_SIM_PORT), + String(port), '--require-token', '--transport', 'webrtc', @@ -50,8 +48,10 @@ export function startServeSim(udid: string, signal: AbortSignal): ChildProcessHa export async function waitForServeSimReadyAsync( udid: string, server: ChildProcessHandle, - signal: AbortSignal + signal: AbortSignal, + port = 3200 ): Promise { + const url = `http://127.0.0.1:${port}`; let exited = false; // Handle both outcomes: a rejection here would otherwise be unhandled and end the launcher. const markExited = (): void => { @@ -74,12 +74,12 @@ export async function waitForServeSimReadyAsync( .then(contents => ServeSimStateSchema.parse(JSON.parse(contents))) .catch(() => undefined); // Only the local serve-sim this launcher started may be probed; the job checks the same. - if (state && state.url !== SERVE_SIM_URL) { - throw new Error(`serve-sim on ${udid} reports ${state.url}, not ${SERVE_SIM_URL}.`); + if (state && state.url !== url) { + throw new Error(`serve-sim on ${udid} reports ${state.url}, not ${url}.`); } try { if (state) { - const response = await fetch(`${SERVE_SIM_URL}/readyz`, { + const response = await fetch(`${url}/readyz`, { signal: AbortSignal.any([signal, AbortSignal.timeout(2_000)]), }); const ready = response.ok diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index 531731f2f2..c8cab0b4a2 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -10,6 +10,9 @@ export const SimulatorDeviceSchema = z.object({ udid: NonBlankStringSchema, name: NonBlankStringSchema, }); +export const WarmSimulatorDeviceSchema = SimulatorDeviceSchema.extend({ + port: z.number().int().min(1).max(65535), +}); export const ClaimInputSchema = z.object({ engine: z.enum(['serve-sim', 'agent-device', 'argent']), requestedDevice: NonBlankStringSchema.optional(), @@ -22,7 +25,8 @@ export const PreinstalledAppSchema = z.object({ version: z.string(), }); export const WarmSessionInfoSchema = z.object({ - device: SimulatorDeviceSchema, + device: WarmSimulatorDeviceSchema, + devices: z.array(WarmSimulatorDeviceSchema).min(1), controllerPid: z.number().int().gt(1), controllerStartTime: NonBlankStringSchema, agentDeviceStateDir: NonBlankStringSchema, @@ -39,6 +43,7 @@ export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({ export type ClaimInput = z.infer; export type LauncherState = z.infer; export type WarmSessionInfo = z.infer; +export type WarmSimulatorDevice = z.infer; export type SimulatorDevice = z.infer; export type PreinstalledApp = z.infer; From 0779aedd6eb5c8542d3c50b850c07b92e48cfc7f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 14:47:37 -0700 Subject: [PATCH 24/42] [simulator-launcher] Claim either warm device and release the others --- .changeset/simulator-launcher.md | 8 +- packages/simulator-launcher/README.md | 75 ++-- .../src/__tests__/main.test.ts | 40 +- .../src/__tests__/simulatorSession.test.ts | 397 ++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 146 +++++-- .../simulator-launcher/src/utils/state.ts | 4 + 6 files changed, 589 insertions(+), 81 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index f4efc8b766..d78956d291 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -4,16 +4,12 @@ Add `@expo/simulator-launcher` for warm Simulators: `start` runs warmup at VM boot, `claim` hands the warm Simulator to a job, and `stop` ends warmup. -Warmup boots the device, disables apsd, preinstalls the latest stable Expo Go, starts serve-sim, and starts Argent and agent-device after serve-sim is ready. The warm Simulator is ready when the device has booted, the apsd disable attempt has finished and serve-sim answers. +Warmup starts the first configured device, then warms the other devices after the first is ready. Each gets its own serve-sim port, apsd disable attempt and best-effort Expo Go preinstall. Agent-device and one Argent server prestart with the default device. A claim waits only for the requested engine and stops the other tools, also while they are still starting. The claim record names the preinstalled Expo Go, so the job can skip a matching install. A claim waits for an Expo Go install that already started and prevents a later one. -Replacement boots the requested device while the old tools and serve-sim stop. The old device shuts down in the background. +A claim reuses a matching warm device and retires the others in the background. Replacement boots the requested device while all old tools and serve-sim processes stop; old devices shut down in the background. `stop` waits for every tool to stop and reports a cleanup failure instead of a confirmed stop. The launcher only checks for ffmpeg, which the VM image provides. Processes that exited but are not yet reaped count as stopped. The launcher records its package version instead of copying its bundle. Build-tools uses that exact version for claim and stop. - -Accept a comma-separated list of exact Simulator names or UDIDs for warmup. - -Warm additional devices after the default is ready, each with its own serve-sim port. diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index 24d27d82cf..00fe1e33d5 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -1,60 +1,59 @@ # @expo/simulator-launcher -Run warmup for an iOS Simulator before an EAS job arrives: boot it and start serve-sim, agent-device and Argent. -Requires macOS. Only vm-tools is image-installed; its startup script runs the launcher: +Warm iOS Simulators before an EAS job arrives. Requires macOS. +The image installs vm-tools and writes its startup script at +`/Volumes/My Shared Files/expo-startup/startup.sh`: ```sh export EXPO_SIMULATOR_STARTUP=1 -export EXPO_SIMULATOR_DEVICE="iPhone 17" +export EXPO_SIMULATOR_DEVICE="iPhone Duo,iPhone 18" exec bunx @expo/simulator-launcher@latest start ``` ## Commands -- `start`: boot the selected Simulator and launch the tools in the foreground. -- `claim ENGINE [DEVICE]`: hand the warm Simulator to one job. Engines: `serve-sim`, `agent-device`, - `argent`. Omit DEVICE to reuse it, or pass an exact name or UDID to select another. - A claim waits only for its engine and stops the other tools. Replacement boots the requested - device while the old tools and serve-sim stop; the old device shuts down in the background. -- `stop`: end warmup for this VM boot and wait for owned resources to be cleaned up. +- `start`: warm the default device in the foreground. After it is ready, warm the remaining devices. +- `claim ENGINE [DEVICE]`: give one device to one job. Engines: `serve-sim`, `agent-device`, `argent`. No DEVICE means the first configured device. A matching warm device is reused; + its device and port are returned, and the other device is stopped in the background. + Otherwise the requested device boots while the old servers/tools stop. Old devices shut down + in the background, without delaying the replacement's startup. +- `stop`: permanently end warmup for this boot and wait for all owned resources to be cleaned up. ```sh -bunx @expo/simulator-launcher@latest claim serve-sim -# Returns one JSON record with the device, boot, engine, controller identity and tool paths. -bunx @expo/simulator-launcher@latest stop +# Build-tools reads LAUNCHER_VERSION from the version recorded by start. +bunx "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim +bunx "@expo/simulator-launcher@$LAUNCHER_VERSION" stop ``` -`start` records its package version in the state file. Build-tools uses that exact version from -Bun's cache for later claim and stop commands. No launcher bundle is copied into the image or home. -Before startup has run, the package `stop` command still disables later warmup for this boot. +There is no copied launcher bundle. Build-tools uses the recorded package version for claim/stop; +Bun may resolve registry metadata on its first exact-version invocation. Before start has run, +stop uses the normal package-manager path and still disables later warmup for this boot. -The launcher has one durable state file: `/tmp/expo-simulator-startup.json`, shared by all commands -regardless of HOME/TMPDIR. It contains the lifecycle, claim and native session metadata. A short -boot-scoped filesystem lock serializes updates; an abandoned lock fails closed without takeover. -This private format requires a fresh VM; there is no live migration. +One durable state file, `/tmp/expo-simulator-startup.json`, holds the lifecycle, launcher version, +devices/ports, claim and native identity. It is independent of HOME/TMPDIR. A short boot-scoped lock +serializes updates; an abandoned lock fails closed. This private format requires a fresh VM. ## Environment -The startup script owns Node/Bun/PATH setup and these optional overrides: - -- `EXPO_SIMULATOR_STARTUP=1` enables warmup; `EXPO_SIMULATOR_DEVICE` selects an exact name or UDID. - Without a selector, start uses the shortest available iPhone name. -- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: tool versions, each defaulting to `latest`. +- `EXPO_SIMULATOR_STARTUP=1` enables warmup. +- `EXPO_SIMULATOR_DEVICE` is a comma-separated list of exact device names or UDIDs. Entries are + trimmed and must each identify one available device. The first is the default. Unset keeps + the shortest available iPhone name; empty, missing, ambiguous or duplicate entries fail clearly. +- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: each defaults to `latest`. - `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations. -- Agent-device daemon/runner idle timers default to off. Override with - `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS` / `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` (milliseconds). +- Agent-device idle timers default to off. Override with `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS` + and `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` (milliseconds). ## Boundaries -- Warmup is a chance to save time, not proof that every service is ready. Build-tools checks - the tools and runs serve-sim adoption (`--adopt`) before app actions. -- One start attempt and one accepted claim per VM boot. Stop is permanent for that boot; - a failed replacement cannot skip cleanup and start another device. -- A session is ready once its device has booted, the apsd disable attempt has finished and serve-sim answers. -- Warmup also tries to preinstall the latest stable Expo Go. It only checks for ffmpeg, which the VM - image provides. Ordinary failures warn. A failed apsd disable leaves push notifications running; - warm jobs do not retry it, and they do not use push notifications. Claim prevents a late Expo Go - install, waiting only if native install already started, and its record names the preinstalled - Expo Go for the same device; the job's requested app remains authoritative. -- Stop has a caller timeout, not a global cleanup deadline. A timeout or cleanup error does not prove - teardown completed, and EAS must not start a fallback session until cleanup is confirmed. +- Ready means the default device booted, its apsd disable attempt finished and serve-sim answers. + The second device may still be warming. Claim waits for the selected device; job-time native, + version and authentication checks plus private serve-sim adoption remain authoritative. +- Agent-device prestarts on the default device only. A secondary claim prepares its runner after + the old tools stop. One device-independent Argent server is reused for the claimed device. +- Warmup tries to preinstall the latest stable Expo Go on each initial device; ordinary failures + warn. Claim prevents new preinstalls and waits for a selected-device native install already in + flight. Unused-device preinstalls drain during background cleanup. Job app installs remain + authoritative. Replacements do not preinstall again. The VM image provides ffmpeg. +- One start attempt and one accepted claim per boot. Failed cleanup never permits another session. + Stop's caller timeout is not a global cleanup deadline or proof that teardown completed. diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 53529310df..72e03f54a3 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -57,6 +57,7 @@ async function beginSession(): Promise[]> { await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting', + launcherVersion: require('../../package.json').version, controllerPid: process.pid, })); const completion = runWarmSimulatorSessionAsync(boot).catch(error => error); @@ -487,8 +488,9 @@ test('publishes default readiness before starting a second device', async () => const original = jest.mocked(commands.runAsync).getMockImplementation()!; let stateAtSecondBoot: string | undefined; jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => { - if (args.includes('bootstatus') && args.includes('PRO')) + if (args.includes('bootstatus') && args.includes('PRO')) { stateAtSecondBoot = (await readLauncherStateAsync(boot))?.status; + } return original(file, args, signal); }); const [completion] = await beginSession(); @@ -501,3 +503,39 @@ test('publishes default readiness before starting a second device', async () => await stopAsync(boot); expect(await completion).toBeUndefined(); }); + +test('stop still waits for cleanup on a versionless session record', async () => { + await updateLauncherStateAsync(boot, () => ({ + bootUuid: boot, + status: 'ready', + controllerPid: 9000, + device: { udid: 'PHONE', name: 'iPhone 17' }, + })); + const stopping = stopAsync(boot); + await status('stopping'); + expect((await readLauncherStateAsync(boot))?.launcherVersion).toBeUndefined(); + await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' })); + expect(await stopping).toBe(0); +}); + +test('claim publishes the selected device port and version as one JSON result', async () => { + process.env.EXPO_SIMULATOR_DEVICE = 'iPhone 17,iPhone 17 Pro'; + const [completion] = await beginSession(); + await status('ready'); + await until(() => jobs.length === 4); + const stdout = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + try { + expect(await claimAsync(boot, 'serve-sim', 'PRO')).toBe(0); + const result = JSON.parse(String(stdout.mock.calls.at(-1)![0])); + expect(result).toMatchObject({ + status: 'claimed', + launcherVersion: require('../../package.json').version, + device: { udid: 'PRO', name: 'iPhone 17 Pro', port: 3202 }, + devices: [{ udid: 'PRO', name: 'iPhone 17 Pro', port: 3202 }], + }); + await stopAsync(boot); + expect(await completion).toBeUndefined(); + } finally { + stdout.mockRestore(); + } +}); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index ef8ab2934a..3ca98958cf 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -683,3 +683,400 @@ it('warms additional devices only after the default is ready, with separate port secondReady.resolve(); } }); + +function configureTwoDevices() { + process.env.EXPO_SIMULATOR_DEVICE = 'iPhone Duo,iPhone 18'; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => + args.includes('devices') + ? Promise.resolve( + JSON.stringify({ + devices: { + ios: [ + { udid: 'DUO', name: 'iPhone Duo' }, + { udid: 'PHONE18', name: 'iPhone 18' }, + { udid: 'OTHER', name: 'iPhone Other' }, + ], + }, + }) + ) + : original(file, args, signal) + ); + const handles = { DUO: handle(201), PHONE18: handle(202), OTHER: handle(203) }; + jest.mocked(startServeSim).mockImplementation(udid => handles[udid as keyof typeof handles]); + return handles; +} + +it('claims the second warm device without rebooting or listing again, retiring the first in the background', async () => { + const servers = configureTwoDevices(); + const oldServerStop = deferred(); + jest + .mocked(terminateProcessGroupAsync) + .mockImplementation(server => + server === servers.DUO ? oldServerStop.promise : Promise.resolve() + ); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + const listCalls = ran('devices').length; + try { + expect(await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'phone18' })).toEqual({ + device: { udid: 'PHONE18', name: 'iPhone 18', port: 3202 }, + }); + expect(startServeSim).toHaveBeenCalledTimes(2); + expect(ran('devices')).toHaveLength(listCalls); + expect(ran('bootstatus', 'PHONE18')).toHaveLength(1); + expect(terminateProcessGroupAsync).toHaveBeenCalledWith(servers.DUO); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + oldServerStop.resolve(); + await stopping; + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE18'); + } finally { + oldServerStop.resolve(); + } +}); + +it('returns the default while unused-device Expo Go cleanup is pending, but terminal stop drains it', async () => { + configureTwoDevices(); + const draining = deferred(); + const defaultPreinstall = { + claimAsync: jest.fn(async () => undefined), + stopAsync: jest.fn(async () => {}), + resultPromise: Promise.resolve(), + }; + const secondPreinstall = { + claimAsync: jest.fn(async () => undefined), + stopAsync: jest.fn(() => draining.promise), + resultPromise: Promise.resolve(), + }; + jest + .mocked(startExpoGoPreinstall) + .mockReturnValueOnce(defaultPreinstall) + .mockReturnValueOnce(secondPreinstall); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startExpoGoPreinstall).mock.calls.length === 2); + try { + expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); + expect(defaultPreinstall.claimAsync).toHaveBeenCalledTimes(1); + expect(secondPreinstall.claimAsync).toHaveBeenCalledTimes(1); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('PHONE18'); + draining.resolve(); + await stopping; + } finally { + draining.resolve(); + } +}); + +it('waits for the selected secondary device before returning its port', async () => { + configureTwoDevices(); + const ready = deferred(); + jest + .mocked(waitForServeSimReadyAsync) + .mockImplementation(udid => (udid === 'PHONE18' ? ready.promise : Promise.resolve())); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + let claimed = false; + const claiming = session + .claimAsync({ engine: 'serve-sim', requestedDevice: 'iPhone 18' }) + .then(value => { + claimed = true; + return value; + }); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(claimed).toBe(false); + ready.resolve(); + expect((await claiming).device.port).toBe(3202); + await session.stopAsync(); + } finally { + ready.resolve(); + } +}); + +it.each(['agent-device'] as const)( + 'moves %s to the secondary only after the old tools stop', + async engine => { + configureTwoDevices(); + const oldStop = deferred(); + let stoppingOld = true; + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async server => { + if (server === agent && stoppingOld) { + stoppingOld = false; + await oldStop.promise; + } + }); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + const before = started(engine === 'agent-device' ? 'prepare' : 'server').length; + const claiming = session.claimAsync({ engine, requestedDevice: 'PHONE18' }); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(started(engine === 'agent-device' ? 'prepare' : 'server')).toHaveLength(before); + oldStop.resolve(); + expect((await claiming).device.udid).toBe('PHONE18'); + expect(started(engine === 'agent-device' ? 'prepare' : 'server')).toHaveLength(before + 1); + if (engine === 'agent-device') { + expect(started('prepare').at(-1)![1]).toContain('PHONE18'); + } + await session.stopAsync(); + } finally { + oldStop.resolve(); + } + } +); + +it('boots an unlisted replacement while both old servers stop, and drains both old shutdowns later', async () => { + const servers = configureTwoDevices(); + const releaseDuo = deferred(), + release18 = deferred(); + const shutdownDuo = deferred(), + shutdown18 = deferred(); + jest + .mocked(terminateProcessGroupAsync) + .mockImplementation(server => + server === servers.DUO ? releaseDuo.promise : release18.promise + ); + jest + .mocked(shutdownSimulatorAsync) + .mockImplementation(udid => + udid === 'DUO' + ? shutdownDuo.promise + : udid === 'PHONE18' + ? shutdown18.promise + : Promise.resolve() + ); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' }); + try { + await until(() => ran('bootstatus', 'OTHER').length === 1); + expect(terminateProcessGroupAsync).toHaveBeenCalledWith(servers.DUO); + expect(terminateProcessGroupAsync).toHaveBeenCalledWith(servers.PHONE18); + expect(startServeSim).toHaveBeenCalledTimes(2); + releaseDuo.resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(startServeSim).toHaveBeenCalledTimes(2); + release18.resolve(); + expect((await claiming).device).toEqual({ udid: 'OTHER', name: 'iPhone Other', port: 3200 }); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(2); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + shutdownDuo.resolve(); + shutdown18.resolve(); + await stopping; + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER'); + } finally { + releaseDuo.resolve(); + release18.resolve(); + shutdownDuo.resolve(); + shutdown18.resolve(); + } +}); + +it('reuses Argent on a secondary device while unused agent-device stops in the background', async () => { + configureTwoDevices(); + const oldStop = deferred(); + jest + .mocked(stopProcessAndDescendantsAsync) + .mockImplementation(server => (server === agent ? oldStop.promise : Promise.resolve())); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until( + () => started('server').length === 1 && jest.mocked(startServeSim).mock.calls.length === 2 + ); + try { + expect( + (await session.claimAsync({ engine: 'argent', requestedDevice: 'PHONE18' })).device.port + ).toBe(3202); + expect(started('server')).toHaveLength(1); + expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(argent); + expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('DUO'); + oldStop.resolve(); + await session.stopAsync(); + } finally { + oldStop.resolve(); + } +}); + +it('stops partial device startup without requiring the parent signal to abort first', async () => { + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => { + if (args.includes('bootstatus')) { + return new Promise((_resolve, reject) => + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + ); + } + return original(file, args, signal); + }); + const session = createSession(); + const preparing = session.prepareAsync().catch(error => error); + await until(() => ran('bootstatus').length === 1); + await session.stopAsync(); + expect(await preparing).toMatchObject({ name: 'AbortError' }); + expect(controller.signal.aborted).toBe(false); + expect(startServeSim).not.toHaveBeenCalled(); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE'); +}); + +it('a secondary startup paused before claim cannot create a late Expo Go install', async () => { + configureTwoDevices(); + const release = deferred(); + let checks = 0; + const session = createWarmSimulatorSession({ + signal: controller.signal, + onPreparationFailure, + onBackgroundFailure, + throwIfStoppingAsync: async () => { + if (++checks === 2) { + await release.promise; + } + controller.signal.throwIfAborted(); + }, + }); + await session.prepareAsync(); + session.startRemainingDevices(); + const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }); + try { + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); + release.resolve(); + expect((await claiming).device.udid).toBe('PHONE18'); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); + await session.stopAsync(); + } finally { + release.resolve(); + } +}); + +it('does not shut down an unused secondary whose boot never started', async () => { + configureTwoDevices(); + const release = deferred(); + let checks = 0; + const session = createWarmSimulatorSession({ + signal: controller.signal, + onPreparationFailure, + onBackgroundFailure, + throwIfStoppingAsync: async () => { + if (++checks === 2) { + await release.promise; + } + controller.signal.throwIfAborted(); + }, + }); + await session.prepareAsync(); + session.startRemainingDevices(); + try { + expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); + release.resolve(); + await session.stopAsync(); + expect(ran('bootstatus', 'PHONE18')).toHaveLength(0); + expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('PHONE18'); + } finally { + release.resolve(); + } +}); + +it('does not cancel a retained Argent configuration when the default device retires', async () => { + configureTwoDevices(); + const configured = deferred(); + let configSignal: AbortSignal | undefined; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => { + if (args.includes('artifacts-list-endpoint')) { + configSignal = signal; + return configured.promise; + } + return original(file, args, signal); + }); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + const claiming = session.claimAsync({ engine: 'argent', requestedDevice: 'PHONE18' }); + try { + await until(() => jest.mocked(terminateProcessGroupAsync).mock.calls.length > 0); + expect(configSignal?.aborted).toBe(false); + expect(onPreparationFailure).not.toHaveBeenCalled(); + configured.resolve(''); + expect((await claiming).device.udid).toBe('PHONE18'); + expect(started('server')).toHaveLength(1); + await session.stopAsync(); + } finally { + configured.resolve(''); + } +}); + +it('rejects an ambiguous warm name even when both exact UDIDs were configured', async () => { + process.env.EXPO_SIMULATOR_DEVICE = 'PHONE,OTHER'; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => + args.includes('devices') + ? Promise.resolve( + JSON.stringify({ + devices: { + ios: [ + { udid: 'PHONE', name: 'iPhone Same' }, + { udid: 'OTHER', name: 'iPhone Same' }, + ], + }, + }) + ) + : original(file, args, signal) + ); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await expect( + session.claimAsync({ engine: 'serve-sim', requestedDevice: 'iPhone Same' }) + ).rejects.toThrow('More than one'); + await session.stopAsync(); +}); + +it('returns only the selected device preinstall metadata', async () => { + configureTwoDevices(); + jest.mocked(startExpoGoPreinstall).mockImplementation(udid => ({ + resultPromise: Promise.resolve(), + stopAsync: jest.fn(async () => {}), + claimAsync: jest.fn(async () => ({ + udid, + bundleIdentifier: 'host.exp.Exponent', + shortVersion: udid === 'DUO' ? '58.0.5' : '58.0.6', + version: '1', + })), + })); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startExpoGoPreinstall).mock.calls.length === 2); + const result = await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }); + expect(result.preinstalledExpoGo).toEqual({ + udid: 'PHONE18', + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.6', + version: '1', + }); + await session.stopAsync(); +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 72c1df504b..6810a6405c 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -250,7 +250,9 @@ export function createWarmSimulatorSession({ ): Promise { for (let attempt = 0; ; attempt++) { const matches = selectSimulators(availableDevices, selector); - if (matches.length === 1) return matches[0]; + if (matches.length === 1) { + return matches[0]; + } if (matches.length > 1 || !refreshOnMiss || attempt === 1) { throw new Error( `Could not select one available Simulator for ${selector ?? 'the default iPhone'}. Use an exact available device name or UDID.` @@ -280,7 +282,7 @@ export function createWarmSimulatorSession({ const udid = device.udid; ownedSimulatorUdids.add(udid); const boot = bootSimulatorAsync(udid, deviceSignal); - if (preinstallExpoGo) { + if (preinstallExpoGo && !claimed && !stopping) { session.expoGo = startExpoGoPreinstall(udid, boot, deviceSignal); void session.expoGo.resultPromise.catch(onBackgroundFailure); } @@ -291,10 +293,7 @@ export function createWarmSimulatorSession({ await waitForServeSimReadyAsync(udid, handle, deviceSignal, device.port); }); for (const tool of tools) { - startedTools.set( - tool, - startTool(tool, udid, serveSimReady, deviceSignal, onPreparationFailure) - ); + startedTools.set(tool, startTool(tool, udid, serveSimReady, signal, onPreparationFailure)); } const apsd = boot.then(() => disableApsdAsync(udid, deviceSignal).catch(error => { @@ -305,7 +304,9 @@ export function createWarmSimulatorSession({ try { await Promise.all([serveSimReady, apsd]); } catch (error) { - if (!session.shutdown.signal.aborted) onPreparationFailure(error); + if (!session.shutdown.signal.aborted) { + onPreparationFailure(error); + } await Promise.allSettled([boot, apsd, serveSimReady, servicesAllowed]); throw error; } @@ -337,7 +338,9 @@ export function createWarmSimulatorSession({ ...previousDevices.map(session => stopDeviceServerAsync(session)), ]); const failed = results.find(result => result.status === 'rejected'); - if (failed?.status === 'rejected') throw failed.reason; + if (failed?.status === 'rejected') { + throw failed.reason; + } await throwIfStoppingAsync(); })(); previousDeviceShutdown = release.then(async () => { @@ -350,7 +353,9 @@ export function createWarmSimulatorSession({ }) ); const failed = results.find(result => result.status === 'rejected'); - if (failed?.status === 'rejected') throw failed.reason; + if (failed?.status === 'rejected') { + throw failed.reason; + } }); void previousDeviceShutdown.catch(() => {}); const replacement = startDevice( @@ -367,11 +372,17 @@ export function createWarmSimulatorSession({ async function stopServicesAndDevicesAsync(): Promise { const cleanupErrors: unknown[] = []; const attempt = (step: Promise) => step.catch(error => cleanupErrors.push(error)); - for (const handle of serveSims) await attempt(stopProcessAndDescendantsAsync(handle)); + for (const handle of serveSims) { + await attempt(stopProcessAndDescendantsAsync(handle)); + } if (ownedSimulatorUdids.size) { - for (const tool of warmTools) await attempt(tool.stopAsync()); + for (const tool of warmTools) { + await attempt(tool.stopAsync()); + } + } + for (const udid of ownedSimulatorUdids) { + await attempt(shutdownSimulatorAsync(udid)); } - for (const udid of ownedSimulatorUdids) await attempt(shutdownSimulatorAsync(udid)); try { commands.throwIfCommandCloseTimedOut(); } catch (error) { @@ -394,10 +405,11 @@ export function createWarmSimulatorSession({ const controllerStartTime = ( await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) ).trim(); - if (!controllerStartTime) + if (!controllerStartTime) { throw new Error( 'Could not read the Simulator launcher process start time; the warm session cannot be verified.' ); + } const first = startDevice(warmDevices[0], warmTools, /* preinstallExpoGo */ true); await first.ready; currentDevice = first.device; @@ -411,9 +423,13 @@ export function createWarmSimulatorSession({ }; }, startRemainingDevices(): void { - if (claimed || stopping) return; + if (claimed || stopping) { + return; + } for (const device of warmDevices.slice(1)) { - if (!devices.has(device.udid)) startDevice(device, [], /* preinstallExpoGo */ true); + if (!devices.has(device.udid)) { + startDevice(device, [], /* preinstallExpoGo */ true); + } } }, async claimAsync(request: ClaimInput) { @@ -426,33 +442,90 @@ export function createWarmSimulatorSession({ }) ); await throwIfStoppingAsync(); - const device = - !request.requestedDevice || - request.requestedDevice.toLowerCase() === currentDevice.udid.toLowerCase() - ? currentDevice - : await resolveDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); + const matches = [...devices.values()].filter(session => + !request.requestedDevice + ? session.device.udid === currentDevice.udid + : session.device.name === request.requestedDevice || + session.device.udid.toLowerCase() === request.requestedDevice.toLowerCase() + ); + if (matches.length > 1) { + throw new Error( + `More than one warm Simulator matches ${request.requestedDevice}. Use an exact UDID.` + ); + } + const existing = matches[0]; const requestedTool = warmTools.find(tool => tool.name === request.engine); - if (device.udid.toLowerCase() !== currentDevice.udid.toLowerCase()) + if (!existing) { + const device = await resolveDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); await replaceDeviceAsync(device, requestedTool); - unusedToolsCleanup = Promise.allSettled( - [...startedTools] - .filter(([tool]) => tool !== requestedTool) - .map(([tool, started]) => { - startedTools.delete(tool); - return started.stopAsync().catch(error => { - onBackgroundFailure(error); - throw error; - }); - }) - ); - if (requestedTool) await startedTools.get(requestedTool)!.ready; + } else { + const movingTools = + existing.device.udid !== currentDevice.udid && requestedTool?.name === 'agent-device'; + unusedToolsCleanup = Promise.allSettled( + [...startedTools] + .filter(([tool]) => movingTools || tool !== requestedTool) + .map(([tool, started]) => { + startedTools.delete(tool); + return started.stopAsync().catch(error => { + onBackgroundFailure(error); + throw error; + }); + }) + ); + unusedDevicesCleanup = Promise.allSettled( + [...devices.values()] + .filter(session => session !== existing) + .map(async session => { + try { + await stopDeviceServerAsync(session); + await session.expoGo?.stopAsync(); + // A native tool startup/stop must settle before its Simulator shuts down. + await unusedToolsCleanup; + if (ownedSimulatorUdids.has(session.device.udid)) { + await shutdownSimulatorAsync(session.device.udid); + ownedSimulatorUdids.delete(session.device.udid); + } + devices.delete(session.device.udid); + } catch (error) { + onBackgroundFailure(error); + throw error; + } + }) + ); + if (movingTools && requestedTool) { + const stopped = await unusedToolsCleanup; + const failed = stopped.find(result => result.status === 'rejected'); + if (failed?.status === 'rejected') { + throw failed.reason; + } + await throwIfStoppingAsync(); + startedTools.set( + requestedTool, + startTool( + requestedTool, + existing.device.udid, + existing.ready, + AbortSignal.any([signal, existing.shutdown.signal]), + onPreparationFailure + ) + ); + } + await existing.ready; + currentDevice = existing.device; + } + if (requestedTool) { + await startedTools.get(requestedTool)!.ready; + } const preinstalledExpoGo = await preinstalls.get(currentDevice.udid); return { device: currentDevice, ...(preinstalledExpoGo ? { preinstalledExpoGo } : {}) }; }, async stopAsync(): Promise { stopping = true; - await Promise.all([...startedTools.values()].map(tool => tool.cancelAsync())); - for (const session of devices.values()) session.shutdown.abort(); + const cancellingTools = [...startedTools.values()].map(tool => tool.cancelAsync()); + for (const session of devices.values()) { + session.shutdown.abort(); + } + await Promise.all(cancellingTools); const [unusedToolsResults, unusedDeviceResults, expoGoResults] = await Promise.all([ unusedToolsCleanup, unusedDevicesCleanup, @@ -464,11 +537,12 @@ export function createWarmSimulatorSession({ const errors = [...unusedToolsResults, ...unusedDeviceResults, ...expoGoResults].flatMap( result => (result.status === 'rejected' ? [result.reason] : []) ); - if (errors.length) + if (errors.length) { throw new AggregateError( errors, 'Warm Simulator background work could not be cleaned up; cleanup remains incomplete.' ); + } }, }; } diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index c8cab0b4a2..57eac2e607 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -33,6 +33,10 @@ export const WarmSessionInfoSchema = z.object({ argentEventLogPath: NonBlankStringSchema, }); export const LauncherStateSchema = WarmSessionInfoSchema.partial().extend({ + // A versionless record can still be stopped; claims require complete port metadata. + device: SimulatorDeviceSchema.extend({ + port: WarmSimulatorDeviceSchema.shape.port.optional(), + }).optional(), bootUuid: NonBlankStringSchema, launcherVersion: NonBlankStringSchema.optional(), status: z.enum(['starting', 'ready', 'claiming', 'claimed', 'stopping', 'disabled']), From cca88196a4097c883f2d10ff6248eae6ac160b41 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 14:47:38 -0700 Subject: [PATCH 25/42] [simulator-launcher] Prime the launcher cache before ready --- packages/simulator-launcher/README.md | 8 ++- .../src/__tests__/main.test.ts | 58 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 20 ++++++- packages/simulator-launcher/src/main.ts | 4 ++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index 00fe1e33d5..a8e738507a 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -21,12 +21,14 @@ exec bunx @expo/simulator-launcher@latest start ```sh # Build-tools reads LAUNCHER_VERSION from the version recorded by start. -bunx "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim -bunx "@expo/simulator-launcher@$LAUNCHER_VERSION" stop +bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim +bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" stop ``` There is no copied launcher bundle. Build-tools uses the recorded package version for claim/stop; -Bun may resolve registry metadata on its first exact-version invocation. Before start has run, +Warmup primes Bun’s exact-version cache before ready. Claim/stop use `--no-install`, retrying +once with the registry only when Bun cannot find that cached binary. Priming failures warn. +Before start has run, stop uses the normal package-manager path and still disables later warmup for this boot. One durable state file, `/tmp/expo-simulator-startup.json`, holds the lifecycle, launcher version, diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index 72e03f54a3..fb37c2dcf5 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -539,3 +539,61 @@ test('claim publishes the selected device port and version as one JSON result', stdout.mockRestore(); } }); + +test('--version prints the package version without reading boot identity or launcher state', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + expect(await mainAsync(['--version'])).toBe(0); + expect(stdout).toHaveBeenCalledWith(`${require('../../package.json').version}\n`); + expect(commands.runAsync).not.toHaveBeenCalled(); + expect(await readLauncherStateAsync(boot)).toBeUndefined(); +}); + +test('primes the exact version once alongside preparation and before publishing ready', async () => { + const primed = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('--version') ? primed.promise : original(file, args, signal) + ); + const starting = startAsync(boot).catch(error => error); + active.push(starting); + try { + await until(() => jest.mocked(serveSim.waitForServeSimReadyAsync).mock.calls.length === 1); + expect((await readLauncherStateAsync(boot))?.status).toBe('starting'); + const calls = jest + .mocked(commands.runAsync) + .mock.calls.filter(([, args]) => args.includes('--version')); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + 'bun', + ['x', `@expo/simulator-launcher@${require('../../package.json').version}`, '--version'], + expect.anything(), + ]); + primed.resolve('version'); + await status('ready'); + await stopAsync(boot); + await starting; + } finally { + primed.resolve('version'); + } +}); + +test('warns about a failed cache prime but still publishes ready', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('--version') + ? Promise.reject(new Error('registry unavailable')) + : original(file, args, signal) + ); + const [completion] = await beginSession(); + await status('ready'); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('Could not cache the Simulator launcher version') + ); + await stopAsync(boot); + expect(await completion).toBeUndefined(); +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 6810a6405c..13db743862 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -243,6 +243,7 @@ export function createWarmSimulatorSession({ let unusedToolsCleanup: Promise[]> = Promise.resolve([]); let unusedDevicesCleanup: Promise[]> = Promise.resolve([]); let previousDeviceShutdown: Promise = Promise.resolve(); + let launcherCachePriming: Promise = Promise.resolve(); async function resolveDeviceAsync( selector?: string, @@ -411,7 +412,23 @@ export function createWarmSimulatorSession({ ); } const first = startDevice(warmDevices[0], warmTools, /* preinstallExpoGo */ true); - await first.ready; + launcherCachePriming = commands + .runAsync( + 'bun', + ['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'], + AbortSignal.any([signal, first.shutdown.signal]) + ) + .then( + () => {}, + error => { + signal.throwIfAborted(); + first.shutdown.signal.throwIfAborted(); + process.stderr.write( + `Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n` + ); + } + ); + await Promise.all([first.ready, launcherCachePriming]); currentDevice = first.device; return { device: currentDevice, @@ -533,6 +550,7 @@ export function createWarmSimulatorSession({ ]); await Promise.allSettled([...devices.values()].map(session => session.ready)); await previousDeviceShutdown.catch(() => {}); + await launcherCachePriming.catch(() => {}); await stopServicesAndDevicesAsync(); const errors = [...unusedToolsResults, ...unusedDeviceResults, ...expoGoResults].flatMap( result => (result.status === 'rejected' ? [result.reason] : []) diff --git a/packages/simulator-launcher/src/main.ts b/packages/simulator-launcher/src/main.ts index 89dce6448d..6e83358ba8 100644 --- a/packages/simulator-launcher/src/main.ts +++ b/packages/simulator-launcher/src/main.ts @@ -9,6 +9,10 @@ export async function mainAsync(args = process.argv.slice(2)): Promise { if (process.platform !== 'darwin') { throw new Error('simulator-launcher requires macOS (darwin).'); } + if (args.length === 1 && args[0] === '--version') { + process.stdout.write(`${require('../package.json').version}\n`); + return 0; + } const [command, engine, requestedDevice] = args; if (command !== 'start' && command !== 'stop' && command !== 'claim') { throw new Error('Usage: simulator-launcher '); From dc6977bb96691497e27e5f695d26d92bfc2d7b5d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:46 -0700 Subject: [PATCH 26/42] [simulator-launcher] Keep unused-device cleanup from stopping the job --- .../src/__tests__/simulatorSession.test.ts | 24 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 6 +++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 3ca98958cf..dba32266fd 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1080,3 +1080,27 @@ it('returns only the selected device preinstall metadata', async () => { }); await session.stopAsync(); }); + +it('keeps the claimed device alive when retiring the unused device fails, then retries at final stop', async () => { + const servers = configureTwoDevices(); + const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + jest + .mocked(terminateProcessGroupAsync) + .mockRejectedValueOnce(new Error('unused server stop failed')); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + try { + expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); + await new Promise(resolve => setImmediate(resolve)); + expect(controller.signal.aborted).toBe(false); + expect(onBackgroundFailure).not.toHaveBeenCalled(); + expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(servers.DUO); + expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('DUO'); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('unused server stop failed')); + } finally { + await session.stopAsync().catch(() => {}); + } + expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(servers.PHONE18); +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 13db743862..f60f825023 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -504,8 +504,10 @@ export function createWarmSimulatorSession({ } devices.delete(session.device.udid); } catch (error) { - onBackgroundFailure(error); - throw error; + // Keep the unused device owned for final cleanup without interrupting the job. + process.stderr.write( + `Could not release unused Simulator ${session.device.udid}; final stop will retry: ${error}\n` + ); } }) ); From fe7c627d3eee3f368c2f6a4f47709f6fbe1eadf3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:46 -0700 Subject: [PATCH 27/42] [simulator-launcher] Keep default warmup after an additional device fails --- .../src/__tests__/simulatorSession.test.ts | 44 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 34 ++++++++++---- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index dba32266fd..bee1433ccc 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1104,3 +1104,47 @@ it('keeps the claimed device alive when retiring the unused device fails, then r } expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(servers.PHONE18); }); + +it.each(['boot', 'serve-sim'])( + 'keeps the default ready after secondary %s fails and replaces it on request', + async failure => { + configureTwoDevices(); + jest.spyOn(process.stderr, 'write').mockReturnValue(true); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + let failed = false; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => { + if ( + failure === 'boot' && + args.includes('bootstatus') && + args.includes('PHONE18') && + !failed + ) { + // Both bootstatus forms must fail for the first attempt. + if (!args.includes('-b')) failed = true; + return Promise.reject(new Error('secondary boot failed')); + } + return original(file, args, signal); + }); + jest.mocked(waitForServeSimReadyAsync).mockImplementation(async udid => { + if (failure === 'serve-sim' && udid === 'PHONE18' && !failed) { + failed = true; + throw new Error('secondary server timed out'); + } + }); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => failed); + await new Promise(resolve => setImmediate(resolve)); + try { + expect(controller.signal.aborted).toBe(false); + expect(onPreparationFailure).not.toHaveBeenCalled(); + const result = await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }); + expect(result.device).toEqual({ udid: 'PHONE18', name: 'iPhone 18', port: 3200 }); + expect(ran('bootstatus', 'PHONE18').length).toBeGreaterThanOrEqual(2); + expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('PHONE18'); + } finally { + await session.stopAsync().catch(() => {}); + } + } +); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index f60f825023..3e7e05d3e4 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -214,6 +214,7 @@ type WarmDeviceSession = { device: WarmSimulatorDevice; shutdown: AbortController; ready: Promise; + failed?: boolean; serveSim?: ChildProcessHandle; expoGo?: ReturnType; }; @@ -305,8 +306,15 @@ export function createWarmSimulatorSession({ try { await Promise.all([serveSimReady, apsd]); } catch (error) { + session.failed = true; if (!session.shutdown.signal.aborted) { - onPreparationFailure(error); + if (preinstallExpoGo && device.udid !== warmDevices[0].udid) { + process.stderr.write( + `Could not warm additional Simulator ${device.udid}; a claim will start it normally: ${error}\n` + ); + } else { + onPreparationFailure(error); + } } await Promise.allSettled([boot, apsd, serveSimReady, servicesAllowed]); throw error; @@ -348,9 +356,15 @@ export function createWarmSimulatorSession({ const results = await Promise.allSettled( previousDevices.map(async session => { await session.expoGo?.stopAsync(); - await shutdownSimulatorAsync(session.device.udid); - ownedSimulatorUdids.delete(session.device.udid); - devices.delete(session.device.udid); + // A failed warm device can itself be the replacement target. Do not shut it down + // underneath its new startup, or remove the new session stored under the same UDID. + if (session.device.udid !== device.udid) { + await shutdownSimulatorAsync(session.device.udid); + ownedSimulatorUdids.delete(session.device.udid); + } + if (devices.get(session.device.udid) === session) { + devices.delete(session.device.udid); + } }) ); const failed = results.find(result => result.status === 'rejected'); @@ -459,11 +473,13 @@ export function createWarmSimulatorSession({ }) ); await throwIfStoppingAsync(); - const matches = [...devices.values()].filter(session => - !request.requestedDevice - ? session.device.udid === currentDevice.udid - : session.device.name === request.requestedDevice || - session.device.udid.toLowerCase() === request.requestedDevice.toLowerCase() + const matches = [...devices.values()].filter( + session => + !session.failed && + (!request.requestedDevice + ? session.device.udid === currentDevice.udid + : session.device.name === request.requestedDevice || + session.device.udid.toLowerCase() === request.requestedDevice.toLowerCase()) ); if (matches.length > 1) { throw new Error( From 29fd654fe6c76cebc3349c613369408cc0f208f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:47 -0700 Subject: [PATCH 28/42] [simulator-launcher] Keep the default device for blank configuration --- packages/simulator-launcher/README.md | 3 ++- packages/simulator-launcher/src/__tests__/simulator.test.ts | 5 ++++- packages/simulator-launcher/src/utils/simulator.ts | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index a8e738507a..bc05eae0d6 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -40,7 +40,8 @@ serializes updates; an abandoned lock fails closed. This private format requires - `EXPO_SIMULATOR_STARTUP=1` enables warmup. - `EXPO_SIMULATOR_DEVICE` is a comma-separated list of exact device names or UDIDs. Entries are trimmed and must each identify one available device. The first is the default. Unset keeps - the shortest available iPhone name; empty, missing, ambiguous or duplicate entries fail clearly. + the shortest available iPhone name, as does an empty or blank value. Empty list entries, missing, + ambiguous or duplicate devices fail clearly. - `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: each defaults to `latest`. - `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations. - Agent-device idle timers default to off. Override with `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS` diff --git a/packages/simulator-launcher/src/__tests__/simulator.test.ts b/packages/simulator-launcher/src/__tests__/simulator.test.ts index 634300f239..cfed6a82c2 100644 --- a/packages/simulator-launcher/src/__tests__/simulator.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulator.test.ts @@ -12,7 +12,7 @@ test('keeps the shortest available iPhone as the default when unset', () => { test('splits and trims a list, preserving its default and accepting case-insensitive UDIDs', () => { expect(selectWarmupSimulators(devices, ' iPhone 18 , duo ')).toEqual([devices[1], devices[0]]); }); -test.each(['', 'iPhone Duo,', ',iPhone 18', 'iPhone Duo, ,iPhone 18'])( +test.each(['iPhone Duo,', ',iPhone 18', 'iPhone Duo, ,iPhone 18'])( 'rejects an empty entry in %j', selectors => { expect(() => selectWarmupSimulators(devices, selectors)).toThrow('nonempty'); @@ -27,3 +27,6 @@ test('rejects a missing or ambiguous device instead of choosing arbitrarily', () test('rejects two selectors for the same device', () => { expect(() => selectWarmupSimulators(devices, 'iPhone Duo,duo')).toThrow('more than once'); }); +test.each(['', ' ', '\n'])('keeps the default iPhone for blank configuration %j', selectors => { + expect(selectWarmupSimulators(devices, selectors)).toEqual([devices[1]]); +}); diff --git a/packages/simulator-launcher/src/utils/simulator.ts b/packages/simulator-launcher/src/utils/simulator.ts index 9e2252ac08..d9327cacef 100644 --- a/packages/simulator-launcher/src/utils/simulator.ts +++ b/packages/simulator-launcher/src/utils/simulator.ts @@ -43,8 +43,9 @@ export function selectWarmupSimulators( devices: SimulatorDevice[], selectors?: string ): SimulatorDevice[] { - const entries = - selectors === undefined ? [undefined] : selectors.split(',').map(value => value.trim()); + const entries = !selectors?.trim() + ? [undefined] + : selectors.split(',').map(value => value.trim()); const selected = new Set(); return entries.map(selector => { if (selector === '') { From 28f4e83da37e621910decb60321ebb3d59d1c32b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:47 -0700 Subject: [PATCH 29/42] [simulator-launcher] Bound cache priming and retry once --- .../src/__tests__/simulatorSession.test.ts | 41 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 30 ++++++++------ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index bee1433ccc..3be8495600 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1148,3 +1148,44 @@ it.each(['boot', 'serve-sim'])( } } ); +it('bounds cache priming to two 30-second attempts and still becomes ready after both time out', async () => { + const attempts = [new AbortController(), new AbortController()]; + const realTimeout = AbortSignal.timeout; + const timeoutSpy = jest + .spyOn(AbortSignal, 'timeout') + .mockImplementation(ms => (ms === 30_000 ? attempts.shift()!.signal : realTimeout(ms))); + const first = attempts[0], + second = attempts[1]; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + args.includes('--version') + ? new Promise((_resolve, reject) => + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + ) + : original(file, args, signal) + ); + const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + const session = createSession(); + const preparing = session.prepareAsync(); + void preparing.catch(() => {}); + try { + await until(() => ran('--version').length === 1); + expect(timeoutSpy).toHaveBeenCalledWith(30_000); + first.abort(new DOMException('prime timed out', 'TimeoutError')); + await until(() => ran('--version').length === 2); + second.abort(new DOMException('prime timed out', 'TimeoutError')); + expect((await preparing).device.udid).toBe('PHONE'); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('claim/stop may need the registry') + ); + } finally { + first.abort(); + second.abort(); + controller.abort(); + await preparing.catch(() => {}); + await session.stopAsync(); + timeoutSpy.mockRestore(); + } +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 3e7e05d3e4..3a2433d5fb 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -426,22 +426,26 @@ export function createWarmSimulatorSession({ ); } const first = startDevice(warmDevices[0], warmTools, /* preinstallExpoGo */ true); - launcherCachePriming = commands - .runAsync( - 'bun', - ['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'], - AbortSignal.any([signal, first.shutdown.signal]) - ) - .then( - () => {}, - error => { + launcherCachePriming = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await commands.runAsync( + 'bun', + ['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'], + AbortSignal.any([signal, first.shutdown.signal, AbortSignal.timeout(30_000)]) + ); + return; + } catch (error) { signal.throwIfAborted(); first.shutdown.signal.throwIfAborted(); - process.stderr.write( - `Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n` - ); + if (attempt === 1) { + process.stderr.write( + `Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n` + ); + } } - ); + } + })(); await Promise.all([first.ready, launcherCachePriming]); currentDevice = first.device; return { From c1aad5991fe17fc2f7b20302a26e0b3756ff4b8b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:47 -0700 Subject: [PATCH 30/42] [simulator-launcher] Shut owned devices down in parallel at terminal stop --- .../src/__tests__/simulatorSession.test.ts | 24 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 4 +--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 3be8495600..f58113db8e 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1189,3 +1189,27 @@ it('bounds cache priming to two 30-second attempts and still becomes ready after timeoutSpy.mockRestore(); } }); +it('shuts owned devices down in parallel during terminal stop', async () => { + configureTwoDevices(); + const duo = deferred(), + other = deferred(); + jest + .mocked(shutdownSimulatorAsync) + .mockImplementation(udid => (udid === 'DUO' ? duo.promise : other.promise)); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + const stopping = session.stopAsync(); + try { + await until(() => jest.mocked(shutdownSimulatorAsync).mock.calls.length > 0); + expect(jest.mocked(shutdownSimulatorAsync).mock.calls.map(([udid]) => udid)).toEqual([ + 'DUO', + 'PHONE18', + ]); + } finally { + duo.resolve(); + other.resolve(); + await stopping; + } +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 3a2433d5fb..235ee742a1 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -395,9 +395,7 @@ export function createWarmSimulatorSession({ await attempt(tool.stopAsync()); } } - for (const udid of ownedSimulatorUdids) { - await attempt(shutdownSimulatorAsync(udid)); - } + await Promise.all([...ownedSimulatorUdids].map(udid => attempt(shutdownSimulatorAsync(udid)))); try { commands.throwIfCommandCloseTimedOut(); } catch (error) { From bf44a35e4980aacedf88dc5555d4e136d3019265 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:47 -0700 Subject: [PATCH 31/42] [simulator-launcher] Finish default warmup before sharing Expo Go with another device --- .changeset/simulator-launcher.md | 2 +- packages/simulator-launcher/README.md | 12 +- .../src/__tests__/expoGo.test.ts | 72 ++++++++ .../src/__tests__/main.test.ts | 2 + .../src/__tests__/simulatorSession.test.ts | 141 ++++++++++++--- .../simulator-launcher/src/commands/start.ts | 69 +++++++- .../simulator-launcher/src/utils/expoGo.ts | 163 +++++++++++------- 7 files changed, 364 insertions(+), 97 deletions(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index d78956d291..bf22565fa6 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -4,7 +4,7 @@ Add `@expo/simulator-launcher` for warm Simulators: `start` runs warmup at VM boot, `claim` hands the warm Simulator to a job, and `stop` ends warmup. -Warmup starts the first configured device, then warms the other devices after the first is ready. Each gets its own serve-sim port, apsd disable attempt and best-effort Expo Go preinstall. Agent-device and one Argent server prestart with the default device. +Warmup starts the first configured device, then warms the other devices after the first is ready and its tools and Expo Go preinstall finish. Each gets its own serve-sim port, apsd disable attempt and best-effort Expo Go preinstall. Agent-device and one Argent server prestart with the default device. A claim waits only for the requested engine and stops the other tools, also while they are still starting. The claim record names the preinstalled Expo Go, so the job can skip a matching install. A claim waits for an Expo Go install that already started and prevents a later one. diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index bc05eae0d6..4c089a4f02 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -12,7 +12,8 @@ exec bunx @expo/simulator-launcher@latest start ## Commands -- `start`: warm the default device in the foreground. After it is ready, warm the remaining devices. +- `start`: warm the default device in the foreground. Once its tools and Expo Go preinstall finish, + warm the remaining devices only while the session is still unclaimed. - `claim ENGINE [DEVICE]`: give one device to one job. Engines: `serve-sim`, `agent-device`, `argent`. No DEVICE means the first configured device. A matching warm device is reused; its device and port are returned, and the other device is stopped in the background. Otherwise the requested device boots while the old servers/tools stop. Old devices shut down @@ -26,7 +27,7 @@ bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" stop ``` There is no copied launcher bundle. Build-tools uses the recorded package version for claim/stop; -Warmup primes Bun’s exact-version cache before ready. Claim/stop use `--no-install`, retrying +Warmup primes Bun’s exact-version cache before ready, with two attempts of up to 30 seconds. Claim/stop use `--no-install`, retrying once with the registry only when Bun cannot find that cached binary. Priming failures warn. Before start has run, stop uses the normal package-manager path and still disables later warmup for this boot. @@ -50,13 +51,16 @@ serializes updates; an abandoned lock fails closed. This private format requires ## Boundaries - Ready means the default device booted, its apsd disable attempt finished and serve-sim answers. - The second device may still be warming. Claim waits for the selected device; job-time native, + The second device may not have started yet. Its startup failure leaves the default available. + Claim waits for the selected device; job-time native, version and authentication checks plus private serve-sim adoption remain authoritative. - Agent-device prestarts on the default device only. A secondary claim prepares its runner after the old tools stop. One device-independent Argent server is reused for the claimed device. -- Warmup tries to preinstall the latest stable Expo Go on each initial device; ordinary failures +- Warmup downloads/extracts the latest stable Expo Go once and reuses it for the second device. + Each initial device gets a preinstall attempt; ordinary failures warn. Claim prevents new preinstalls and waits for a selected-device native install already in flight. Unused-device preinstalls drain during background cleanup. Job app installs remain authoritative. Replacements do not preinstall again. The VM image provides ffmpeg. - One start attempt and one accepted claim per boot. Failed cleanup never permits another session. Stop's caller timeout is not a global cleanup deadline or proof that teardown completed. + A build-tools claim timeout uses normal startup only after confirmed terminal cleanup. diff --git a/packages/simulator-launcher/src/__tests__/expoGo.test.ts b/packages/simulator-launcher/src/__tests__/expoGo.test.ts index a1ed068b10..68126513c0 100644 --- a/packages/simulator-launcher/src/__tests__/expoGo.test.ts +++ b/packages/simulator-launcher/src/__tests__/expoGo.test.ts @@ -425,3 +425,75 @@ it('claim returns no identity when the preinstall fails', async () => { expect(await warmup.claimAsync()).toBeUndefined(); expect(jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'plutil')).toBe(false); }); + +it('downloads and extracts once for two devices, then removes the shared app after both installs', async () => { + warmup = startExpoGoPreinstall( + 'FIRST', + Promise.resolve(), + controller.signal, + /* reuseArchive */ true + ); + await warmup.resultPromise; + const app = installCalls()[0][1][3]; + expect((await fs.stat(app)).isDirectory()).toBe(true); + await warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal); + expect(fetch).toHaveBeenCalledTimes(2); + expect( + jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === '/usr/bin/tar') + ).toHaveLength(1); + expect(installCalls().map(([, args]) => args.slice(2))).toEqual([ + ['FIRST', app], + ['SECOND', app], + ]); + await expect(fs.stat(app)).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await warmup.claimAsync('SECOND'))?.udid).toBe('SECOND'); +}); +it('a claim prevents secondary preinstall from starting and releases the retained archive', async () => { + warmup = startExpoGoPreinstall( + 'FIRST', + Promise.resolve(), + controller.signal, + /* reuseArchive */ true + ); + await warmup.resultPromise; + await warmup.claimAsync(); + await warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal); + await warmup.stopAsync(); + expect(installCalls()).toHaveLength(1); + expect(await fs.readdir(directory)).toEqual([]); +}); +it('claim and stop drain a secondary native install without cancelling its transaction', async () => { + const install = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + warmup = startExpoGoPreinstall( + 'FIRST', + Promise.resolve(), + controller.signal, + /* reuseArchive */ true + ); + await warmup.resultPromise; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + file === 'xcrun' && args.includes('SECOND') ? install.promise : original(file, args, signal) + ); + const secondary = warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal); + let claimed = false; + try { + await until(() => installCalls().length === 2); + const claiming = warmup.claimAsync('SECOND').then(() => { + claimed = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(claimed).toBe(false); + expect(installCalls()[1][2]).toBeUndefined(); + install.resolve(''); + await claiming; + await secondary; + await warmup.stopAsync(); + expect(await fs.readdir(directory)).toEqual([]); + } finally { + install.resolve(''); + await secondary; + } +}); diff --git a/packages/simulator-launcher/src/__tests__/main.test.ts b/packages/simulator-launcher/src/__tests__/main.test.ts index fb37c2dcf5..eed3a22237 100644 --- a/packages/simulator-launcher/src/__tests__/main.test.ts +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -88,6 +88,7 @@ beforeEach(async () => { .mockReset() .mockReturnValue({ resultPromise: Promise.resolve(), + preinstallOnDeviceAsync: jest.fn(async () => {}), claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(async () => {}), }); @@ -422,6 +423,7 @@ test('claim joins an already-started Expo Go install and terminal stop drains th const cleanup = deferred(); jest.mocked(startExpoGoPreinstall).mockReturnValue({ resultPromise: cleanup.promise, + preinstallOnDeviceAsync: jest.fn(async () => {}), claimAsync: jest.fn(() => install.promise), stopAsync: jest.fn(() => cleanup.promise), }); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index f58113db8e..2bf35f9a3c 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -47,6 +47,7 @@ let onBackgroundFailure: jest.Mock; function createSession() { return createWarmSimulatorSession({ signal: controller.signal, + isReadyAsync: async () => true, throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), onPreparationFailure, onBackgroundFailure, @@ -77,6 +78,7 @@ beforeEach(() => { jest.mocked(shutdownSimulatorAsync).mockResolvedValue(); jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue(); jest.mocked(startExpoGoPreinstall).mockReturnValue({ + preinstallOnDeviceAsync: jest.fn(async () => {}), claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(async () => {}), resultPromise: Promise.resolve(), @@ -404,6 +406,7 @@ it('drains unused tools and the whole Expo Go task before native cleanup, retain const expoCleanup = deferred(); const failure = new Error('Argent stop failed'); const preinstall = { + preinstallOnDeviceAsync: jest.fn(async () => {}), claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(() => expoCleanup.promise), resultPromise: expoCleanup.promise, @@ -596,6 +599,7 @@ it('reports the preinstalled Expo Go only for the device it was installed on', a ); for (const requestedDevice of [undefined, 'PRO']) { jest.mocked(startExpoGoPreinstall).mockReturnValueOnce({ + preinstallOnDeviceAsync: jest.fn(async () => {}), claimAsync: jest.fn(async () => preinstalledExpoGo), stopAsync: jest.fn(async () => {}), resultPromise: Promise.resolve(), @@ -669,7 +673,7 @@ it('warms additional devices only after the default is ready, with separate port session.startRemainingDevices(); try { await until(() => jest.mocked(startServeSim).mock.calls.length === 2); - expect(startExpoGoPreinstall).toHaveBeenCalledTimes(2); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); expect(startServeSim).toHaveBeenCalledWith('PHONE18', expect.any(AbortSignal), 3202); expect(started('prepare')).toHaveLength(1); expect(started('prepare')[0][1]).toContain('DUO'); @@ -747,27 +751,20 @@ it('returns the default while unused-device Expo Go cleanup is pending, but term configureTwoDevices(); const draining = deferred(); const defaultPreinstall = { + preinstallOnDeviceAsync: jest.fn(async () => {}), claimAsync: jest.fn(async () => undefined), stopAsync: jest.fn(async () => {}), resultPromise: Promise.resolve(), }; - const secondPreinstall = { - claimAsync: jest.fn(async () => undefined), - stopAsync: jest.fn(() => draining.promise), - resultPromise: Promise.resolve(), - }; - jest - .mocked(startExpoGoPreinstall) - .mockReturnValueOnce(defaultPreinstall) - .mockReturnValueOnce(secondPreinstall); + defaultPreinstall.stopAsync.mockImplementation(() => draining.promise); + jest.mocked(startExpoGoPreinstall).mockReturnValueOnce(defaultPreinstall); const session = createSession(); await session.prepareAsync(); session.startRemainingDevices(); - await until(() => jest.mocked(startExpoGoPreinstall).mock.calls.length === 2); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); try { expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); - expect(defaultPreinstall.claimAsync).toHaveBeenCalledTimes(1); - expect(secondPreinstall.claimAsync).toHaveBeenCalledTimes(1); + expect(defaultPreinstall.claimAsync).toHaveBeenCalledTimes(2); let stopped = false; const stopping = session.stopAsync().then(() => { stopped = true; @@ -791,6 +788,7 @@ it('waits for the selected secondary device before returning its port', async () const session = createSession(); await session.prepareAsync(); session.startRemainingDevices(); + await until(() => ran('bootstatus', 'PHONE18').length === 1); let claimed = false; const claiming = session .claimAsync({ engine: 'serve-sim', requestedDevice: 'iPhone 18' }) @@ -878,7 +876,7 @@ it('boots an unlisted replacement while both old servers stop, and drains both o expect(startServeSim).toHaveBeenCalledTimes(2); release18.resolve(); expect((await claiming).device).toEqual({ udid: 'OTHER', name: 'iPhone Other', port: 3200 }); - expect(startExpoGoPreinstall).toHaveBeenCalledTimes(2); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); let stopped = false; const stopping = session.stopAsync().then(() => { stopped = true; @@ -949,17 +947,21 @@ it('a secondary startup paused before claim cannot create a late Expo Go install let checks = 0; const session = createWarmSimulatorSession({ signal: controller.signal, - onPreparationFailure, - onBackgroundFailure, - throwIfStoppingAsync: async () => { + isReadyAsync: async () => { if (++checks === 2) { await release.promise; } + return true; + }, + onPreparationFailure, + onBackgroundFailure, + throwIfStoppingAsync: async () => { controller.signal.throwIfAborted(); }, }); await session.prepareAsync(); session.startRemainingDevices(); + await until(() => checks === 2); const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }); try { expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); @@ -978,17 +980,21 @@ it('does not shut down an unused secondary whose boot never started', async () = let checks = 0; const session = createWarmSimulatorSession({ signal: controller.signal, - onPreparationFailure, - onBackgroundFailure, - throwIfStoppingAsync: async () => { + isReadyAsync: async () => { if (++checks === 2) { await release.promise; } + return true; + }, + onPreparationFailure, + onBackgroundFailure, + throwIfStoppingAsync: async () => { controller.signal.throwIfAborted(); }, }); await session.prepareAsync(); session.startRemainingDevices(); + await until(() => checks === 2); try { expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); release.resolve(); @@ -1060,17 +1066,18 @@ it('returns only the selected device preinstall metadata', async () => { jest.mocked(startExpoGoPreinstall).mockImplementation(udid => ({ resultPromise: Promise.resolve(), stopAsync: jest.fn(async () => {}), - claimAsync: jest.fn(async () => ({ - udid, + preinstallOnDeviceAsync: jest.fn(async () => {}), + claimAsync: jest.fn(async (selectedUdid = udid) => ({ + udid: selectedUdid, bundleIdentifier: 'host.exp.Exponent', - shortVersion: udid === 'DUO' ? '58.0.5' : '58.0.6', + shortVersion: selectedUdid === 'DUO' ? '58.0.5' : '58.0.6', version: '1', })), })); const session = createSession(); await session.prepareAsync(); session.startRemainingDevices(); - await until(() => jest.mocked(startExpoGoPreinstall).mock.calls.length === 2); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); const result = await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }); expect(result.preinstalledExpoGo).toEqual({ udid: 'PHONE18', @@ -1100,7 +1107,7 @@ it('keeps the claimed device alive when retiring the unused device fails, then r expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('DUO'); expect(warning).toHaveBeenCalledWith(expect.stringContaining('unused server stop failed')); } finally { - await session.stopAsync().catch(() => {}); + await session.stopAsync(); } expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(servers.PHONE18); }); @@ -1120,7 +1127,9 @@ it.each(['boot', 'serve-sim'])( !failed ) { // Both bootstatus forms must fail for the first attempt. - if (!args.includes('-b')) failed = true; + if (!args.includes('-b')) { + failed = true; + } return Promise.reject(new Error('secondary boot failed')); } return original(file, args, signal); @@ -1144,7 +1153,7 @@ it.each(['boot', 'serve-sim'])( expect(ran('bootstatus', 'PHONE18').length).toBeGreaterThanOrEqual(2); expect(shutdownSimulatorAsync).not.toHaveBeenCalledWith('PHONE18'); } finally { - await session.stopAsync().catch(() => {}); + await session.stopAsync(); } } ); @@ -1213,3 +1222,81 @@ it('shuts owned devices down in parallel during terminal stop', async () => { await stopping; } }); +it('defers secondary work until default tools and Expo Go finish, and reuses the default preinstall', async () => { + configureTwoDevices(); + const expo = deferred(), + runner = deferred(); + const defaultInstall = Object.assign( + { + resultPromise: expo.promise, + preinstallOnDeviceAsync: jest.fn(async () => {}), + claimAsync: jest.fn(async () => undefined), + stopAsync: jest.fn(async () => {}), + }, + { preinstallOnDeviceAsync: jest.fn(async () => {}) } + ); + jest.mocked(startExpoGoPreinstall).mockReturnValue(defaultInstall); + jest + .mocked(commands.start) + .mockImplementation((_file, args) => + args.includes('prepare') ? { child: agent.child, resultPromise: runner.promise } : argent + ); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(startServeSim).toHaveBeenCalledTimes(1); + expo.resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(startServeSim).toHaveBeenCalledTimes(1); + runner.resolve(''); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + expect(startExpoGoPreinstall).toHaveBeenCalledTimes(1); + expect(defaultInstall.preinstallOnDeviceAsync).toHaveBeenCalledWith( + 'PHONE18', + expect.any(Promise), + expect.any(AbortSignal) + ); + } finally { + expo.resolve(); + runner.resolve(''); + await session.stopAsync(); + } +}); +it('does not start a secondary device when the durable session is no longer ready', async () => { + configureTwoDevices(); + const session = createWarmSimulatorSession({ + signal: controller.signal, + onPreparationFailure, + onBackgroundFailure, + throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), + isReadyAsync: async () => false, + }); + await session.prepareAsync(); + session.startRemainingDevices(); + await new Promise(resolve => setImmediate(resolve)); + expect(ran('bootstatus', 'PHONE18')).toHaveLength(0); + expect(startServeSim).toHaveBeenCalledTimes(1); + await session.stopAsync(); +}); +it('a claim while default warm work is pending prevents any later secondary boot', async () => { + configureTwoDevices(); + const finish = deferred(); + const preinstall = jest.mocked(startExpoGoPreinstall).getMockImplementation()!; + jest + .mocked(startExpoGoPreinstall) + .mockImplementation((...args) => ({ ...preinstall(...args), resultPromise: finish.promise })); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + try { + expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); + finish.resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(ran('bootstatus', 'PHONE18')).toHaveLength(0); + } finally { + finish.resolve(); + await session.stopAsync(); + } +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 235ee742a1..ab2f51e11b 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -104,6 +104,8 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise + requireOwnedState(await readLauncherStateAsync(bootUuid)).status === 'ready', onPreparationFailure(error) { if (!signal.aborted) { sessionError = error; @@ -223,11 +225,13 @@ type WarmDeviceSession = { export function createWarmSimulatorSession({ signal, throwIfStoppingAsync, + isReadyAsync, onPreparationFailure, onBackgroundFailure, }: { signal: AbortSignal; throwIfStoppingAsync(): Promise; + isReadyAsync(): Promise; onPreparationFailure(error: unknown): void; onBackgroundFailure(error: unknown): void; }): WarmSimulatorSession { @@ -245,6 +249,7 @@ export function createWarmSimulatorSession({ let unusedDevicesCleanup: Promise[]> = Promise.resolve([]); let previousDeviceShutdown: Promise = Promise.resolve(); let launcherCachePriming: Promise = Promise.resolve(); + let additionalWarmup: Promise | undefined; async function resolveDeviceAsync( selector?: string, @@ -280,13 +285,39 @@ export function createWarmSimulatorSession({ session.ready = (async () => { await throwIfStoppingAsync(); const deviceSignal = AbortSignal.any([signal, session.shutdown.signal]); + const isAdditional = preinstallExpoGo && device.udid !== warmDevices[0].udid; + if (isAdditional && (claimed || stopping || !(await isReadyAsync()))) { + return; + } deviceSignal.throwIfAborted(); const udid = device.udid; ownedSimulatorUdids.add(udid); const boot = bootSimulatorAsync(udid, deviceSignal); if (preinstallExpoGo && !claimed && !stopping) { - session.expoGo = startExpoGoPreinstall(udid, boot, deviceSignal); - void session.expoGo.resultPromise.catch(onBackgroundFailure); + const shared = devices.get(warmDevices[0].udid)?.expoGo; + if (isAdditional && shared) { + session.expoGo = { + ...shared, + resultPromise: shared.preinstallOnDeviceAsync(udid, boot, deviceSignal), + async claimAsync() { + return await shared.claimAsync(udid); + }, + }; + void session.expoGo.resultPromise.catch(error => { + session.failed = true; + process.stderr.write( + `Expo Go preinstall on unused Simulator ${udid} failed; final stop will drain it: ${error}\n` + ); + }); + } else { + session.expoGo = startExpoGoPreinstall( + udid, + boot, + deviceSignal, + /* reuseArchive */ warmDevices.length > 1 + ); + void session.expoGo.resultPromise.catch(onBackgroundFailure); + } } const serveSimReady = Promise.all([boot, servicesAllowed]).then(async () => { const handle = startServeSim(udid, deviceSignal, device.port); @@ -456,14 +487,29 @@ export function createWarmSimulatorSession({ }; }, startRemainingDevices(): void { - if (claimed || stopping) { + if (claimed || stopping || additionalWarmup) { return; } - for (const device of warmDevices.slice(1)) { - if (!devices.has(device.udid)) { - startDevice(device, [], /* preinstallExpoGo */ true); + additionalWarmup = (async () => { + const first = devices.get(warmDevices[0].udid)!; + // Keep second-device work out of the default Expo Go and tool startup critical path. + await Promise.all([ + first.expoGo?.resultPromise, + ...[...startedTools.values()].map(tool => tool.ready), + ]); + if (claimed || stopping || !(await isReadyAsync())) { + return; } - } + for (const device of warmDevices.slice(1)) { + if (!devices.has(device.udid)) { + startDevice(device, [], /* preinstallExpoGo */ true); + } + } + })().catch(error => { + if (!claimed && !stopping && !signal.aborted) { + onPreparationFailure(error); + } + }); }, async claimAsync(request: ClaimInput) { claimed = true; @@ -475,6 +521,14 @@ export function createWarmSimulatorSession({ }) ); await throwIfStoppingAsync(); + if ( + request.requestedDevice && + selectSimulators(availableDevices, request.requestedDevice).length > 1 + ) { + throw new Error( + `More than one warm Simulator matches ${request.requestedDevice}. Use an exact UDID.` + ); + } const matches = [...devices.values()].filter( session => !session.failed && @@ -563,6 +617,7 @@ export function createWarmSimulatorSession({ session.shutdown.abort(); } await Promise.all(cancellingTools); + await additionalWarmup; const [unusedToolsResults, unusedDeviceResults, expoGoResults] = await Promise.all([ unusedToolsCleanup, unusedDevicesCleanup, diff --git a/packages/simulator-launcher/src/utils/expoGo.ts b/packages/simulator-launcher/src/utils/expoGo.ts index d3b0ac3f66..b5de8e48d3 100644 --- a/packages/simulator-launcher/src/utils/expoGo.ts +++ b/packages/simulator-launcher/src/utils/expoGo.ts @@ -21,14 +21,15 @@ const VersionsSchema = z.object({ ), }); -/** Warm only this initial device. Claim prevents new installs; a started install must finish. */ +/** Claim forbids later installs; started native installs and the shared archive stay owned. */ export function startExpoGoPreinstall( udid: string, boot: Promise, - signal: AbortSignal + signal: AbortSignal, + reuseArchive = false ): { - /** Resolves with the installed Expo Go, or undefined when the preinstall did not finish. */ - claimAsync(): Promise; + claimAsync(selectedUdid?: string): Promise; + preinstallOnDeviceAsync(udid: string, boot: Promise, signal: AbortSignal): Promise; stopAsync(): Promise; resultPromise: Promise; } { @@ -38,55 +39,73 @@ export function startExpoGoPreinstall( downloadController.signal, AbortSignal.timeout(120_000), ]); - let installation: Promise | undefined; - let installed: PreinstalledApp | undefined; + const directory = fs.mkdtemp(path.join(os.tmpdir(), 'simulator-expo-go-')); + const installations = new Map>(); + const installed = new Map(); + const operations = new Set>(); let failure: unknown; - void boot.catch(() => {}); - const warmup = (async () => { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'simulator-expo-go-')); + let cleanup: Promise | undefined; + const archive = (async () => { + const directoryPath = await directory; + const response = await fetch(VERSIONS_URL, { headers: HEADERS, signal: downloadSignal }); + if (!response.ok) { + throw new Error(`Expo Go versions returned HTTP ${response.status}.`); + } + const { sdkVersions } = VersionsSchema.parse(await response.json()); + // Same latest stable release policy as expo.dev/go (GoScene/utils.ts). + const sdk = Object.keys(sdkVersions) + .filter(sdk => /\b(\d*)\.0\.0/.test(sdk)) + .slice(-10) + .reverse() + .find(sdk => sdkVersions[sdk].releaseNoteUrl && !sdkVersions[sdk].isBeta); + const url = sdk && sdkVersions[sdk].iosClientUrl; + if (!url || new URL(url).protocol !== 'https:') { + throw new Error('No stable Expo Go Simulator archive is available.'); + } + const archive = await fetch(url, { headers: HEADERS, signal: downloadSignal }); + if (!archive.ok || !archive.body) { + throw new Error(`Expo Go archive returned HTTP ${archive.status}.`); + } + const archivePath = path.join(directoryPath, 'expo-go.tar.gz'); + await archive.body.pipeTo(Writable.toWeb(createWriteStream(archivePath)), { + signal: downloadSignal, + }); + downloadSignal.throwIfAborted(); + // Wait for the owned extractor to close, including on bad archives and claim/stop. + const appPath = path.join(directoryPath, 'ExpoGo.app'); + await fs.mkdir(appPath); + await commands.runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]); + downloadSignal.throwIfAborted(); + await fs.access(path.join(appPath, 'Info.plist')); + + return { appPath, sdk }; + })(); + void archive.catch(() => {}); + + async function preinstallAsync( + target: string, + boot: Promise, + deviceSignal: AbortSignal + ): Promise { + void boot.catch(() => {}); + let installation: Promise | undefined; + const installSignal = AbortSignal.any([signal, deviceSignal, downloadController.signal]); try { - const response = await fetch(VERSIONS_URL, { headers: HEADERS, signal: downloadSignal }); - if (!response.ok) { - throw new Error(`Expo Go versions returned HTTP ${response.status}.`); - } - const { sdkVersions } = VersionsSchema.parse(await response.json()); - // Same latest stable release policy as expo.dev/go (GoScene/utils.ts). - const sdk = Object.keys(sdkVersions) - .filter(sdk => /\b(\d*)\.0\.0/.test(sdk)) - .slice(-10) - .reverse() - .find(sdk => sdkVersions[sdk].releaseNoteUrl && !sdkVersions[sdk].isBeta); - const url = sdk && sdkVersions[sdk].iosClientUrl; - if (!url || new URL(url).protocol !== 'https:') { - throw new Error('No stable Expo Go Simulator archive is available.'); - } - const archive = await fetch(url, { headers: HEADERS, signal: downloadSignal }); - if (!archive.ok || !archive.body) { - throw new Error(`Expo Go archive returned HTTP ${archive.status}.`); - } - const archivePath = path.join(directory, 'expo-go.tar.gz'); - await archive.body.pipeTo(Writable.toWeb(createWriteStream(archivePath)), { - signal: downloadSignal, - }); - downloadSignal.throwIfAborted(); - // Wait for the owned extractor to close, including on bad archives and claim/stop. - const appPath = path.join(directory, 'ExpoGo.app'); - await fs.mkdir(appPath); - await commands.runAsync('/usr/bin/tar', ['-xzf', archivePath, '-C', appPath]); - downloadSignal.throwIfAborted(); - await fs.access(path.join(appPath, 'Info.plist')); + const { appPath, sdk } = await archive; await boot; - downloadSignal.throwIfAborted(); - // No await between this latch and launch. Claim cannot race a not-yet-started install. - process.stderr.write(`Preinstalling Expo Go for SDK ${sdk} on Simulator ${udid}.\n`); - installation = commands.runAsync('xcrun', ['simctl', 'install', udid, appPath]).then( + installSignal.throwIfAborted(); + // No await between checking the claim latch and registering the native install. + process.stderr.write(`Preinstalling Expo Go for SDK ${sdk} on Simulator ${target}.\n`); + installation = commands.runAsync('xcrun', ['simctl', 'install', target, appPath]).then( async () => { process.stderr.write('Expo Go preinstall finished.\n'); - // The job compares its app with this record instead of asking the Simulator. - installed = await readPreinstalledAppAsync(udid, appPath).catch(error => { + const info = await readPreinstalledAppAsync(target, appPath).catch(error => { process.stderr.write(`Could not read the preinstalled Expo Go version: ${error}\n`); return undefined; }); + if (info) { + installed.set(target, info); + } }, error => { commands.throwIfCommandCloseTimedOut(); @@ -98,41 +117,69 @@ export function startExpoGoPreinstall( ); } ); + installations.set(target, installation); await installation; } catch (error) { commands.throwIfCommandCloseTimedOut(); - // A signal exit is not evidence that the native installation transaction finished. if (installation) { throw error; } - if (!signal.aborted && !downloadController.signal.aborted) { + if (!installSignal.aborted) { process.stderr.write( `Could not preinstall Expo Go; the job will install its requested app: ${error}\n` ); } - } finally { - // A rejected command with unproved close must not race removal or permit a stopped ack. - commands.throwIfCommandCloseTimedOut(); - await fs.rm(directory, { recursive: true, force: true }); } - })(); - void warmup.catch(error => { + } + + async function cleanUpAsync(): Promise { + return await (cleanup ??= (async () => { + const results = await Promise.allSettled([...operations]); + await archive.catch(() => {}); + // Unproved close never permits temporary-file removal or a stopped acknowledgement. + commands.throwIfCommandCloseTimedOut(); + await fs.rm(await directory, { recursive: true, force: true }); + const rejected = results.find(result => result.status === 'rejected'); + if (rejected?.status === 'rejected') { + throw rejected.reason; + } + })()); + } + const initial = preinstallAsync(udid, boot, downloadSignal); + operations.add(initial); + const resultPromise = reuseArchive ? initial : initial.finally(cleanUpAsync); + void resultPromise.catch(error => { failure = error; }); return { - resultPromise: warmup, - async claimAsync() { + resultPromise, + async preinstallOnDeviceAsync(target, boot, deviceSignal) { + if (downloadController.signal.aborted || cleanup) { + return Promise.resolve(); + } + const operation = preinstallAsync(target, boot, deviceSignal); + operations.add(operation); + const complete = operation.finally(cleanUpAsync); + void complete.catch(error => { + failure = error; + }); + await complete; + }, + async claimAsync(selectedUdid = udid) { downloadController.abort(); - await installation; + void cleanUpAsync().catch(error => { + failure = error; + }); + await installations.get(selectedUdid); if (failure) { throw failure; } commands.throwIfCommandCloseTimedOut(); - return installed; + return installed.get(selectedUdid); }, async stopAsync() { downloadController.abort(); - await warmup; + await cleanUpAsync(); commands.throwIfCommandCloseTimedOut(); }, }; From 6646061b8d74f36e869cb6c4e7731ccc02dbf55a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:30:47 -0700 Subject: [PATCH 32/42] [simulator-launcher] Start warm serve-sim with explicit orphan ownership --- .../src/__tests__/simulatorSession.test.ts | 12 ++++++++++++ packages/simulator-launcher/src/utils/serveSim.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 2bf35f9a3c..f27d3040b4 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1300,3 +1300,15 @@ it('a claim while default warm work is pending prevents any later secondary boot await session.stopAsync(); } }); +it('starts each warm serve-sim as an explicit orphan on its device port', () => { + const { startServeSim: launch } = + jest.requireActual('../utils/serveSim'); + launch('DUO', controller.signal, 3200); + launch('PHONE18', controller.signal, 3202); + const launches = jest.mocked(commands.start).mock.calls; + expect(launches).toHaveLength(2); + for (const [, args] of launches) { + expect(args).toContain('--orphan'); + } + expect(launches.map(([, args]) => args[args.indexOf('--port') + 1])).toEqual(['3200', '3202']); +}); diff --git a/packages/simulator-launcher/src/utils/serveSim.ts b/packages/simulator-launcher/src/utils/serveSim.ts index 3f0365d955..9338fd59ea 100644 --- a/packages/simulator-launcher/src/utils/serveSim.ts +++ b/packages/simulator-launcher/src/utils/serveSim.ts @@ -22,6 +22,7 @@ export function startServeSim(udid: string, signal: AbortSignal, port = 3200): C '--port', String(port), '--require-token', + '--orphan', '--transport', 'webrtc', '--webrtc-codec', From 3326bbd697001c760a8b3c5d4f1951b35ef2e234 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 16:19:41 -0700 Subject: [PATCH 33/42] [simulator-launcher] Start the selected Simulator before returning its claim --- .../src/__tests__/simulatorSession.test.ts | 21 +++++++++++++++++++ .../simulator-launcher/src/commands/start.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index f27d3040b4..94b2cb1c57 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1312,3 +1312,24 @@ it('starts each warm serve-sim as an explicit orphan on its device port', () => } expect(launches.map(([, args]) => args[args.indexOf('--port') + 1])).toEqual(['3200', '3202']); }); +it('a claim during the first secondary startup check replaces the resource-free reservation', async () => { + configureTwoDevices(); + const check = deferred(); + let checks = 0; + const session = createWarmSimulatorSession({ + signal:controller.signal, onPreparationFailure, onBackgroundFailure, isReadyAsync:async () => true, + throwIfStoppingAsync:async () => { if (++checks===2) await check.promise; controller.signal.throwIfAborted(); }, + }); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => checks===2); + const claiming = session.claimAsync({engine:'serve-sim',requestedDevice:'PHONE18'}); + void claiming.catch(() => {}); + try { + check.resolve(); + const result = await claiming; + expect(ran('bootstatus','PHONE18')).toHaveLength(1); + expect(startServeSim).toHaveBeenCalledWith('PHONE18',expect.any(AbortSignal),3200); + expect(result.device.port).toBe(3200); + } finally { check.resolve(); await claiming.catch(() => {}); await session.stopAsync(); } +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index ab2f51e11b..78df0a8009 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -287,6 +287,7 @@ export function createWarmSimulatorSession({ const deviceSignal = AbortSignal.any([signal, session.shutdown.signal]); const isAdditional = preinstallExpoGo && device.udid !== warmDevices[0].udid; if (isAdditional && (claimed || stopping || !(await isReadyAsync()))) { + session.failed = true; return; } deviceSignal.throwIfAborted(); @@ -531,6 +532,7 @@ export function createWarmSimulatorSession({ } const matches = [...devices.values()].filter( session => + ownedSimulatorUdids.has(session.device.udid) && !session.failed && (!request.requestedDevice ? session.device.udid === currentDevice.udid From 6e60a81821e64a400ab87850a1d1e58679171c59 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 16:19:41 -0700 Subject: [PATCH 34/42] [simulator-launcher] Keep Expo Go failures scoped to their device --- .../src/__tests__/expoGo.test.ts | 59 ++++++++++ .../src/__tests__/simulatorSession.test.ts | 107 ++++++++++++++++-- .../simulator-launcher/src/commands/start.ts | 26 ++++- .../simulator-launcher/src/utils/expoGo.ts | 40 ++++--- 4 files changed, 201 insertions(+), 31 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/expoGo.test.ts b/packages/simulator-launcher/src/__tests__/expoGo.test.ts index 68126513c0..a30ca0caf7 100644 --- a/packages/simulator-launcher/src/__tests__/expoGo.test.ts +++ b/packages/simulator-launcher/src/__tests__/expoGo.test.ts @@ -497,3 +497,62 @@ it('claim and stop drain a secondary native install without cancelling its trans await secondary; } }); + +it.each(['signal', 'close timeout'])( + 'isolates a secondary %s failure from the already-installed default', + async kind => { + warmup = startExpoGoPreinstall('FIRST', Promise.resolve(), controller.signal, true); + await warmup.resultPromise; + const failure = Object.assign(new Error('secondary install failed'), { code: null }); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => { + if (file === 'xcrun' && args.includes('SECOND')) { + if (kind === 'close timeout') { + jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => { + throw failure; + }); + } + return Promise.reject(failure); + } + return original(file, args, signal); + }); + await expect( + warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal) + ).rejects.toBe(failure); + expect((await warmup.claimAsync('FIRST'))?.udid).toBe('FIRST'); + await expect(warmup.claimAsync('SECOND')).rejects.toBe(failure); + if (kind === 'close timeout') { + await expect(warmup.stopAsync('FIRST')).rejects.toBe(failure); + } else { + await expect(warmup.stopAsync('FIRST')).resolves.toBeUndefined(); + } + await expect(warmup.stopAsync('SECOND')).rejects.toBe(failure); + } +); +it('a concurrent stop cannot miss a secondary install failure while shared cleanup drains', async () => { + warmup = startExpoGoPreinstall('FIRST', Promise.resolve(), controller.signal, true); + await warmup.resultPromise; + const installation = deferred(); + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + file === 'xcrun' && args.includes('SECOND') + ? installation.promise + : original(file, args, signal) + ); + const secondary = warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal); + void secondary.catch(() => {}); + try { + await until(() => installCalls().length === 2); + const stopping = warmup.stopAsync('SECOND'); + void stopping.catch(() => {}); + const failure = Object.assign(new Error('secondary killed'), { code: null }); + installation.reject(failure); + await expect(stopping).rejects.toBe(failure); + await expect(secondary).rejects.toBe(failure); + } finally { + installation.resolve(''); + await secondary.catch(() => {}); + } +}); diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 94b2cb1c57..d33d31e67d 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -1317,19 +1317,112 @@ it('a claim during the first secondary startup check replaces the resource-free const check = deferred(); let checks = 0; const session = createWarmSimulatorSession({ - signal:controller.signal, onPreparationFailure, onBackgroundFailure, isReadyAsync:async () => true, - throwIfStoppingAsync:async () => { if (++checks===2) await check.promise; controller.signal.throwIfAborted(); }, + signal: controller.signal, + onPreparationFailure, + onBackgroundFailure, + isReadyAsync: async () => true, + throwIfStoppingAsync: async () => { + if (++checks === 2) { + await check.promise; + } + controller.signal.throwIfAborted(); + }, }); await session.prepareAsync(); session.startRemainingDevices(); - await until(() => checks===2); - const claiming = session.claimAsync({engine:'serve-sim',requestedDevice:'PHONE18'}); + await until(() => checks === 2); + const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }); void claiming.catch(() => {}); try { check.resolve(); const result = await claiming; - expect(ran('bootstatus','PHONE18')).toHaveLength(1); - expect(startServeSim).toHaveBeenCalledWith('PHONE18',expect.any(AbortSignal),3200); + expect(ran('bootstatus', 'PHONE18')).toHaveLength(1); + expect(startServeSim).toHaveBeenCalledWith('PHONE18', expect.any(AbortSignal), 3200); expect(result.device.port).toBe(3200); - } finally { check.resolve(); await claiming.catch(() => {}); await session.stopAsync(); } + } finally { + check.resolve(); + await claiming.catch(() => {}); + await session.stopAsync(); + } +}); +it('does not abort the claimed default when unused-device preinstall claim fails', async () => { + const servers = configureTwoDevices(); + const install = jest.mocked(startExpoGoPreinstall).getMockImplementation()!; + jest.mocked(startExpoGoPreinstall).mockImplementation((...args) => ({ + ...install(...args), + claimAsync: jest.fn(async (udid = 'DUO') => { + if (udid === 'PHONE18') { + throw new Error('unused install failed'); + } + return undefined; + }), + })); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + try { + expect((await session.claimAsync({ engine: 'serve-sim' })).device.udid).toBe('DUO'); + await new Promise(resolve => setImmediate(resolve)); + expect(controller.signal.aborted).toBe(false); + expect(onBackgroundFailure).not.toHaveBeenCalled(); + expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(servers.DUO); + } finally { + await session.stopAsync(); + } +}); +it('still shuts old devices down when their preinstall cleanup rejects during replacement', async () => { + configureTwoDevices(); + const install = jest.mocked(startExpoGoPreinstall).getMockImplementation()!; + jest.mocked(startExpoGoPreinstall).mockImplementation((...args) => ({ + ...install(...args), + stopAsync: jest.fn(async () => { + throw new Error('archive cleanup failed'); + }), + })); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + try { + await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' }); + await new Promise(resolve => setImmediate(resolve)); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE18'); + } finally { + await session.stopAsync().catch(() => {}); + } +}); +it('retains failed preinstall ownership when replacement overwrites that device entry', async () => { + configureTwoDevices(); + const failure = new Error('secondary installation is unconfirmed'); + const install = jest.mocked(startExpoGoPreinstall).getMockImplementation()!; + jest.mocked(startExpoGoPreinstall).mockImplementation((...args) => ({ + ...install(...args), + preinstallOnDeviceAsync: jest.fn(async () => { + throw failure; + }), + claimAsync: jest.fn(async (udid = 'DUO') => { + if (udid === 'PHONE18') { + throw failure; + } + return undefined; + }), + stopAsync: jest.fn(async (udid = 'DUO') => { + if (udid === 'PHONE18') { + throw failure; + } + }), + })); + jest.spyOn(process.stderr, 'write').mockReturnValue(true); + const session = createSession(); + await session.prepareAsync(); + session.startRemainingDevices(); + await until(() => jest.mocked(startServeSim).mock.calls.length === 2); + await new Promise(resolve => setImmediate(resolve)); + await expect( + session.claimAsync({ engine: 'serve-sim', requestedDevice: 'PHONE18' }) + ).rejects.toBe(failure); + expect(jest.mocked(startServeSim).mock.calls.at(-1)?.[2]).toBe(3200); + await expect(session.stopAsync()).rejects.toThrow('background work could not be cleaned up'); }); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 78df0a8009..1dbe809016 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -237,6 +237,7 @@ export function createWarmSimulatorSession({ }): WarmSimulatorSession { const warmTools = [createAgentDeviceTool(), createArgentTool()]; const serveSims = new Set(); + const expoGoPreinstalls = new Set>(); const ownedSimulatorUdids = new Set(); const devices = new Map(); const startedTools = new Map(); @@ -303,6 +304,9 @@ export function createWarmSimulatorSession({ async claimAsync() { return await shared.claimAsync(udid); }, + async stopAsync() { + await shared.stopAsync(udid); + }, }; void session.expoGo.resultPromise.catch(error => { session.failed = true; @@ -320,6 +324,10 @@ export function createWarmSimulatorSession({ void session.expoGo.resultPromise.catch(onBackgroundFailure); } } + if (session.expoGo) { + // Replacing a device entry must not forget an unconfirmed native install. + expoGoPreinstalls.add(session.expoGo); + } const serveSimReady = Promise.all([boot, servicesAllowed]).then(async () => { const handle = startServeSim(udid, deviceSignal, device.port); session.serveSim = handle; @@ -387,11 +395,16 @@ export function createWarmSimulatorSession({ previousDeviceShutdown = release.then(async () => { const results = await Promise.allSettled( previousDevices.map(async session => { - await session.expoGo?.stopAsync(); - // A failed warm device can itself be the replacement target. Do not shut it down - // underneath its new startup, or remove the new session stored under the same UDID. + try { + await session.expoGo?.stopAsync(); + } finally { + // Failed preinstall cleanup must not skip an old device's shutdown. + // Never shut down the replacement when it reuses a failed warm UDID. + if (session.device.udid !== device.udid) { + await shutdownSimulatorAsync(session.device.udid); + } + } if (session.device.udid !== device.udid) { - await shutdownSimulatorAsync(session.device.udid); ownedSimulatorUdids.delete(session.device.udid); } if (devices.get(session.device.udid) === session) { @@ -517,7 +530,8 @@ export function createWarmSimulatorSession({ const preinstalls = new Map( [...devices].map(([udid, session]) => { const result = session.expoGo?.claimAsync() ?? Promise.resolve(undefined); - void result.catch(onBackgroundFailure); + // Only the selected device's installation can fail the claim below. + void result.catch(() => {}); return [udid, result]; }) ); @@ -623,7 +637,7 @@ export function createWarmSimulatorSession({ const [unusedToolsResults, unusedDeviceResults, expoGoResults] = await Promise.all([ unusedToolsCleanup, unusedDevicesCleanup, - Promise.allSettled([...devices.values()].map(session => session.expoGo?.stopAsync())), + Promise.allSettled([...expoGoPreinstalls].map(preinstall => preinstall.stopAsync())), ]); await Promise.allSettled([...devices.values()].map(session => session.ready)); await previousDeviceShutdown.catch(() => {}); diff --git a/packages/simulator-launcher/src/utils/expoGo.ts b/packages/simulator-launcher/src/utils/expoGo.ts index b5de8e48d3..798d1d77d7 100644 --- a/packages/simulator-launcher/src/utils/expoGo.ts +++ b/packages/simulator-launcher/src/utils/expoGo.ts @@ -30,7 +30,7 @@ export function startExpoGoPreinstall( ): { claimAsync(selectedUdid?: string): Promise; preinstallOnDeviceAsync(udid: string, boot: Promise, signal: AbortSignal): Promise; - stopAsync(): Promise; + stopAsync(selectedUdid?: string): Promise; resultPromise: Promise; } { const downloadController = new AbortController(); @@ -43,7 +43,7 @@ export function startExpoGoPreinstall( const installations = new Map>(); const installed = new Map(); const operations = new Set>(); - let failure: unknown; + const failures = new Map(); let cleanup: Promise | undefined; const archive = (async () => { const directoryPath = await directory; @@ -134,22 +134,22 @@ export function startExpoGoPreinstall( async function cleanUpAsync(): Promise { return await (cleanup ??= (async () => { - const results = await Promise.allSettled([...operations]); + // Install failures belong to a device; cleanup still drains every operation. + await Promise.allSettled([...operations]); await archive.catch(() => {}); // Unproved close never permits temporary-file removal or a stopped acknowledgement. commands.throwIfCommandCloseTimedOut(); await fs.rm(await directory, { recursive: true, force: true }); - const rejected = results.find(result => result.status === 'rejected'); - if (rejected?.status === 'rejected') { - throw rejected.reason; - } })()); } - const initial = preinstallAsync(udid, boot, downloadSignal); + const initial = preinstallAsync(udid, boot, downloadSignal).catch(error => { + failures.set(udid, error); + throw error; + }); operations.add(initial); const resultPromise = reuseArchive ? initial : initial.finally(cleanUpAsync); void resultPromise.catch(error => { - failure = error; + failures.set(udid, error); }); return { resultPromise, @@ -157,30 +157,34 @@ export function startExpoGoPreinstall( if (downloadController.signal.aborted || cleanup) { return Promise.resolve(); } - const operation = preinstallAsync(target, boot, deviceSignal); + const operation = preinstallAsync(target, boot, deviceSignal).catch(error => { + failures.set(target, error); + throw error; + }); operations.add(operation); const complete = operation.finally(cleanUpAsync); void complete.catch(error => { - failure = error; + failures.set(target, error); }); await complete; }, async claimAsync(selectedUdid = udid) { downloadController.abort(); - void cleanUpAsync().catch(error => { - failure = error; - }); + void cleanUpAsync().catch(() => {}); await installations.get(selectedUdid); - if (failure) { - throw failure; + if (failures.has(selectedUdid)) { + throw failures.get(selectedUdid); } - commands.throwIfCommandCloseTimedOut(); return installed.get(selectedUdid); }, - async stopAsync() { + async stopAsync(selectedUdid = udid) { + // The session owner must still confirm Simulator shutdown before publishing disabled. downloadController.abort(); await cleanUpAsync(); commands.throwIfCommandCloseTimedOut(); + if (failures.has(selectedUdid)) { + throw failures.get(selectedUdid); + } }, }; } From 1f214df4e3a6c89dea9caf8523cb1ae73cc8903d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 26 Oct 2026 20:04:50 -0700 Subject: [PATCH 35/42] [simulator-launcher] Keep one selected device in session state Explain claim, stop and replacement ordering. --- packages/simulator-launcher/src/__tests__/state.test.ts | 1 - packages/simulator-launcher/src/commands/start.ts | 6 ++++-- packages/simulator-launcher/src/utils/state.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/state.test.ts b/packages/simulator-launcher/src/__tests__/state.test.ts index 17379d1af8..34e2408d4f 100644 --- a/packages/simulator-launcher/src/__tests__/state.test.ts +++ b/packages/simulator-launcher/src/__tests__/state.test.ts @@ -16,7 +16,6 @@ const boot = 'boot-one'; let root: string; const info = { device: { udid: 'PHONE', name: 'iPhone 17', port: 3200 }, - devices: [{ udid: 'PHONE', name: 'iPhone 17', port: 3200 }], controllerPid: 9000, controllerStartTime: 'Thu Oct 22 10:00:00 2026', agentDeviceStateDir: '/agent', diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index ccdc04d768..80273b4bfd 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -142,6 +142,7 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise { @@ -156,7 +157,6 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise {}); throw error; } + // Only confirmed cleanup may publish disabled, which lets a job safely start cold. await updateLauncherStateAsync(bootUuid, state => ({ ...requireOwnedState(state, 'stopping'), status: 'disabled', @@ -329,6 +330,7 @@ export function createWarmSimulatorSession({ } await throwIfStoppingAsync(); })(); + // Release the old ports before shutdown; final stop still drains this background work. previousDeviceShutdown = release.then(async () => { const results = await Promise.allSettled( previousDevices.map(async session => { @@ -349,6 +351,7 @@ export function createWarmSimulatorSession({ } }); void previousDeviceShutdown.catch(() => {}); + // Boot overlaps the release; new services wait for the old ones to exit. const replacement = startDevice( { ...device, port: 3200 }, requestedTool ? [requestedTool] : [], @@ -423,7 +426,6 @@ export function createWarmSimulatorSession({ currentDevice = first.device; return { device: currentDevice, - devices: [currentDevice], controllerPid: process.pid, controllerStartTime, agentDeviceStateDir: process.env.AGENT_DEVICE_STATE_DIR!, diff --git a/packages/simulator-launcher/src/utils/state.ts b/packages/simulator-launcher/src/utils/state.ts index f3c34dd641..17dbb7dd8f 100644 --- a/packages/simulator-launcher/src/utils/state.ts +++ b/packages/simulator-launcher/src/utils/state.ts @@ -18,8 +18,8 @@ export const ClaimInputSchema = z.object({ requestedDevice: NonBlankStringSchema.optional(), }); export const WarmSessionInfoSchema = z.object({ + // The default before claim, then the device selected for the job. device: WarmSimulatorDeviceSchema, - devices: z.array(WarmSimulatorDeviceSchema).min(1), controllerPid: z.number().int().gt(1), controllerStartTime: NonBlankStringSchema, agentDeviceStateDir: NonBlankStringSchema, From 2b471ce1eae3b9d8897b0dc4f19de149ddca2114 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 26 Oct 2026 20:41:41 -0700 Subject: [PATCH 36/42] [simulator-launcher] Make session startup and cleanup explicit --- .../src/__tests__/simulatorSession.test.ts | 48 +++ .../simulator-launcher/src/commands/start.ts | 385 ++++++++++-------- 2 files changed, 274 insertions(+), 159 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index 338efffb80..d10861530e 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -676,3 +676,51 @@ it('starts each warm serve-sim as an explicit orphan on its device port', () => } expect(launches.map(([, args]) => args[args.indexOf('--port') + 1])).toEqual(['3200', '3202']); }); + +it('a stopped session cannot begin preparation later', async () => { + const session = createSession(); + await session.stopAsync(); + try { + await expect(session.prepareAsync()).rejects.toMatchObject({ name: 'AbortError' }); + expect(commands.runAsync).not.toHaveBeenCalled(); + expect(startServeSim).not.toHaveBeenCalled(); + } finally { + await session.stopAsync(); + } +}); + +it('stop cancels and drains the initial device listing before returning', async () => { + const listing = deferred(); + let listingSignal: AbortSignal | undefined; + const original = jest.mocked(commands.runAsync).getMockImplementation()!; + jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => { + if (args.includes('devices')) { + listingSignal = signal; + return listing.promise; + } + return original(file, args, signal); + }); + const session = createSession(); + const preparing = session.prepareAsync().catch(error => error); + await until(() => listingSignal !== undefined); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(listingSignal!.aborted).toBe(true); + expect(stopped).toBe(false); + listing.resolve(JSON.stringify({ devices: { ios: [{ udid: 'PHONE', name: 'iPhone 17' }] } })); + await stopping; + expect(await preparing).toMatchObject({ name: 'AbortError' }); + expect(startServeSim).not.toHaveBeenCalled(); + expect(ran('bootstatus')).toHaveLength(0); + expect(shutdownSimulatorAsync).not.toHaveBeenCalled(); + } finally { + listing.resolve(JSON.stringify({ devices: { ios: [{ udid: 'PHONE', name: 'iPhone 17' }] } })); + await preparing; + await stopping; + await session.stopAsync(); + } +}); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index 80273b4bfd..78b2a7df3d 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events'; import { setTimeout as sleepAsync } from 'node:timers/promises'; import { createAgentDeviceTool } from '../utils/agentDevice'; @@ -121,54 +122,53 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise ({ - ...requireOwnedState(state, 'starting'), - ...warm!, - status: 'ready', - }), - signal - ); - while (!signal.aborted) { - const state = requireOwnedState(await readLauncherStateAsync(bootUuid)); - if (state.status === 'claiming') { - const request = ClaimInputSchema.parse(state); - const { device } = await session.claimAsync(request); - // Stop can arrive during native work; recheck ownership under the state lock. - await updateLauncherStateAsync( - bootUuid, - current => { - const owned = requireOwnedState(current, 'claiming'); - if ( - owned.engine !== request.engine || - owned.requestedDevice !== request.requestedDevice - ) { - throw interruption; - } - return { - ...owned, - status: 'claimed', - device, - }; - }, - signal - ); - } - await sleepAsync(100, undefined, { signal }); - } - } catch (error) { - if (!signal.aborted) { - sessionError ??= error; + async function prepareDefaultDeviceAsync(): Promise { + await throwIfStoppingAsync(); + warm = await session.prepareAsync(); + await updateLauncherStateAsync( + bootUuid, + state => ({ + ...requireOwnedState(state, 'starting'), + ...warm!, + status: 'ready', + }), + signal + ); + } + + async function handleOneClaimAsync(): Promise { + while (!signal.aborted) { + const state = requireOwnedState(await readLauncherStateAsync(bootUuid)); + if (state.status === 'claiming') { + const request = ClaimInputSchema.parse(state); + const { device } = await session.claimAsync(request); + // Stop can arrive during native work; recheck ownership under the state lock. + await updateLauncherStateAsync( + bootUuid, + current => { + const owned = requireOwnedState(current, 'claiming'); + if ( + owned.engine !== request.engine || + owned.requestedDevice !== request.requestedDevice + ) { + throw interruption; + } + return { + ...owned, + status: 'claimed', + device, + }; + }, + signal + ); + return; } + await sleepAsync(100, undefined, { signal }); } + signal.throwIfAborted(); + } + + async function finishSessionAsync(): Promise { await updateLauncherStateAsync(bootUuid, state => ({ ...requireOwnedState(state), status: 'stopping', @@ -196,6 +196,25 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise; - failed?: boolean; - serveSim?: ChildProcessHandle; + startupSignal: AbortSignal; + readonly canClaim: boolean; + cancelStartup(): void; + /** Release an unclaimed server quickly before reusing its port. */ + releaseServerAsync(): Promise; + /** Confirm descendant cleanup at the end of the job. */ + stopServerAsync(): Promise; + shutdownAsync(): Promise; }; /** Own resources before preparation starts so every partial attempt can be stopped. */ export function createWarmSimulatorSession({ - signal, + signal: parentSignal, throwIfStoppingAsync, onPreparationFailure, onBackgroundFailure, @@ -224,15 +248,18 @@ export function createWarmSimulatorSession({ onPreparationFailure(error: unknown): void; onBackgroundFailure(error: unknown): void; }): WarmSimulatorSession { + const lifetime = new AbortController(); + const signal = AbortSignal.any([parentSignal, lifetime.signal]); + let preparation: Promise | undefined; const warmTools = [createAgentDeviceTool(), createArgentTool()]; - const serveSims = new Set(); + // Attempts can reuse a UDID; physical boot ownership is shared until shutdown succeeds. const ownedSimulatorUdids = new Set(); - const devices = new Map(); + const deviceSessions = new Set(); const startedTools = new Map(); - let currentDevice: WarmSimulatorDevice; + let currentDevice: WarmDeviceSession; let availableDevices: SimulatorDevice[]; - let unusedToolsCleanup: Promise[]> = Promise.resolve([]); - let previousDeviceShutdown: Promise = Promise.resolve(); + let toolRetirement: Promise[]> = Promise.resolve([]); + const deviceRetirements: Promise[] = []; let launcherCachePriming: Promise = Promise.resolve(); async function resolveDeviceAsync( @@ -259,23 +286,22 @@ export function createWarmSimulatorSession({ tools: WarmTool[], servicesAllowed: Promise = Promise.resolve() ): WarmDeviceSession { - const session: WarmDeviceSession = { - device, - shutdown: new AbortController(), - ready: Promise.resolve(), - }; - devices.set(device.udid, session); - session.ready = (async () => { + const cancellation = new AbortController(); + const deviceSignal = AbortSignal.any([signal, cancellation.signal]); + let bootStarted = false; + let failed = false; + let retiring = false; + let serveSim: ChildProcessHandle | undefined; + const ready = (async () => { await throwIfStoppingAsync(); - const deviceSignal = AbortSignal.any([signal, session.shutdown.signal]); deviceSignal.throwIfAborted(); const udid = device.udid; + bootStarted = true; ownedSimulatorUdids.add(udid); const boot = bootSimulatorAsync(udid, deviceSignal); const serveSimReady = Promise.all([boot, servicesAllowed]).then(async () => { const handle = startServeSim(udid, deviceSignal, device.port); - session.serveSim = handle; - serveSims.add(handle); + serveSim = handle; await waitForServeSimReadyAsync(udid, handle, deviceSignal, device.port); }); for (const tool of tools) { @@ -290,39 +316,63 @@ export function createWarmSimulatorSession({ try { await Promise.all([serveSimReady, apsd]); } catch (error) { - session.failed = true; - if (!session.shutdown.signal.aborted) { + failed = true; + if (!cancellation.signal.aborted) { onPreparationFailure(error); } await Promise.allSettled([boot, apsd, serveSimReady, servicesAllowed]); throw error; } })(); - void session.ready.catch(() => {}); - return session; - } - - /** Cancel startup, drain it, then release this device's unclaimed server and its port. */ - async function stopDeviceServerAsync(session: WarmDeviceSession): Promise { - session.shutdown.abort(); - await session.ready.catch(() => {}); - if (session.serveSim) { - await terminateProcessGroupAsync(session.serveSim); - serveSims.delete(session.serveSim); - } + void ready.catch(() => {}); + const owner: WarmDeviceSession = { + device, + ready, + startupSignal: deviceSignal, + get canClaim() { + return bootStarted && ownedSimulatorUdids.has(device.udid) && !failed && !retiring; + }, + cancelStartup() { + cancellation.abort(); + }, + async releaseServerAsync() { + retiring = true; + cancellation.abort(); + await ready.catch(() => {}); + if (serveSim) { + await terminateProcessGroupAsync(serveSim); + serveSim = undefined; + } + }, + async stopServerAsync() { + if (serveSim) { + await stopProcessAndDescendantsAsync(serveSim); + serveSim = undefined; + } + }, + async shutdownAsync() { + if (ownedSimulatorUdids.has(device.udid)) { + await shutdownSimulatorAsync(device.udid); + ownedSimulatorUdids.delete(device.udid); + } + }, + }; + // Keep every attempt owned, including one replaced by another attempt on the same UDID. + deviceSessions.add(owner); + return owner; } async function replaceDeviceAsync( device: SimulatorDevice, requestedTool?: WarmTool ): Promise { - const previousDevices = [...devices.values()]; + const previousDevices = [...deviceSessions]; const previousTools = [...startedTools.values()]; startedTools.clear(); const release = (async () => { const results = await Promise.allSettled([ ...previousTools.map(tool => tool.stopAsync()), - ...previousDevices.map(session => stopDeviceServerAsync(session)), + ...previousDevices.map(session => session.releaseServerAsync()), ]); const failed = results.find(result => result.status === 'rejected'); if (failed?.status === 'rejected') { @@ -331,17 +381,11 @@ export function createWarmSimulatorSession({ await throwIfStoppingAsync(); })(); // Release the old ports before shutdown; final stop still drains this background work. - previousDeviceShutdown = release.then(async () => { + const retirement = release.then(async () => { const results = await Promise.allSettled( previousDevices.map(async session => { if (session.device.udid !== device.udid) { - await shutdownSimulatorAsync(session.device.udid); - } - if (session.device.udid !== device.udid) { - ownedSimulatorUdids.delete(session.device.udid); - } - if (devices.get(session.device.udid) === session) { - devices.delete(session.device.udid); + await session.shutdownAsync(); } }) ); @@ -350,7 +394,14 @@ export function createWarmSimulatorSession({ throw failed.reason; } }); - void previousDeviceShutdown.catch(() => {}); + deviceRetirements.push( + retirement.catch(error => { + // The claim observes release failures; final stop retries resources still owned here. + process.stderr.write( + `Could not finish replaced Simulator cleanup; final stop will retry: ${error}\n` + ); + }) + ); // Boot overlaps the release; new services wait for the old ones to exit. const replacement = startDevice( { ...device, port: 3200 }, @@ -358,22 +409,23 @@ export function createWarmSimulatorSession({ release ); await replacement.ready; - currentDevice = replacement.device; + currentDevice = replacement; } /** Stops services before shutting down every device whose boot was attempted. */ async function stopServicesAndDevicesAsync(): Promise { const cleanupErrors: unknown[] = []; const attempt = (step: Promise) => step.catch(error => cleanupErrors.push(error)); - for (const handle of serveSims) { - await attempt(stopProcessAndDescendantsAsync(handle)); + for (const owner of deviceSessions) { + await attempt(owner.stopServerAsync()); } if (ownedSimulatorUdids.size) { for (const tool of warmTools) { await attempt(tool.stopAsync()); } } - await Promise.all([...ownedSimulatorUdids].map(udid => attempt(shutdownSimulatorAsync(udid)))); + const physicalDevices = new Map([...deviceSessions].map(owner => [owner.device.udid, owner])); + await Promise.all([...physicalDevices.values()].map(owner => attempt(owner.shutdownAsync()))); try { commands.throwIfCommandCloseTimedOut(); } catch (error) { @@ -387,50 +439,76 @@ export function createWarmSimulatorSession({ } } - return { - async prepareAsync(): Promise { - availableDevices = await listAvailableSimulatorsAsync(signal); - const device = await resolveDeviceAsync( - process.env.EXPO_SIMULATOR_DEVICE?.trim() || undefined - ); - const controllerStartTime = ( - await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) - ).trim(); - if (!controllerStartTime) { - throw new Error( - 'Could not read the Simulator launcher process start time; the warm session cannot be verified.' + async function claimExistingDeviceAsync( + existing: WarmDeviceSession, + requestedTool?: WarmTool + ): Promise { + toolRetirement = Promise.allSettled( + [...startedTools] + .filter(([tool]) => tool !== requestedTool) + .map(([tool, started]) => { + startedTools.delete(tool); + return started.stopAsync().catch(error => { + onBackgroundFailure(error); + throw error; + }); + }) + ); + await existing.ready; + currentDevice = existing; + } + + async function primeLauncherCacheAsync(first: WarmDeviceSession): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await commands.runAsync( + 'bun', + ['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'], + AbortSignal.any([signal, first.startupSignal, AbortSignal.timeout(30_000)]) ); - } - const first = startDevice({ ...device, port: 3200 }, warmTools); - launcherCachePriming = (async () => { - for (let attempt = 0; attempt < 2; attempt++) { - try { - await commands.runAsync( - 'bun', - ['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'], - AbortSignal.any([signal, first.shutdown.signal, AbortSignal.timeout(30_000)]) - ); - return; - } catch (error) { - signal.throwIfAborted(); - first.shutdown.signal.throwIfAborted(); - if (attempt === 1) { - process.stderr.write( - `Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n` - ); - } - } + return; + } catch (error) { + signal.throwIfAborted(); + first.startupSignal.throwIfAborted(); + if (attempt === 1) { + process.stderr.write( + `Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n` + ); } - })(); - await Promise.all([first.ready, launcherCachePriming]); - currentDevice = first.device; - return { - device: currentDevice, - controllerPid: process.pid, - controllerStartTime, - agentDeviceStateDir: process.env.AGENT_DEVICE_STATE_DIR!, - argentEventLogPath: process.env.ARGENT_EVENT_LOG!, - }; + } + } + } + + async function prepareDefaultAsync(): Promise { + signal.throwIfAborted(); + availableDevices = await listAvailableSimulatorsAsync(signal); + signal.throwIfAborted(); + const device = await resolveDeviceAsync(process.env.EXPO_SIMULATOR_DEVICE?.trim() || undefined); + const controllerStartTime = ( + await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) + ).trim(); + signal.throwIfAborted(); + if (!controllerStartTime) { + throw new Error( + 'Could not read the Simulator launcher process start time; the warm session cannot be verified.' + ); + } + const first = startDevice({ ...device, port: 3200 }, warmTools); + launcherCachePriming = primeLauncherCacheAsync(first); + await Promise.all([first.ready, launcherCachePriming]); + currentDevice = first; + return { + device: currentDevice.device, + controllerPid: process.pid, + controllerStartTime, + agentDeviceStateDir: process.env.AGENT_DEVICE_STATE_DIR!, + argentEventLogPath: process.env.ARGENT_EVENT_LOG!, + }; + } + + return { + async prepareAsync(): Promise { + return await (preparation ??= prepareDefaultAsync()); }, async claimAsync(request: ClaimInput) { await throwIfStoppingAsync(); @@ -442,12 +520,11 @@ export function createWarmSimulatorSession({ `More than one warm Simulator matches ${request.requestedDevice}. Use an exact UDID.` ); } - const matches = [...devices.values()].filter( + const matches = [...deviceSessions].filter( session => - ownedSimulatorUdids.has(session.device.udid) && - !session.failed && + session.canClaim && (!request.requestedDevice - ? session.device.udid === currentDevice.udid + ? session.device.udid === currentDevice.device.udid : session.device.name === request.requestedDevice || session.device.udid.toLowerCase() === request.requestedDevice.toLowerCase()) ); @@ -462,34 +539,24 @@ export function createWarmSimulatorSession({ const device = await resolveDeviceAsync(request.requestedDevice, /* refreshOnMiss */ true); await replaceDeviceAsync(device, requestedTool); } else { - unusedToolsCleanup = Promise.allSettled( - [...startedTools] - .filter(([tool]) => tool !== requestedTool) - .map(([tool, started]) => { - startedTools.delete(tool); - return started.stopAsync().catch(error => { - onBackgroundFailure(error); - throw error; - }); - }) - ); - await existing.ready; - currentDevice = existing.device; + await claimExistingDeviceAsync(existing, requestedTool); } if (requestedTool) { await startedTools.get(requestedTool)!.ready; } - return { device: currentDevice }; + return { device: currentDevice.device }; }, async stopAsync(): Promise { const cancellingTools = [...startedTools.values()].map(tool => tool.cancelAsync()); - for (const session of devices.values()) { - session.shutdown.abort(); + lifetime.abort(); + for (const owner of deviceSessions) { + owner.cancelStartup(); } await Promise.all(cancellingTools); - const unusedToolsResults = await unusedToolsCleanup; - await Promise.allSettled([...devices.values()].map(session => session.ready)); - await previousDeviceShutdown.catch(() => {}); + await preparation?.catch(() => {}); + const unusedToolsResults = await toolRetirement; + await Promise.allSettled([...deviceSessions].map(owner => owner.ready)); + await Promise.all(deviceRetirements); await launcherCachePriming.catch(() => {}); await stopServicesAndDevicesAsync(); const errors = unusedToolsResults.flatMap(result => From bae24fc0ecb78a84e04d44bb69af47760c72d785 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 00:06:59 -0700 Subject: [PATCH 37/42] [stack] Retain the launcher foundation release entry Co-authored-by: Codex --- .changeset/simulator-launcher-foundation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/simulator-launcher-foundation.md diff --git a/.changeset/simulator-launcher-foundation.md b/.changeset/simulator-launcher-foundation.md new file mode 100644 index 0000000000..52b41fc8ca --- /dev/null +++ b/.changeset/simulator-launcher-foundation.md @@ -0,0 +1,5 @@ +--- +'@expo/simulator-launcher': minor +--- + +Add Simulator state and service helpers. From f1edc8385285ea49182571cc9b0bf9c1ce0780fc Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 12:21:04 -0700 Subject: [PATCH 38/42] [simulator-launcher] Add braces to the single-device selection guard Edited-by: Codex --- packages/simulator-launcher/src/commands/start.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index ff600a4431..b7959a6530 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -328,10 +328,11 @@ export function createWarmSimulatorSession({ availableDevices, process.env.EXPO_SIMULATOR_DEVICE?.trim() || undefined ); - if (selected.length !== 1) + if (selected.length !== 1) { throw new Error( 'Could not select one available Simulator. Use an exact device name or UDID.' ); + } warmDevices = selected; const controllerStartTime = ( await commands.runAsync('ps', ['-p', String(process.pid), '-o', 'lstart='], signal) From dfcf51583138491049ac62dae152b3f9e0562358 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 14:28:42 -0700 Subject: [PATCH 39/42] [simulator-launcher] Merge the launcher changesets into one Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 +- .changeset/warm-adopted-apsd.md | 5 ----- .changeset/warm-server-claim.md | 5 ----- 3 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 .changeset/warm-adopted-apsd.md delete mode 100644 .changeset/warm-server-claim.md diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 1a0b5c65a8..8cd2811aee 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -2,4 +2,4 @@ '@expo/simulator-launcher': minor --- -Add `@expo/simulator-launcher` to warm an iOS Simulator at VM startup, hand it to one job with `claim`, and clean up with `stop`. +Add `@expo/simulator-launcher` with commands for warm Simulators. `start` runs warmup at VM boot: it boots the device, tries to disable apsd, and starts one serve-sim with `--orphan`, Argent and agent-device. `claim` reserves the warm Simulator for one job without waiting for a tool. After serve-sim adoption selects the job's device, `prepare-tools` prepares the requested tool and tries to disable apsd on a device that missed warmup, without blocking app startup. `stop` ends warmup and waits for cleanup. This requires the matching serve-sim release and a fresh VM. diff --git a/.changeset/warm-adopted-apsd.md b/.changeset/warm-adopted-apsd.md deleted file mode 100644 index 9c706ae1bb..0000000000 --- a/.changeset/warm-adopted-apsd.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@expo/simulator-launcher': patch ---- - -Attempt to disable apsd after adopting a Simulator that missed warmup setup. Keep the attempt nonblocking for app startup, and drain it before shutting down the device. diff --git a/.changeset/warm-server-claim.md b/.changeset/warm-server-claim.md deleted file mode 100644 index 0551535886..0000000000 --- a/.changeset/warm-server-claim.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@expo/simulator-launcher': minor ---- - -Use one orphan serve-sim for warm devices. Reserve the job with `claim`, then prepare its tools with `prepare-tools` after private adoption selects and boots its Simulator. This requires the matching serve-sim release and a fresh VM. From e86e18180f663a02d2baf8608918deb05cb6c29d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 15:36:10 -0700 Subject: [PATCH 40/42] [simulator-launcher] Shorten the launcher changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/simulator-launcher.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md index 8cd2811aee..dbf2c57b58 100644 --- a/.changeset/simulator-launcher.md +++ b/.changeset/simulator-launcher.md @@ -2,4 +2,4 @@ '@expo/simulator-launcher': minor --- -Add `@expo/simulator-launcher` with commands for warm Simulators. `start` runs warmup at VM boot: it boots the device, tries to disable apsd, and starts one serve-sim with `--orphan`, Argent and agent-device. `claim` reserves the warm Simulator for one job without waiting for a tool. After serve-sim adoption selects the job's device, `prepare-tools` prepares the requested tool and tries to disable apsd on a device that missed warmup, without blocking app startup. `stop` ends warmup and waits for cleanup. This requires the matching serve-sim release and a fresh VM. +Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). From c1bb5723d99f8274ffcd4c877ba89baafe9cc0c4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 16:23:48 -0700 Subject: [PATCH 41/42] [simulator-launcher] Keep the claimed Simulator when an unused tool stop fails Co-Authored-By: Claude Opus 5.5 --- packages/simulator-launcher/README.md | 4 ++-- .../src/__tests__/earlyClaim.test.ts | 1 - .../src/__tests__/simulatorSession.test.ts | 22 ++++++++++++++----- .../simulator-launcher/src/commands/start.ts | 16 +++++--------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md index 042143a4f9..f134f6a907 100644 --- a/packages/simulator-launcher/README.md +++ b/packages/simulator-launcher/README.md @@ -20,8 +20,8 @@ exec bunx @expo/simulator-launcher@latest start Build-tools uses the package version recorded by `start` for `claim`, `prepare-tools` and `stop`. There is no copied bundle. Warmup primes Bun's exact-version cache before ready, with two attempts -of up to 30 seconds. Calls use `--no-install`; only a cache-resolution failure retries against the -registry. Priming failures warn. Stop before start disables startup for this VM boot. +of up to 30 seconds. Build-tools calls use `--no-install`; only a cache-resolution failure retries +against the registry. Priming failures warn. Stop before start disables startup for this VM boot. The single private record `/tmp/expo-simulator-startup.json` contains lifecycle, launcher version, selected device, controller and server identity, credentials and tool paths. A boot-scoped lock diff --git a/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts b/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts index d8495457a2..1351cc8132 100644 --- a/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts +++ b/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts @@ -57,7 +57,6 @@ it('an early claim survives unused-tool wrapper exit while the selected server a signal: controller.signal, throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), onPreparationFailure: onFailure, - onBackgroundFailure: onFailure, }); await session.prepareAsync(); expect(await session.claimAsync({ engine: 'serve-sim' })).toMatchObject({ diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index b64565ebda..a783809409 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -40,7 +40,6 @@ const argent = handle(12); let controller: AbortController; let env: NodeJS.ProcessEnv; let onPreparationFailure: jest.Mock; -let onBackgroundFailure: jest.Mock; const starts = (word: string) => jest.mocked(commands.start).mock.calls.filter(([, args]) => args.includes(word)); function createSession() { @@ -48,7 +47,6 @@ function createSession() { signal: controller.signal, throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), onPreparationFailure, - onBackgroundFailure, }); } @@ -60,7 +58,6 @@ beforeEach(() => { process.env.ARGENT_EVENT_LOG = '/fixture/argent-events'; controller = new AbortController(); onPreparationFailure = jest.fn(error => controller.abort(error)); - onBackgroundFailure = jest.fn(error => controller.abort(error)); jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => { if (args.includes('devices')) { return JSON.stringify({ devices: { ios: [defaultDevice, secondary, otherDevice] } }); @@ -145,7 +142,7 @@ it('starts one orphan with all configured devices and keeps its identity separat expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER'); }); -it('primes the exact version once before readiness and bounds two failed attempts', async () => { +it('primes the exact version once before readiness', async () => { const priming = deferred(); const run = jest.mocked(commands.runAsync).getMockImplementation()!; jest @@ -255,6 +252,22 @@ it('serve-sim tool preparation does not gate on unused tool stops, but terminal await stopping; }); +it('a failed unused-tool stop after the claim keeps the session and the stop retries it', async () => { + jest + .mocked(stopProcessAndDescendantsAsync) + .mockImplementationOnce(async () => {}) + .mockRejectedValueOnce(new Error('the tool is still running')); + const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'serve-sim' }); + await until(() => warning.mock.calls.some(([text]) => String(text).includes('retrying at stop'))); + await session.prepareToolsAsync(); + expect(controller.signal.aborted).toBe(false); + await session.stopAsync(); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); +}); + it('starts an owned nonblocking apsd attempt after selected adoption and drains it before shutdown', async () => { const session = createSession(); await session.prepareAsync(); @@ -299,7 +312,6 @@ it('warns on ordinary adopted-device apsd failure without failing the claimed se await session.prepareToolsAsync(); await session.stopAsync(); expect(onPreparationFailure).not.toHaveBeenCalled(); - expect(onBackgroundFailure).not.toHaveBeenCalled(); expect(warning.mock.calls.map(([text]) => text).join('')).toContain( 'Could not disable apsd on OTHER; continuing' ); diff --git a/packages/simulator-launcher/src/commands/start.ts b/packages/simulator-launcher/src/commands/start.ts index e24cd9ea33..d813f91c8b 100644 --- a/packages/simulator-launcher/src/commands/start.ts +++ b/packages/simulator-launcher/src/commands/start.ts @@ -102,10 +102,6 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise { while (!watcherAbort.signal.aborted) { @@ -197,6 +193,7 @@ export async function runWarmSimulatorSessionAsync(bootUuid: string): Promise; onPreparationFailure(error: unknown): void; - onBackgroundFailure(error: unknown): void; }): WarmSimulatorSession { const lifetime = new AbortController(); const signal = AbortSignal.any([parentSignal, lifetime.signal]); @@ -391,8 +386,9 @@ export function createWarmSimulatorSession({ ) .map(([tool, started]) => { startedTools.delete(tool); + // The job owns the device now; the terminal stop retries this tool. return started.stopAsync().catch(error => { - onBackgroundFailure(error); + process.stderr.write(`Could not stop ${tool.name}; retrying at stop: ${error}\n`); throw error; }); }) @@ -452,11 +448,9 @@ export function createWarmSimulatorSession({ await Promise.allSettled(cancellations); await preparation?.catch(() => {}); await Promise.allSettled(simulatorSetup); - const retired = await toolRetirement; + await toolRetirement; await launcherCachePriming.catch(() => {}); - const errors = retired.flatMap(result => - result.status === 'rejected' ? [result.reason] : [] - ); + const errors: unknown[] = []; const attemptAsync = async (operation: Promise) => { await operation.catch(error => errors.push(error)); }; From c33d766c9094ade4402cac3f605904b47344c311 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 16:46:26 -0700 Subject: [PATCH 42/42] [simulator-launcher] Prove the stop retries an unused tool Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/simulatorSession.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts index a783809409..515110cd74 100644 --- a/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -253,10 +253,12 @@ it('serve-sim tool preparation does not gate on unused tool stops, but terminal }); it('a failed unused-tool stop after the claim keeps the session and the stop retries it', async () => { - jest - .mocked(stopProcessAndDescendantsAsync) - .mockImplementationOnce(async () => {}) - .mockRejectedValueOnce(new Error('the tool is still running')); + const failure = new Error('Argent is still running'); + const stop = jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => { + if (handle === argent) { + throw failure; + } + }); const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true); const session = createSession(); await session.prepareAsync(); @@ -264,7 +266,9 @@ it('a failed unused-tool stop after the claim keeps the session and the stop ret await until(() => warning.mock.calls.some(([text]) => String(text).includes('retrying at stop'))); await session.prepareToolsAsync(); expect(controller.signal.aborted).toBe(false); - await session.stopAsync(); + // The retry fails too, so the stop reports it. + await expect(session.stopAsync()).rejects.toMatchObject({ errors: [failure] }); + expect(stop.mock.calls.filter(([handle]) => handle === argent)).toHaveLength(2); expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); });