diff --git a/.changeset/simulator-launcher.md b/.changeset/simulator-launcher.md new file mode 100644 index 0000000000..dbf2c57b58 --- /dev/null +++ b/.changeset/simulator-launcher.md @@ -0,0 +1,5 @@ +--- +'@expo/simulator-launcher': minor +--- + +Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). diff --git a/packages/simulator-launcher/README.md b/packages/simulator-launcher/README.md new file mode 100644 index 0000000000..f134f6a907 --- /dev/null +++ b/packages/simulator-launcher/README.md @@ -0,0 +1,42 @@ +# @expo/simulator-launcher + +Warm an iOS Simulator and its tools before an EAS job arrives. Requires macOS. +The VM's startup script at `/usr/local/share/vm-tools/startup.sh` can run: + +```sh +export EXPO_SIMULATOR_STARTUP=1 +export EXPO_SIMULATOR_DEVICE="iPhone Duo" +exec bunx @expo/simulator-launcher@latest start +``` + +- `start` runs one orphan serve-sim, Argent and agent-device in the foreground. +- `claim ENGINE [DEVICE]` reserves one job and returns the selected device separately from the + existing server's identity, port and credentials. Engines: `serve-sim`, `agent-device`, `argent`. + Omit DEVICE to use the startup device. Claim does not wait for the selected tool or boot another + device; build-tools privately adopts the requested device on the existing serve-sim port. +- `prepare-tools` continues the consumed claim after adoption. It reuses global Argent or the + default agent-device runner, or prepares an agent-device runner for the newly selected device. +- `stop` waits for owned processes, background work and Simulators to be cleaned up. + +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. 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 +serializes state updates. An abandoned lock fails closed. The file is independent of HOME/TMPDIR. +This format and port-based adoption require the matching serve-sim release and a fresh VM. + +`EXPO_SIMULATOR_DEVICE` selects an exact device name or UDID. Unset or blank selects the available +iPhone with the shortest name. `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION` and `ARGENT_VERSION` +default to `latest`. `AGENT_DEVICE_STATE_DIR` and `ARGENT_EVENT_LOG` control tool state locations. +Agent-device's daemon and runner idle timers default to off. The image provides ffmpeg. + +Ready means the startup device booted, its apsd disable attempt finished and serve-sim answers. +It does not mean all tools are ready. A claim reserves a device; private adoption must confirm the +same server, selected-device readiness and native capture before tool validation and app actions. +One start attempt and one job claim are allowed per boot. Cleanup failures never report disabled. +A caller timeout is not a global cleanup deadline or proof that teardown finished. + +Claim and tool JSON include credentials; capture them rather than streaming them to job logs. 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/package.json b/packages/simulator-launcher/package.json index fd73a5e2a2..5bff1b5ecd 100644 --- a/packages/simulator-launcher/package.json +++ b/packages/simulator-launcher/package.json @@ -1,8 +1,7 @@ { "name": "@expo/simulator-launcher", "version": "27.1.0", - "private": true, - "description": "Simulator state, process and service helpers", + "description": "Launch and claim Simulator sessions on EAS workers", "bugs": "https://github.com/expo/eas-cli/issues", "license": "BUSL-1.1", "author": "Expo ", @@ -11,16 +10,25 @@ "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", - "typecheck": "tsc --noEmit" + "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", diff --git a/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts b/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts new file mode 100644 index 0000000000..1351cc8132 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/earlyClaim.test.ts @@ -0,0 +1,80 @@ +import { commands } from '../utils/processes'; +import { startOwnedWrapperAsync } from './fixtures/ownedWrapper'; + +it('an early claim survives unused-tool wrapper exit while the selected server and Simulator stay owned', async () => { + const fixture = await startOwnedWrapperAsync(); + const environment = { ...process.env }; + let session: + | ReturnType + | undefined; + try { + const { createWarmSimulatorSession } = await import('../commands/start'); + const simulator = await import('../utils/simulator'); + const serveSim = await import('../utils/serveSim'); + const processes = await import('../utils/processes'); + const agentDevice = await import('../utils/agentDevice'); + const argent = await import('../utils/argent'); + const controller = new AbortController(); + const running = commands.runAsync.bind(commands); + process.env.EXPO_SIMULATOR_DEVICE = 'DUO'; + process.env.AGENT_DEVICE_STATE_DIR = '/owned-wrapper-fixture/agent'; + process.env.ARGENT_EVENT_LOG = '/owned-wrapper-fixture/argent-events'; + jest + .spyOn(commands, 'runAsync') + .mockImplementation((file, args, signal) => + file === 'bun' || (file === 'ps' && args[1] === String(process.pid)) + ? Promise.resolve('controller-start') + : running(file, args, signal) + ); + jest + .spyOn(simulator, 'listAvailableSimulatorsAsync') + .mockResolvedValue([{ udid: 'DUO', name: 'iPhone Duo' }]); + jest.spyOn(simulator, 'disableApsdAsync').mockResolvedValue(); + const shutdown = jest.spyOn(simulator, 'shutdownSimulatorAsync').mockResolvedValue(); + const stopServer = jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue(); + jest.spyOn(agentDevice, 'createAgentDeviceTool').mockReturnValue({ + name: 'agent-device', + startAsync: async () => {}, + stopAsync: fixture.stopAsync, + }); + jest + .spyOn(argent, 'createArgentTool') + .mockReturnValue({ name: 'argent', startAsync: async () => {}, stopAsync: async () => {} }); + jest.spyOn(serveSim, 'startServeSim').mockReturnValue({ + child: { pid: 90001 } as import('node:child_process').ChildProcess, + resultPromise: new Promise(() => {}), + }); + jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({ + startupDevice: 'DUO', + pid: 90001, + nativeStartTime: 'fixture-start', + port: 3200, + url: 'http://127.0.0.1:3200', + token: 'fixture-token', + }); + const onFailure = jest.fn(error => controller.abort(error)); + session = createWarmSimulatorSession({ + signal: controller.signal, + throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), + onPreparationFailure: onFailure, + }); + await session.prepareAsync(); + expect(await session.claimAsync({ engine: 'serve-sim' })).toMatchObject({ + device: { udid: 'DUO' }, + }); + await session.prepareToolsAsync(); + await fixture.stopAsync(); + expect(fixture.job.child.exitCode).toBe(23); + expect(onFailure).not.toHaveBeenCalled(); + expect(controller.signal.aborted).toBe(false); + expect(stopServer).not.toHaveBeenCalled(); + expect(shutdown).not.toHaveBeenCalled(); + await session.stopAsync(); + expect(shutdown).toHaveBeenCalledWith('DUO'); + } finally { + await session?.stopAsync().catch(() => {}); + jest.restoreAllMocks(); + process.env = environment; + await fixture.cleanupAsync(); + } +}, 10_000); 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..f89ca1f4b9 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/main.test.ts @@ -0,0 +1,389 @@ +import { type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { claimAsync, prepareToolsAsync } from '../commands/claim'; +import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start'; +import { stopAsync } from '../commands/stop'; +import { mainAsync } from '../main'; +import { createArgentTool } from '../utils/argent'; +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/argent'); +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; + +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', + launcherVersion: require('../../package.json').version, + 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')); + await fs.mkdir(path.join(root, '.argent')); + await fs.writeFile( + path.join(root, '.argent/tool-server.json'), + JSON.stringify({ pid: 90002, port: 3201, token: 'fixture-token' }) + ); + jest.spyOn(processes, 'isProcessOwnedByServiceAsync').mockResolvedValue(true); + jest + .spyOn(global, 'fetch') + .mockImplementation(async () => new Response(JSON.stringify({ tools: [] }))); + 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'; + process.env.ARGENT_EVENT_LOG = path.join(root, 'argent-events'); + active = []; + jobs = []; + 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('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((_file, args) => { + const job = { + child: Object.assign(new EventEmitter(), { + pid: 90000 + jobs.length, + exitCode: null, + signalCode: null, + kill: jest.fn(), + }) as unknown as ChildProcess, + resultPromise: args.includes('server') ? new Promise(() => {}) : Promise.resolve(''), + }; + jobs.push(job); + return job; + }); + // Fixture PIDs are fake; never signal them. + jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue(); + jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({ + startupDevice: 'PHONE', + pid: 90000, + nativeStartTime: 'native-start', + port: 3200, + url: 'http://127.0.0.1:3200', + token: 'fixture-token', + }); + jest.mocked(createArgentTool).mockImplementation(() => ({ + name: 'argent', + startAsync: async () => {}, + stopAsync: async () => {}, + })); +}); +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 record the exact package version', async () => { + const results: unknown[] = []; + const starts = [0, 1].map(() => + startAsync(boot).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((await readLauncherStateAsync(boot))?.launcherVersion).toBe( + require('../../package.json').version + ); + expect(jest.mocked(serveSim.waitForServeSimReadyAsync).mock.calls).toHaveLength(1); + await stopAsync(boot); + await Promise.all(starts); + expect((await readLauncherStateAsync(boot))?.status).toBe('disabled'); +}); + +test('stop before start makes late startup a resource-free disabled no-op', async () => { + expect(await stopAsync(boot)).toBe(0); + 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' }); +}); + +test('stop under different HOME and TMPDIR waits for the same active session cleanup', async () => { + const [completion] = await beginSession(); + await status('ready'); + const drain = deferred(); + jest.mocked(processes.stopProcessAndDescendantsAsync).mockImplementation(async () => { + await drain.promise; + }); + process.env.HOME = path.join(root, 'other-home'); + process.env.TMPDIR = path.join(root, 'other-tmp'); + jest.mocked(os.homedir).mockReturnValue(process.env.HOME); + let done = false; + const stopping = mainAsync(['stop']).then(value => { + done = true; + return value; + }); + try { + await status('stopping'); + expect(done).toBe(false); + drain.resolve(); + expect(await stopping).toBe(0); + expect(await completion).toBeUndefined(); + expect((await readLauncherStateAsync(boot))?.status).toBe('disabled'); + } finally { + drain.resolve(); + await stopping; + } +}); + +test('ready contains one server identity and effective paths in the single state file', async () => { + const [completion] = await beginSession(); + const ready = await status('ready'); + expect(ready).toMatchObject({ + controllerPid: process.pid, + device: { udid: 'PHONE' }, + server: { startupDevice: 'PHONE', pid: 90000, nativeStartTime: 'native-start', port: 3200 }, + }); + expect(ready?.agentDeviceStateDir).toContain(root); + expect(await fs.readdir(path.join(root, 'control'))).toEqual([ + path.basename(LAUNCHER_STATE_FILE), + ]); + await stopAsync(boot); + expect(await completion).toBeUndefined(); +}); + +test.each([undefined, 'phone', 'iPhone 17 Pro'])( + 'claim reserves %s without changing the server or booting devices', + async selector => { + 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.at(-1)![0])); + expect(result).toMatchObject({ + status: 'claimed', + engine: 'serve-sim', + device: { udid: selector === 'iPhone 17 Pro' ? 'PRO' : 'PHONE' }, + server: { startupDevice: 'PHONE', port: 3200, pid: 90000 }, + }); + expect(result.device).not.toHaveProperty('port'); + expect( + jest.mocked(commands.runAsync).mock.calls.filter(([, args]) => args.includes('bootstatus')) + ).toHaveLength(0); + await expect(claimAsync(boot, 'argent')).rejects.toThrow('available'); + await stopAsync(boot); + expect(await completion).toBeUndefined(); + } +); + +test.each([ + [undefined, undefined], + ['invalid', undefined], + ['serve-sim', ' '], +])('invalid claim %s/%s does not acquire or mutate state', async (engine, device) => { + const [completion] = await beginSession(); + await status('ready'); + const before = await readLauncherStateAsync(boot); + await expect(claimAsync(boot, engine, device)).rejects.toThrow( + 'Invalid Simulator session request' + ); + expect(await readLauncherStateAsync(boot)).toEqual(before); + await stopAsync(boot); + await completion; +}); + +test('the post-adopt preparation command uses the consumed claim and returns toolsReady', async () => { + const [completion] = await beginSession(); + await status('ready'); + const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + await expect(prepareToolsAsync(boot)).rejects.toThrow('Claim and adopt'); + await claimAsync(boot, 'agent-device'); + expect((await readLauncherStateAsync(boot))?.toolsReady).toBeUndefined(); + await prepareToolsAsync(boot); + expect(JSON.parse(String(output.mock.calls.at(-1)![0]))).toMatchObject({ + status: 'claimed', + engine: 'agent-device', + toolsReady: true, + prepareToolsRequested: true, + }); + await stopAsync(boot); + expect(await completion).toBeUndefined(); +}); + +test('stop during post-adopt preparation prevents toolsReady and waits for owned drain', async () => { + const [completion] = await beginSession(); + await status('ready'); + jest.spyOn(process.stdout, 'write').mockReturnValue(true); + await claimAsync(boot, 'serve-sim'); + const entered = deferred(); + const release = deferred(); + let preparationSignal: AbortSignal; + jest + .mocked(serveSim.waitForServeSimReadyAsync) + .mockImplementationOnce(async (_device, _server, signal) => { + preparationSignal = signal; + entered.resolve(); + await release.promise; + signal.throwIfAborted(); + throw new Error('unreachable'); + }); + const preparing = prepareToolsAsync(boot).catch(error => error); + try { + await entered.promise; + const stopping = stopAsync(boot); + await status('stopping'); + await until(() => preparationSignal.aborted); + release.resolve(); + await stopping; + expect(await preparing).toBeInstanceOf(Error); + expect((await readLauncherStateAsync(boot))?.toolsReady).not.toBe(true); + expect(await completion).toBeUndefined(); + } finally { + release.resolve(); + } +}); + +test('preparation failure confirms cleanup before publishing disabled with its cause', async () => { + jest + .mocked(serveSim.waitForServeSimReadyAsync) + .mockRejectedValueOnce(new Error('native readiness failed')); + const [completion] = await beginSession(); + expect(((await completion) as Error).message).toBe('native readiness failed'); + expect(processes.stopProcessAndDescendantsAsync).toHaveBeenCalled(); + expect(await readLauncherStateAsync(boot)).toMatchObject({ + status: 'disabled', + error: 'native readiness failed', + }); +}); + +test('cleanup failure leaves stopping with a cause and never publishes disabled', async () => { + const [completion] = await beginSession(); + await status('ready'); + jest + .mocked(processes.stopProcessAndDescendantsAsync) + .mockRejectedValue(new Error('child not drained')); + process.emit('SIGTERM', 'SIGTERM'); + expect(await completion).toBeInstanceOf(AggregateError); + expect(await readLauncherStateAsync(boot)).toMatchObject({ + status: 'stopping', + error: expect.stringContaining('cleanup could not be confirmed'), + }); +}); + +test('stop waits on a versionless active state', async () => { + await updateLauncherStateAsync(boot, () => ({ + bootUuid: boot, + status: 'ready', + controllerPid: 9000, + })); + const stopping = stopAsync(boot); + await status('stopping'); + await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' })); + expect(await stopping).toBe(0); +}); + +test('--version prints the exact package version without touching boot or state', async () => { + const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + expect(await mainAsync(['--version'])).toBe(0); + expect(output).toHaveBeenCalledWith(`${require('../../package.json').version}\n`); + expect(commands.runAsync).not.toHaveBeenCalled(); +}); + +test.each(['install', 'adopt', 'run'])('rejects removed command %s', async command => { + await expect(mainAsync([command])).rejects.toThrow('Usage:'); + expect(commands.runAsync).not.toHaveBeenCalled(); +}); +test.each(['linux', 'win32'])('rejects %s before state or native work', async target => { + Object.defineProperty(process, 'platform', { value: target }); + await expect(mainAsync(['start'])).rejects.toThrow('requires macOS'); + expect(await fs.readdir(path.join(root, 'control'))).toEqual([]); +}); + +test.each([ + ['Shutdown', true], + ['Booted', false], +])('native shutdown error for %s is confirmed done: %s', async (state, done) => { + const failure = new Error('shutdown failed'); + jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => { + if (args.includes('shutdown')) { + throw failure; + } + return JSON.stringify({ devices: { ios: [{ udid: 'OLD', state }] } }); + }); + const result = shutdownSimulatorAsync('OLD'); + if (done) { + await expect(result).resolves.toBeUndefined(); + } else { + await expect(result).rejects.toBe(failure); + } +}); 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..515110cd74 --- /dev/null +++ b/packages/simulator-launcher/src/__tests__/simulatorSession.test.ts @@ -0,0 +1,322 @@ +import { type ChildProcess } from 'node:child_process'; + +import { createWarmSimulatorSession } from '../commands/start'; +import { createArgentTool } from '../utils/argent'; +import { + type ChildProcessHandle, + commands, + stopProcessAndDescendantsAsync, +} from '../utils/processes'; +import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; +import { disableApsdAsync, shutdownSimulatorAsync } from '../utils/simulator'; +import { deferred, until } from './helpers'; + +jest.mock('../utils/argent'); +jest.mock('../utils/processes'); +jest.mock('../utils/serveSim'); +jest.mock('../utils/simulator', () => ({ + ...jest.requireActual('../utils/simulator'), + shutdownSimulatorAsync: jest.fn(), + disableApsdAsync: jest.fn(), +})); + +const defaultDevice = { udid: 'DUO', name: 'iPhone Duo' }; +const secondary = { udid: 'PHONE18', name: 'iPhone 18' }; +const otherDevice = { udid: 'OTHER', name: 'iPhone Pro' }; +const serverInfo = { + startupDevice: 'DUO', + pid: 10, + nativeStartTime: 'server-start', + port: 3200, + url: 'http://127.0.0.1:3200', + token: 'fixture-token', +}; +function handle(pid: number): ChildProcessHandle { + return { child: { pid } as ChildProcess, resultPromise: Promise.resolve('') }; +} +const server = handle(10); +const agent = handle(11); +const argent = handle(12); +let controller: AbortController; +let env: NodeJS.ProcessEnv; +let onPreparationFailure: jest.Mock; +const starts = (word: string) => + jest.mocked(commands.start).mock.calls.filter(([, args]) => args.includes(word)); +function createSession() { + return createWarmSimulatorSession({ + signal: controller.signal, + throwIfStoppingAsync: async () => controller.signal.throwIfAborted(), + onPreparationFailure, + }); +} + +beforeEach(() => { + jest.resetAllMocks(); + env = { ...process.env }; + process.env.EXPO_SIMULATOR_DEVICE = 'iPhone Duo'; + process.env.AGENT_DEVICE_STATE_DIR = '/fixture/agent'; + process.env.ARGENT_EVENT_LOG = '/fixture/argent-events'; + controller = new AbortController(); + onPreparationFailure = 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] } }); + } + if (args.includes('launchctl') && args.includes('list')) { + throw Object.assign(new Error('not loaded'), { code: 113 }); + } + return 'controller-start'; + }); + jest + .mocked(commands.start) + .mockImplementation((_file, args) => (args.includes('prepare') ? agent : argent)); + jest.mocked(createArgentTool).mockImplementation(() => ({ + name: 'argent', + startAsync: async () => { + commands.start('bunx', ['argent', 'server']); + }, + stopAsync: async () => { + await stopProcessAndDescendantsAsync(argent); + }, + })); + jest.mocked(startServeSim).mockReturnValue(server); + jest.mocked(waitForServeSimReadyAsync).mockResolvedValue(serverInfo); + jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue(); + jest.mocked(shutdownSimulatorAsync).mockResolvedValue(); + jest + .mocked(disableApsdAsync) + .mockImplementation(jest.requireActual('../utils/simulator').disableApsdAsync); +}); +afterEach(() => { + process.env = env; + jest.restoreAllMocks(); +}); + +it('creates a stoppable owner before any native work and does not start after stop', async () => { + const session = createSession(); + expect(commands.runAsync).not.toHaveBeenCalled(); + await session.stopAsync(); + await expect(session.prepareAsync()).rejects.toThrow(); + expect(startServeSim).not.toHaveBeenCalled(); + expect(shutdownSimulatorAsync).not.toHaveBeenCalled(); +}); + +it('stop joins pending device selection without starting a late server', async () => { + const listing = deferred(); + jest.mocked(commands.runAsync).mockReturnValueOnce(listing.promise); + const session = createSession(); + const preparing = session.prepareAsync(); + void preparing.catch(() => {}); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + listing.resolve(JSON.stringify({ devices: { ios: [defaultDevice] } })); + await stopping; + await expect(preparing).rejects.toThrow(); + expect(startServeSim).not.toHaveBeenCalled(); +}); + +it('starts one orphan with all configured devices and keeps its identity separate from selection', async () => { + const session = createSession(); + const info = await session.prepareAsync(); + expect(startServeSim).toHaveBeenCalledWith(['DUO'], expect.any(AbortSignal)); + expect(info).toMatchObject({ + device: defaultDevice, + server: serverInfo, + controllerPid: process.pid, + controllerStartTime: 'controller-start', + }); + const selected = await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' }); + expect(selected.device).toEqual(otherDevice); + expect(startServeSim).toHaveBeenCalledTimes(1); + expect(commands.runAsync).not.toHaveBeenCalledWith( + 'xcrun', + expect.arrayContaining(['bootstatus']), + expect.anything() + ); + await session.stopAsync(); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO'); + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER'); +}); + +it('primes the exact version once before readiness', async () => { + const priming = deferred(); + const run = jest.mocked(commands.runAsync).getMockImplementation()!; + jest + .mocked(commands.runAsync) + .mockImplementation((file, args, signal) => + file === 'bun' ? priming.promise : run(file, args, signal) + ); + const session = createSession(); + let ready = false; + const preparing = session.prepareAsync().then(() => { + ready = true; + }); + await until(() => jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'bun')); + expect(ready).toBe(false); + expect(jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'bun')).toHaveLength( + 1 + ); + priming.resolve('25.3.0'); + await preparing; + await session.stopAsync(); +}); + +it('reuses the default runner after adoption without preparing it twice', async () => { + const preparation = deferred(); + jest + .mocked(commands.start) + .mockImplementation((_file, args) => + args.includes('prepare') ? { ...agent, resultPromise: preparation.promise } : argent + ); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'agent-device' }); + let ready = false; + const tools = session.prepareToolsAsync().then(() => { + ready = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(ready).toBe(false); + preparation.resolve(''); + await tools; + expect(starts('prepare')).toHaveLength(1); + await session.stopAsync(); +}); + +it('starts the selected runner only in the post-adopt phase, after old runner retirement', async () => { + const retirement = deferred(); + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => { + if (handle === agent) { + await retirement.promise; + } + }); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'agent-device', requestedDevice: 'PHONE18' }); + expect(starts('prepare')).toHaveLength(1); + let finished = false; + const tools = session.prepareToolsAsync().then(() => { + finished = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(finished).toBe(false); + expect(starts('prepare')).toHaveLength(1); + retirement.resolve(); + await tools; + expect(starts('prepare')[1][1]).toEqual(expect.arrayContaining(['--udid', 'PHONE18'])); + await session.stopAsync(); +}); + +it('keeps global Argent when selecting another device', async () => { + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'argent', requestedDevice: 'PHONE18' }); + await session.prepareToolsAsync(); + expect(starts('server')).toHaveLength(1); + expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(argent); + await session.stopAsync(); +}); + +it('rejects a changed server identity before starting the selected runner', async () => { + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'agent-device', requestedDevice: 'PHONE18' }); + jest.mocked(waitForServeSimReadyAsync).mockResolvedValueOnce({ ...serverInfo, pid: 99 }); + await expect(session.prepareToolsAsync()).rejects.toThrow('does not match'); + expect(starts('prepare')).toHaveLength(1); + await session.stopAsync(); +}); + +it('serve-sim tool preparation does not gate on unused tool stops, but terminal cleanup drains them', async () => { + const retirement = deferred(); + jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => { + if (handle === agent) { + await retirement.promise; + } + }); + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'serve-sim' }); + await session.prepareToolsAsync(); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + retirement.resolve(); + await stopping; +}); + +it('a failed unused-tool stop after the claim keeps the session and the stop retries it', async () => { + 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(); + 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); + // 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'); +}); + +it('starts an owned nonblocking apsd attempt after selected adoption and drains it before shutdown', async () => { + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' }); + const setup = deferred(); + const disable = jest.mocked(disableApsdAsync).mockImplementation(() => setup.promise); + try { + await session.prepareToolsAsync(); + expect(disable).toHaveBeenCalledWith('OTHER', expect.any(AbortSignal)); + let stopped = false; + const stopping = session.stopAsync().then(() => { + stopped = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(stopped).toBe(false); + expect(shutdownSimulatorAsync).not.toHaveBeenCalled(); + setup.resolve(); + await stopping; + expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER'); + } finally { + setup.resolve(); + await session.stopAsync(); + } +}); + +it('does not repeat completed default-device setup after adoption', async () => { + const session = createSession(); + await session.prepareAsync(); + jest.mocked(disableApsdAsync).mockClear(); + await session.claimAsync({ engine: 'serve-sim' }); + await session.prepareToolsAsync(); + expect(disableApsdAsync).not.toHaveBeenCalled(); + await session.stopAsync(); +}); + +it('warns on ordinary adopted-device apsd failure without failing the claimed session', async () => { + const session = createSession(); + await session.prepareAsync(); + await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' }); + jest.mocked(disableApsdAsync).mockRejectedValue(new Error('apsd remains loaded')); + const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + await session.prepareToolsAsync(); + await session.stopAsync(); + expect(onPreparationFailure).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 new file mode 100644 index 0000000000..d813f91c8b --- /dev/null +++ b/packages/simulator-launcher/src/commands/start.ts @@ -0,0 +1,495 @@ +import { once } from 'node:events'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; + +import { createAgentDeviceTool } from '../utils/agentDevice'; +import { createArgentTool } from '../utils/argent'; +import { + type ChildProcessHandle, + commands, + stopProcessAndDescendantsAsync, +} from '../utils/processes'; +import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim'; +import { + disableApsdAsync, + listAvailableSimulatorsAsync, + selectSimulators, + shutdownSimulatorAsync, +} from '../utils/simulator'; +import { + type ClaimInput, + ClaimInputSchema, + type LauncherState, + type SimulatorDevice, + type WarmSessionInfo, + 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<{ device: SimulatorDevice }>; + prepareToolsAsync(): Promise; + stopAsync(): Promise; +}; + +export async function startAsync(bootUuid: 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, + launcherVersion: LAUNCHER_VERSION, + }; + }); + if (initial.status === 'disabled') { + return 0; + } + 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?.status === 'stopping' || state?.status === 'disabled') { + requestShutdown(); + } + 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(); + requireOwnedState(await readLauncherStateAsync(bootUuid)); + signal.throwIfAborted(); + } + const session = createWarmSimulatorSession({ + signal, + throwIfStoppingAsync, + onPreparationFailure(error) { + if (!signal.aborted) { + sessionError = error; + } + shutdown.abort(error); + }, + }); + 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(); + } + }); + 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', + ...(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; + } + // Only confirmed cleanup may publish disabled, which lets a job safely start cold. + await updateLauncherStateAsync(bootUuid, state => ({ + ...requireOwnedState(state, 'stopping'), + status: 'disabled', + })); + if (sessionError) { + throw sessionError; + } + } + + try { + try { + if (process.env.EXPO_SIMULATOR_STARTUP !== '1') { + process.stderr.write('EXPO_SIMULATOR_STARTUP is not 1; Simulator warmup is disabled.\n'); + requestShutdown(); + } + await prepareDefaultDeviceAsync(); + await handleOneClaimAsync(); + while (!signal.aborted) { + const state = requireOwnedState(await readLauncherStateAsync(bootUuid), 'claimed'); + if (state.prepareToolsRequested && !state.toolsReady) { + await session.prepareToolsAsync(); + await updateLauncherStateAsync( + bootUuid, + current => ({ + ...requireOwnedState(current, 'claimed'), + toolsReady: true, + }), + signal + ); + break; + } + await sleepAsync(100, undefined, { signal }); + } + if (!signal.aborted) { + await once(signal, 'abort'); + } + } catch (error) { + if (!signal.aborted) { + sessionError ??= error; + } + } + + await finishSessionAsync(); + } finally { + watcherAbort.abort(); + await watcher; + process.off('SIGTERM', requestShutdown); + process.off('SIGINT', requestShutdown); + } +} + +/** Own one orphan process and its tool/preinstall work before any preparation awaits. */ +export function createWarmSimulatorSession({ + signal: parentSignal, + throwIfStoppingAsync, + onPreparationFailure, +}: { + signal: AbortSignal; + throwIfStoppingAsync(): Promise; + onPreparationFailure(error: unknown): void; +}): WarmSimulatorSession { + const lifetime = new AbortController(); + const signal = AbortSignal.any([parentSignal, lifetime.signal]); + const warmTools = [createAgentDeviceTool(), createArgentTool(onPreparationFailure)]; + const startedTools = new Map(); + const ownedDevices = new Set(); + const simulatorSetup: Promise[] = []; + const setupFinished = new Set(); + let preparation: Promise | undefined; + let server: ChildProcessHandle | undefined; + let info: WarmSessionInfo; + let availableDevices: SimulatorDevice[]; + let warmDevices: SimulatorDevice[] = []; + let request: ClaimInput | undefined; + let stopping = false; + let launcherCachePriming: Promise = Promise.resolve(); + let toolRetirement: Promise[]> = Promise.resolve([]); + + function startSimulatorSetup( + udid: string, + ready: Promise, + setupSignal: AbortSignal + ): Promise { + const setup = ready.then(async () => { + setupSignal.throwIfAborted(); + await disableApsdAsync(udid, setupSignal).catch(error => { + setupSignal.throwIfAborted(); + process.stderr.write(`Could not disable apsd on ${udid}; continuing: ${error}\n`); + }); + setupFinished.add(udid); + }); + simulatorSetup.push(setup); + return setup; + } + + async function resolveDeviceAsync(selector: string): Promise { + for (let attempt = 0; ; attempt++) { + const matches = selectSimulators(availableDevices, selector); + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1 || attempt === 1) { + throw new Error( + `Could not select one available Simulator for ${selector}. Use an exact available device name or UDID.` + ); + } + await throwIfStoppingAsync(); + availableDevices = await listAvailableSimulatorsAsync(signal); + } + } + + async function primeLauncherCacheAsync(): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await commands.runAsync( + 'bun', + ['x', `@expo/simulator-launcher@${LAUNCHER_VERSION}`, '--version'], + AbortSignal.any([signal, AbortSignal.timeout(30_000)]) + ); + return; + } catch (error) { + signal.throwIfAborted(); + if (attempt === 1) { + process.stderr.write( + `Could not cache the Simulator launcher version; claim/stop may need the registry: ${error}\n` + ); + } + } + } + } + + async function prepareDefaultAsync(): Promise { + signal.throwIfAborted(); + availableDevices = await listAvailableSimulatorsAsync(signal); + signal.throwIfAborted(); + const selected = selectSimulators( + availableDevices, + process.env.EXPO_SIMULATOR_DEVICE?.trim() || undefined + ); + 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) + ).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 = warmDevices[0]; + // serve-sim owns all boot/selection work, including partial boots during failed adoption. + server = startServeSim( + warmDevices.map(device => device.udid), + signal + ); + for (const device of warmDevices) { + ownedDevices.add(device.udid); + } + const ready = waitForServeSimReadyAsync(first.udid, server, signal); + void ready.catch(() => {}); + for (const tool of warmTools) { + startedTools.set(tool, startTool(tool, first.udid, ready, signal, onPreparationFailure)); + } + const apsd = startSimulatorSetup(first.udid, ready, signal); + launcherCachePriming = primeLauncherCacheAsync(); + const [serverInfo] = await Promise.all([ready, apsd, launcherCachePriming]); + info = { + device: first, + server: serverInfo, + controllerPid: process.pid, + controllerStartTime, + agentDeviceStateDir: process.env.AGENT_DEVICE_STATE_DIR!, + argentEventLogPath: process.env.ARGENT_EVENT_LOG!, + }; + return info; + } + + return { + async prepareAsync() { + return await (preparation ??= prepareDefaultAsync()); + }, + async claimAsync(input) { + if (request || stopping) { + throw new Error('This warm Simulator session has already been claimed or is stopping.'); + } + request = input; + await throwIfStoppingAsync(); + const selected = input.requestedDevice + ? await resolveDeviceAsync(input.requestedDevice) + : warmDevices[0]; + ownedDevices.add(selected.udid); + const requestedTool = warmTools.find(tool => tool.name === input.engine); + const sameDevice = selected.udid === warmDevices[0].udid; + toolRetirement = Promise.allSettled( + [...startedTools] + .filter( + ([tool]) => tool !== requestedTool || (tool.name === 'agent-device' && !sameDevice) + ) + .map(([tool, started]) => { + startedTools.delete(tool); + // The job owns the device now; the terminal stop retries this tool. + return started.stopAsync().catch(error => { + process.stderr.write(`Could not stop ${tool.name}; retrying at stop: ${error}\n`); + throw error; + }); + }) + ); + // Adoption can release an old device; join cancelled launchctl commands before it does. + await Promise.allSettled(simulatorSetup); + info = { ...info, device: selected }; + return { device: selected }; + }, + async prepareToolsAsync() { + if (!request || stopping) { + throw new Error('Claim a warm Simulator session before preparing its tools.'); + } + // This phase cannot start a runner against the old device before private adoption finishes. + const adopted = await waitForServeSimReadyAsync( + info.device.udid, + server!, + signal, + info.server.port + ); + if ( + adopted.pid !== info.server.pid || + adopted.nativeStartTime !== info.server.nativeStartTime || + adopted.url !== info.server.url || + adopted.token !== info.server.token + ) { + throw new Error('The adopted serve-sim process does not match the claimed warm server.'); + } + if (!setupFinished.has(info.device.udid)) { + // A replacement or cancelled secondary can miss warmup setup. Keep this optional work + // nonblocking for tools/app actions, but owned and joined before device teardown. + void startSimulatorSetup(info.device.udid, Promise.resolve(), signal).catch(() => {}); + } + const tool = warmTools.find(tool => tool.name === request!.engine); + if (tool?.name === 'agent-device' && !startedTools.has(tool)) { + const results = await toolRetirement; + const failed = results.find(result => result.status === 'rejected'); + if (failed?.status === 'rejected') { + throw failed.reason; + } + } + await throwIfStoppingAsync(); + if (tool && !startedTools.has(tool)) { + startedTools.set( + tool, + startTool(tool, info.device.udid, Promise.resolve(), signal, onPreparationFailure) + ); + } + if (tool) { + await startedTools.get(tool)!.ready; + } + }, + async stopAsync() { + stopping = true; + const cancellations = [...startedTools.values()].map(tool => tool.cancelAsync()); + lifetime.abort(); + await Promise.allSettled(cancellations); + await preparation?.catch(() => {}); + await Promise.allSettled(simulatorSetup); + await toolRetirement; + await launcherCachePriming.catch(() => {}); + const errors: unknown[] = []; + const attemptAsync = async (operation: Promise) => { + await operation.catch(error => errors.push(error)); + }; + // Keep handles after failure so terminal cleanup never mistakes wrapper exit for drain. + for (const started of startedTools.values()) { + await attemptAsync(started.stopAsync()); + } + for (const tool of warmTools) { + if (!startedTools.has(tool) && server) { + await attemptAsync(tool.stopAsync()); + } + } + if (server) { + await attemptAsync( + stopProcessAndDescendantsAsync(server).then(() => { + server = undefined; + }) + ); + } + await Promise.all( + [...ownedDevices].map(udid => + attemptAsync( + shutdownSimulatorAsync(udid).then(() => { + ownedDevices.delete(udid); + }) + ) + ) + ); + try { + commands.throwIfCommandCloseTimedOut(); + } catch (error) { + errors.push(error); + } + if (errors.length) { + throw new AggregateError( + errors, + 'Warm Simulator cleanup could not be confirmed; services or devices 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..0f182b4937 --- /dev/null +++ b/packages/simulator-launcher/src/main.ts @@ -0,0 +1,55 @@ +import path from 'node:path'; + +import { claimAsync, prepareToolsAsync } from './commands/claim'; +import { startAsync } from './commands/start'; +import { stopAsync } from './commands/stop'; +import { commands } from './utils/processes'; + +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' && + command !== 'prepare-tools' + ) { + throw new Error('Usage: simulator-launcher '); + } + 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); + case 'stop': + return await stopAsync(bootUuid); + case 'claim': + return await claimAsync(bootUuid, engine, requestedDevice); + case 'prepare-tools': + return await prepareToolsAsync(bootUuid); + } +} + +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/yarn.lock b/yarn.lock index 52b1accfef..2cf6e15705 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3128,10 +3128,13 @@ __metadata: 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