From 36b4a24aa3efc7ffa437f7f39db7d32daf80e8c7 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 23:21:30 -0700 Subject: [PATCH] [build-tools] Claim the warm Simulator and adopt its serve-sim Co-Authored-By: Claude Opus 5.5 Edited-by: Codex Edited-by: Codex --- .../prepared-simulator-adoption-helpers.md | 5 + .../steps/utils/IosSimulatorRecordingUtils.ts | 77 +++++++++- .../IosSimulatorRecordingUtils.test.ts | 95 ++++++++++++ .../utils/__tests__/iosAppArtifact.test.ts | 58 +++++++- .../__tests__/warmDeviceSessionHost.test.ts | 114 +++++++++++++++ .../utils/__tests__/warmIosSession.test.ts | 136 ++++++++++++++++++ .../src/steps/utils/deviceSessionHost.ts | 26 +++- .../src/steps/utils/iosAppArtifact.ts | 51 +++++++ .../src/steps/utils/remoteDeviceRunSession.ts | 2 +- .../src/steps/utils/warmDeviceSessionHost.ts | 63 ++++++++ .../src/steps/utils/warmIosSession.ts | 114 +++++++++++++++ .../src/utils/__tests__/warmSimulator.test.ts | 61 ++++++++ .../build-tools/src/utils/warmSimulator.ts | 39 +++++ 13 files changed, 835 insertions(+), 6 deletions(-) create mode 100644 .changeset/prepared-simulator-adoption-helpers.md create mode 100644 packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts create mode 100644 packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts create mode 100644 packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts create mode 100644 packages/build-tools/src/steps/utils/warmIosSession.ts create mode 100644 packages/build-tools/src/utils/__tests__/warmSimulator.test.ts create mode 100644 packages/build-tools/src/utils/warmSimulator.ts diff --git a/.changeset/prepared-simulator-adoption-helpers.md b/.changeset/prepared-simulator-adoption-helpers.md new file mode 100644 index 0000000000..471032ce49 --- /dev/null +++ b/.changeset/prepared-simulator-adoption-helpers.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Add internal helpers that claim the warm Simulator through the launcher and adopt it on the warm serve-sim. Skip the Expo Go install when the warm Simulator already has the same Expo Go version. diff --git a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts index 80b3b89d29..4600a01bf2 100644 --- a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts +++ b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts @@ -57,11 +57,19 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & { hasExited: () => boolean; }; +type AdoptedIosSimulatorRecording = { + udid: string; + serverToken: string; + collectAsync(): Promise; +}; + type IosSimulatorRecordingSession = { env: Env; logger: bunyan; recordingsRootDirectory: string; activeRecordings: Map; + adoptedRecordings: Map; + runtimeDisplayNames: Map; completedRecordings: IosSimulatorRecording[]; recordingFailureCounts: Map; /** Failed attempts for the whole session; unlike recordingFailureCounts, never reset. */ @@ -81,6 +89,50 @@ export namespace IosSimulatorRecordingUtils { serveSimPackageSpec ??= packageSpec; } + /** + * Reserves this job's recording output before warm serve-sim adoption, which starts the + * recording. The poller skips that device, so it never starts a second recorder. + */ + export async function prepareAdoptedRecordingAsync({ + udid, + deviceName, + serverToken, + }: { + udid: string; + deviceName: string; + serverToken: string; + }): Promise<{ output: string; recordingId: string } | undefined> { + const session = activeIosSimulatorRecordingSession; + if (!session) { + return undefined; + } + udid = udid.toUpperCase(); + const recordingId = randomUUID(); + const output = path.join(session.recordingsRootDirectory, recordingId); + const startedAt = new Date(); + await mkdir(output, { recursive: true }); + session.adoptedRecordings.set(recordingId, { + udid, + serverToken, + async collectAsync() { + // The session host already stopped serve-sim, which finalizes its recording. Keep the + // manifest unchanged: the uploader also preserves footage marked as partial or failed. + await access(path.join(output, 'session.json')); + session.completedRecordings.push({ + id: recordingId, + udid: udid as IosSimulatorUuid, + deviceName, + runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime', + outputDirectory: output, + startedAt, + getOutput: () => '', + hasStarted: () => true, + }); + }, + }); + return { output, recordingId }; + } + export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise { if (activeIosSimulatorRecordingSession) { logger.info('iOS Simulator screen recording polling is already running.'); @@ -95,6 +147,8 @@ export namespace IosSimulatorRecordingUtils { logger, recordingsRootDirectory, activeRecordings: new Map(), + adoptedRecordings: new Map(), + runtimeDisplayNames: new Map(), completedRecordings: [], recordingFailureCounts: new Map(), failedAttemptCounts: new Map(), @@ -190,6 +244,17 @@ export namespace IosSimulatorRecordingUtils { }) ); + await Promise.all( + [...session.adoptedRecordings.values()].map(recording => + recording.collectAsync().catch(err => { + logger.warn( + { err }, + 'Could not collect the warm Simulator video recording after shutdown.' + ); + }) + ) + ); + const completedRecordings = [...session.completedRecordings].sort( (a, b) => a.startedAt.getTime() - b.startedAt.getTime() ); @@ -243,6 +308,9 @@ async function pollIosSimulatorRecordingsAsync( break; } listDevicesErrorCount = 0; + for (const device of bootedDevices) { + session.runtimeDisplayNames.set(device.udid.toUpperCase(), device.runtimeDisplayName); + } const readyServers = new Map( (await readServeSimServersAsync(SERVE_SIM_STATE_DIR)).flatMap(server => server.token ? [[server.udid, server.token] as const] : [] @@ -262,7 +330,14 @@ async function pollIosSimulatorRecordingsAsync( for (const device of bootedDevices) { const token = readyServers.get(device.udid); const packageSpec = serveSimPackageSpec; - if (!token || !packageSpec) { + if ( + !token || + !packageSpec || + [...session.adoptedRecordings.values()].some( + recording => + recording.udid === device.udid.toUpperCase() && recording.serverToken === token + ) + ) { continue; } if (session.serveBearerTokens.get(device.udid) !== token) { diff --git a/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts b/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts index e2336c1dea..f6ad52f47f 100644 --- a/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/IosSimulatorRecordingUtils.test.ts @@ -9,12 +9,14 @@ import { setTimeout } from 'node:timers/promises'; import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { Sentry } from '../../../sentry'; +import { turtleFetch } from '../../../utils/turtleFetch'; import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils'; import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; jest.mock('@expo/turtle-spawn'); jest.mock('../serveSimMetricsRecorder'); jest.mock('../../../sentry'); +jest.mock('../../../utils/turtleFetch'); const logger = { info: jest.fn(), @@ -537,3 +539,96 @@ test('records every booted device with the first serve-sim package', async () => currentTime += 311_000; await IosSimulatorRecordingUtils.finishAsync({ logger }); }); + +test('does not request adopted video when job recording is disabled', async () => { + await expect( + IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid, + deviceName: 'iPhone 17 Pro', + serverToken: 'job-token', + }) + ).resolves.toBeUndefined(); + expect(turtleFetch).not.toHaveBeenCalled(); +}); + +test.each(['complete', 'failed'])( + 'collects an adopted %s recording after host shutdown without a control request', + async status => { + let releaseDevices!: (devices: never[]) => void; + jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementation( + () => + new Promise(resolve => { + releaseDevices = resolve as never; + }) + ); + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'job-token' }]); + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid, + deviceName: 'iPhone 17 Pro', + serverToken: 'job-token', + }); + if (!recording) { + throw new Error('Expected the adopted recording output.'); + } + releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never); + await setTimeout(20); + expect(spawn).not.toHaveBeenCalled(); + await writeFile( + `${recording.output}/session.json`, + JSON.stringify({ recording: 'recording.mp4', status }) + ); + jest.mocked(turtleFetch).mockRejectedValue(new Error('serve-sim has stopped')); + expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([ + { + udid, + deviceName: 'iPhone 17 Pro', + runtimeDisplayName: 'iOS 26.4', + directory: recording.output, + }, + ]); + expect(turtleFetch).not.toHaveBeenCalled(); + } +); + +test('does not publish an adopted recording without a manifest', async () => { + jest.mocked(readServeSimServersAsync).mockResolvedValue([]); + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid, + deviceName: 'iPhone', + serverToken: 'job-token', + }); + expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + { err: expect.any(Error) }, + 'Could not collect the warm Simulator video recording after shutdown.' + ); + expect(turtleFetch).not.toHaveBeenCalled(); +}); + +test('a reserved adopted recording does not suppress recording a replacement server token', async () => { + let releaseDevices!: (devices: never[]) => void; + jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockImplementationOnce( + () => + new Promise(resolve => { + releaseDevices = resolve as never; + }) + ); + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([{ udid, url: 'http://127.0.0.1:3200', token: 'replacement-token' }]); + mockFailingRecorder(); + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid, + deviceName: 'iPhone', + serverToken: 'old-token', + }); + jest.mocked(turtleFetch).mockRejectedValue(new Error('old server stopped')); + releaseDevices([{ udid, name: 'iPhone', runtimeDisplayName: 'iOS 26.4' }] as never); + await waitForRecorderSpawnAsync(); + expect(spawn).toHaveBeenCalledTimes(1); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts index 5e3e437146..dfd560a180 100644 --- a/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { readIosApplicationIdentifierAsync } from '../iosAppArtifact'; +import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact'; jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); jest.unmock('fs'); @@ -61,3 +61,59 @@ describe(readIosApplicationIdentifierAsync, () => { ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' }); }); }); + +describe(isPreinstalledExpoGoAsync, () => { + const preinstalled = { + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.7', + version: '58.0.7', + }; + function plist(values: Record) { + jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({ + stdout: `${values[args[1]]}\n`, + })) as never); + } + const app = (shortVersion: string, bundleIdentifier = 'host.exp.Exponent') => ({ + CFBundleIdentifier: bundleIdentifier, + CFBundleShortVersionString: shortVersion, + CFBundleVersion: shortVersion, + }); + + beforeEach(() => { + jest.mocked(spawn).mockReset(); + }); + + it('matches the preinstalled Expo Go without asking the Simulator', async () => { + plist(app('58.0.7')); + await expect( + isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} }) + ).resolves.toBe(true); + expect(jest.mocked(spawn).mock.calls.every(([command]) => command === 'plutil')).toBe(true); + }); + + it('does not match a different Expo Go version', async () => { + plist(app('58.0.6')); + await expect( + isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} }) + ).resolves.toBe(false); + }); + + it('never matches a custom build, even with the same version', async () => { + plist(app('58.0.7', 'com.example.app')); + await expect( + isPreinstalledExpoGoAsync({ + appPath: '/job/Example.app', + preinstalled: { ...preinstalled, bundleIdentifier: 'com.example.app' }, + env: {}, + }) + ).resolves.toBe(false); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('does not match an app it cannot read', async () => { + jest.mocked(spawn).mockRejectedValue(new Error('plutil failed')); + await expect( + isPreinstalledExpoGoAsync({ appPath: '/job/Expo Go.app', preinstalled, env: {} }) + ).resolves.toBe(false); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts new file mode 100644 index 0000000000..0cc7013685 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts @@ -0,0 +1,114 @@ +import { type bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; + +import { type CustomBuildContext } from '../../../customBuildContext'; +import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils'; +import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession'; +import { WarmSessionAdoptionError, createWarmDeviceSessionServer } from '../warmDeviceSessionHost'; +import { type WarmIosSession } from '../warmIosSession'; + +jest.mock('@expo/turtle-spawn'); +jest.mock('../IosSimulatorRecordingUtils'); +jest.mock('../remoteDeviceRunSession', () => ({ + ...jest.requireActual('../remoteDeviceRunSession'), + fetchWebPreviewTurnArgsAsync: jest.fn(), +})); + +const ctx = {} as CustomBuildContext; +const logger = {} as bunyan; +let warm: WarmIosSession; + +function launch(signal?: AbortSignal) { + return { + command: 'bunx', + args: ['serve-sim', '--port', '3200'], + env: {}, + logger, + signal, + secrets: [], + stopGracePeriodMs: 1_000, + }; +} + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(fetchWebPreviewTurnArgsAsync).mockResolvedValue(['--turn', 'creds']); + jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never); + jest + .mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync) + .mockResolvedValue({ output: '/recordings/id', recordingId: 'id' }); + warm = { + device: { udid: 'UDID', name: 'iPhone 17' }, + server: { pid: 10, port: 3200, token: 'serve-token' }, + agentDeviceStateDir: '/agent', + argentEventLogPath: '/argent/events', + preinstalledExpoGo: { + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '2.0', + version: '1', + }, + prepareToolsAsync: jest.fn().mockResolvedValue(undefined), + process: { + pid: 10, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +it('adopts the warm device on the warm port with its recording, then prepares tools', async () => { + const server = createWarmDeviceSessionServer(ctx, logger, warm); + expect(server).toMatchObject({ + port: 3200, + requiresPreviewRetirement: true, + preinstalledApp: warm.preinstalledExpoGo, + }); + const handle = await server.startAsync(launch()); + expect(spawn).toHaveBeenCalledWith( + 'bunx', + [ + 'serve-sim', + '--port', + '3200', + '--single-device', + 'UDID', + '--adopt', + '--recording-output', + '/recordings/id', + '--recording-id', + 'id', + ], + expect.objectContaining({ stdio: 'pipe' }) + ); + expect(warm.prepareToolsAsync).toHaveBeenCalled(); + expect(handle.pid).toBe(10); + await handle.stopAsync(); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); +}); + +it.each([ + ['adoption', () => jest.mocked(spawn).mockRejectedValue(new Error('adopt --turn creds failed'))], + [ + 'tool preparation', + () => jest.mocked(warm.prepareToolsAsync).mockRejectedValue(new Error('no tool')), + ], +])('reports a %s failure as an adoption error without its details', async (_name, setup) => { + setup(); + const error = await createWarmDeviceSessionServer(ctx, logger, warm) + .startAsync(launch()) + .catch(error => error); + expect(error).toBeInstanceOf(WarmSessionAdoptionError); + expect(String(error)).not.toContain('creds'); +}); + +it('reports a cancellation as a cancellation', async () => { + const controller = new AbortController(); + jest.mocked(spawn).mockImplementation((() => { + controller.abort(new Error('cancelled')); + return Promise.reject(new Error('killed')); + }) as never); + await expect( + createWarmDeviceSessionServer(ctx, logger, warm).startAsync(launch(controller.signal)) + ).rejects.toThrow('cancelled'); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts new file mode 100644 index 0000000000..8863b0a97a --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts @@ -0,0 +1,136 @@ +import { type bunyan } from '@expo/logger'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator'; +import { tryClaimWarmIosSessionAsync } from '../warmIosSession'; + +jest.mock('../../../utils/warmSimulator'); + +const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; +const env = { EAS_USE_WARM_SIMULATOR: '1' }; +const claim = { + status: 'claimed', + device: { udid: 'UDID', name: 'iPhone 17' }, + server: { pid: 10, port: 3200, token: 'serve-token', url: 'http://127.0.0.1:3200' }, + agentDeviceStateDir: '/agent', + argentEventLogPath: '/argent/events', + preinstalledExpoGo: { bundleIdentifier: 'host.exp.Exponent', shortVersion: '2.0', version: '1' }, +}; + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(stopWarmSimulatorAsync).mockResolvedValue(); + jest + .mocked(runSimulatorLauncherAsync) + .mockImplementation(async ([command]) => (command === 'claim' ? JSON.stringify(claim) : '{}')); +}); +afterEach(() => jest.restoreAllMocks()); + +it('does not claim when the job does not use the warm Simulator', async () => { + await expect( + tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env: {}, logger }) + ).resolves.toBeUndefined(); + expect(runSimulatorLauncherAsync).not.toHaveBeenCalled(); + expect(stopWarmSimulatorAsync).not.toHaveBeenCalled(); +}); + +it('claims the requested device and returns the launcher record', async () => { + const session = await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: 'iPhone 17', + env, + logger, + }); + expect(runSimulatorLauncherAsync).toHaveBeenCalledWith(['claim', 'serve-sim', 'iPhone 17'], { + env, + signal: undefined, + }); + expect(session).toMatchObject({ + device: claim.device, + server: { pid: 10, port: 3200, token: 'serve-token' }, + preinstalledExpoGo: claim.preinstalledExpoGo, + }); + await session!.prepareToolsAsync(); + expect(runSimulatorLauncherAsync).toHaveBeenLastCalledWith(['prepare-tools'], { + env, + signal: undefined, + }); + expect(session!.tools).toBeUndefined(); +}); + +it('takes the preinstalled Expo Go that prepare-tools reports', async () => { + const expoGo = { bundleIdentifier: 'host.exp.Exponent', shortVersion: '3.0', version: '2' }; + jest + .mocked(runSimulatorLauncherAsync) + .mockImplementation(async ([command]) => + command === 'claim' + ? JSON.stringify({ ...claim, preinstalledExpoGo: undefined }) + : JSON.stringify({ preinstalledExpoGo: expoGo }) + ); + const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger }); + expect(session!.preinstalledExpoGo).toBeUndefined(); + await session!.prepareToolsAsync(); + expect(session!.preinstalledExpoGo).toEqual(expoGo); +}); + +it('reads the agent-device server after tool preparation', async () => { + jest + .spyOn(fs, 'readFile') + .mockResolvedValue(JSON.stringify({ pid: 20, httpPort: 4310, token: 'agent-token' })); + const session = await tryClaimWarmIosSessionAsync({ engine: 'agent-device', env, logger }); + await session!.prepareToolsAsync(); + expect(fs.readFile).toHaveBeenCalledWith(path.join('/agent', 'daemon.json'), 'utf8'); + expect(session!.tools).toEqual({ pid: 20, port: 4310, token: 'agent-token' }); +}); + +it('uses the Argent identity validated by the launcher without scanning state files again', async () => { + const tools = { pid: 30, port: 3201, token: 'argent-token' }; + const readDirectory = jest.spyOn(fs, 'readdir'); + const readFile = jest.spyOn(fs, 'readFile'); + jest + .mocked(runSimulatorLauncherAsync) + .mockImplementation(async ([command]) => + JSON.stringify(command === 'claim' ? claim : { tools }) + ); + const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger }); + await session?.prepareToolsAsync(); + expect(session?.tools).toEqual(tools); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); +}); + +it('rejects Argent preparation when the launcher returns no validated identity', async () => { + const session = await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger }); + await expect(session?.prepareToolsAsync()).rejects.toThrow(); +}); + +it.each([ + [ + 'a failed claim', + {}, + () => jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')), + ], + ['a pinned package version', { packageVersion: '1.2.3' }, () => {}], + ['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}], +])('stops warmup and returns nothing for %s', async (_name, options, setup) => { + setup(); + await expect( + tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger, ...options }) + ).resolves.toBeUndefined(); + expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1); +}); + +it('rejects when warmup cleanup cannot be confirmed', async () => { + jest.mocked(runSimulatorLauncherAsync).mockRejectedValue(new Error('no session')); + jest.mocked(stopWarmSimulatorAsync).mockRejectedValue(new Error('cleanup failed')); + await expect(tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).rejects.toThrow( + 'cleanup failed' + ); +}); + +it('stops the claimed session once', async () => { + const session = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger }); + await Promise.all([session!.process.stopAsync(), session!.process.stopAsync()]); + expect(stopWarmSimulatorAsync).toHaveBeenCalledTimes(1); +}); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index f62bcde244..61b6bfbc30 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -49,7 +49,11 @@ import { import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; import { startLogPhase } from '../../utils/logPhase'; import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions'; -import { readIosApplicationIdentifierAsync } from './iosAppArtifact'; +import { + type IosAppIdentity, + isPreinstalledExpoGoAsync, + readIosApplicationIdentifierAsync, +} from './iosAppArtifact'; import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity'; import { verifyLocalEgressGuardAsync } from './localEgressGuard'; import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; @@ -421,6 +425,8 @@ export type DeviceSessionServer = { turnArgs: Promise; /** Cold iOS setup runs after control readiness, alongside app installation and before launch. */ setupSimulatorAsync?(options: { signal: AbortSignal; logger: bunyan }): Promise; + /** An app the server's launcher installed and never launched. */ + preinstalledApp?: IosAppIdentity; /** Another session can start on this machine, so an unconfirmed tunnel close fails cleanup. */ requiresPreviewRetirement: boolean; startAsync(launch: DeviceSessionServerLaunch): Promise; @@ -893,8 +899,22 @@ async function startDeviceSessionHostInternalAsync( signal: startup.signal, }); // The bundle that has not run yet: just installed, or preinstalled and never launched. - let notYetLaunched: string | undefined; - if (app.installAppPath) { + let notYetLaunched = + !app.installAppPath && app.launchAppIdentifier === server.preinstalledApp?.bundleIdentifier + ? server.preinstalledApp?.bundleIdentifier + : undefined; + if ( + app.installAppPath && + server.preinstalledApp && + (await isPreinstalledExpoGoAsync({ + appPath: app.installAppPath, + preinstalled: server.preinstalledApp, + env, + })) + ) { + logger.info('This Expo Go version is already installed on the Simulator.'); + notYetLaunched = server.preinstalledApp.bundleIdentifier; + } else if (app.installAppPath) { const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir); stagedAppDirectory = staged.directory; signal?.throwIfAborted(); diff --git a/packages/build-tools/src/steps/utils/iosAppArtifact.ts b/packages/build-tools/src/steps/utils/iosAppArtifact.ts index 145f0bae0e..2bdb23f67d 100644 --- a/packages/build-tools/src/steps/utils/iosAppArtifact.ts +++ b/packages/build-tools/src/steps/utils/iosAppArtifact.ts @@ -39,3 +39,54 @@ export async function readIosApplicationIdentifierAsync({ } return applicationIdentifier; } + +const EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent'; + +export type IosAppIdentity = { bundleIdentifier: string; shortVersion: string; version: string }; + +/** + * Expo Go releases are identified by their version, so a preinstalled copy with the same version is + * the same app. Custom builds can reuse a version, so they are always installed. + */ +export async function isPreinstalledExpoGoAsync({ + appPath, + preinstalled, + env, +}: { + appPath: string; + preinstalled: IosAppIdentity; + env: BuildStepEnv; +}): Promise { + if (preinstalled.bundleIdentifier !== EXPO_GO_BUNDLE_IDENTIFIER) { + return false; + } + try { + const requested = await readIosAppIdentityAsync(appPath, env); + return ( + requested.bundleIdentifier === preinstalled.bundleIdentifier && + requested.shortVersion === preinstalled.shortVersion && + requested.version === preinstalled.version + ); + } catch { + // An app that cannot be read is installed as usual. + return false; + } +} + +async function readIosAppIdentityAsync( + appPath: string, + env: BuildStepEnv +): Promise { + const infoPlistPath = path.join(appPath, 'Info.plist'); + const [bundleIdentifier, shortVersion, version] = await Promise.all( + ['CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion'].map(async key => + ( + await spawn('plutil', ['-extract', key, 'raw', '-o', '-', infoPlistPath], { + stdio: 'pipe', + env, + }) + ).stdout.trim() + ) + ); + return { bundleIdentifier, shortVersion, version }; +} diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 5df5846b0e..d66911f77b 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -601,7 +601,7 @@ export function isProcessRunning(pid: number): boolean { } } -async function stopDetachedProcessAsync( +export async function stopDetachedProcessAsync( pid: number | undefined, gracePeriodMs = 5_000 ): Promise { diff --git a/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts new file mode 100644 index 0000000000..3b70f7c1a5 --- /dev/null +++ b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts @@ -0,0 +1,63 @@ +import type { bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; + +import { type CustomBuildContext } from '../../customBuildContext'; +import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils'; +import { type DeviceSessionServer } from './deviceSessionHost'; +import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession'; +import { type WarmIosSession } from './warmIosSession'; + +/** Adoption failed before app actions, so the caller can stop warmup and start cold. */ +export class WarmSessionAdoptionError extends Error { + constructor() { + // The adoption command line includes TURN credentials, so the cause stays out of the message. + super('Could not adopt the warm Simulator for this job.'); + this.name = 'WarmSessionAdoptionError'; + } +} + +/** A session server that adopts the warm serve-sim for the job's device instead of launching one. */ +export function createWarmDeviceSessionServer( + ctx: CustomBuildContext, + logger: bunyan, + warm: WarmIosSession +): DeviceSessionServer { + const { udid, name } = warm.device; + async function stopAsync(): Promise { + await warm.process.stopAsync(); + } + return { + port: warm.server.port, + turnArgs: fetchWebPreviewTurnArgsAsync(ctx, { logger }), + // Set by prepare-tools, which the host runs before it installs the app. + get preinstalledApp() { + return warm.preinstalledExpoGo; + }, + requiresPreviewRetirement: true, + stopAsync, + async startAsync({ command, args, env, signal }) { + try { + // serve-sim starts this job's recording when it adopts the device. + const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid, + deviceName: name, + serverToken: warm.server.token, + }); + const recordingArgs = recording + ? ['--recording-output', recording.output, '--recording-id', recording.recordingId] + : []; + const timeout = AbortSignal.timeout(360_000); + await spawn(command, [...args, '--single-device', udid, '--adopt', ...recordingArgs], { + env, + stdio: 'pipe', + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + await warm.prepareToolsAsync(signal); + } catch { + signal?.throwIfAborted(); + throw new WarmSessionAdoptionError(); + } + return { ...warm.process, stopAsync }; + }, + }; +} diff --git a/packages/build-tools/src/steps/utils/warmIosSession.ts b/packages/build-tools/src/steps/utils/warmIosSession.ts new file mode 100644 index 0000000000..279bf8d00a --- /dev/null +++ b/packages/build-tools/src/steps/utils/warmIosSession.ts @@ -0,0 +1,114 @@ +import { type bunyan } from '@expo/logger'; +import { type BuildStepEnv } from '@expo/steps'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; + +import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator'; +import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession'; + +const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() }); +const ClaimSchema = z.object({ + device: z.object({ udid: z.string(), name: z.string() }), + server: ServiceSchema, + agentDeviceStateDir: z.string(), + argentEventLogPath: z.string(), + preinstalledExpoGo: z + .object({ bundleIdentifier: z.string(), shortVersion: z.string(), version: z.string() }) + .optional(), +}); + +type Service = z.infer; + +/** The warm Simulator that the launcher handed to this job. */ +export type WarmIosSession = z.infer & { + /** The selected tool's server; set by prepareToolsAsync. */ + tools?: Service; + /** Runs after adoption: the launcher prepares the selected tool for the adopted device. */ + prepareToolsAsync(signal?: AbortSignal): Promise; + process: DetachedProcessHandle; +}; + +/** + * Claims the warm Simulator. The launcher checks the session; build-tools only uses it. On any + * failure, warmup stops and the caller uses a cold start; a failed stop rejects. + */ +export async function tryClaimWarmIosSessionAsync({ + engine, + requestedDevice, + packageVersion, + env, + logger, + signal, +}: { + engine: 'serve-sim' | 'agent-device' | 'argent' | 'appium'; + requestedDevice?: string; + packageVersion?: string; + env: BuildStepEnv; + logger: bunyan; + signal?: AbortSignal; +}): Promise { + if (env.EAS_USE_WARM_SIMULATOR !== '1') { + return undefined; + } + try { + // Warm tools run the latest release with the VM's Simulator settings. + if (packageVersion && packageVersion !== 'latest') { + throw new Error('A pinned package_version needs a cold start.'); + } + const setting = Object.keys(env).find( + name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_') + ); + if (setting) { + throw new Error(`${setting} needs a cold start.`); + } + const claimArgs = ['claim', engine, ...(requestedDevice ? [requestedDevice] : [])]; + const claim = ClaimSchema.parse( + JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal })) + ); + logger.info(`Claimed the warm Simulator ${claim.device.name} (${claim.device.udid}).`); + let stopping: Promise | undefined; + const session: WarmIosSession = { + ...claim, + async prepareToolsAsync(prepareSignal = signal) { + const prepared = ClaimSchema.pick({ preinstalledExpoGo: true }) + .extend({ tools: ServiceSchema.optional() }) + .parse( + JSON.parse( + await runSimulatorLauncherAsync(['prepare-tools'], { env, signal: prepareSignal }) + ) + ); + // The launcher reports the Expo Go it preinstalled once its install has finished. + session.preinstalledExpoGo = prepared.preinstalledExpoGo ?? session.preinstalledExpoGo; + if (engine === 'agent-device') { + const daemon = z + .object({ pid: z.number(), httpPort: z.number(), token: z.string() }) + .parse(await readJsonAsync(path.join(claim.agentDeviceStateDir, 'daemon.json'))); + session.tools = { pid: daemon.pid, port: daemon.httpPort, token: daemon.token }; + } else if (engine === 'argent') { + session.tools = ServiceSchema.parse(prepared.tools); + } + }, + process: { + pid: claim.server.pid, + getOutput: () => '', + getExitError: () => + isProcessRunning(claim.server.pid) && + (!session.tools || isProcessRunning(session.tools.pid)) + ? undefined + : new Error('A service of the warm Simulator session has exited.'), + stopAsync: () => (stopping ??= stopWarmSimulatorAsync(env)), + }, + }; + return session; + } catch (error) { + logger.warn({ err: error }, 'Could not use the warm Simulator; stopping it for a cold start.'); + await stopWarmSimulatorAsync(env); + signal?.throwIfAborted(); + return undefined; + } +} + +async function readJsonAsync(file: string): Promise { + return JSON.parse(await fs.readFile(file, 'utf8')); +} diff --git a/packages/build-tools/src/utils/__tests__/warmSimulator.test.ts b/packages/build-tools/src/utils/__tests__/warmSimulator.test.ts new file mode 100644 index 0000000000..c69614b8d8 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/warmSimulator.test.ts @@ -0,0 +1,61 @@ +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; + +import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../warmSimulator'; + +jest.mock('@expo/turtle-spawn'); + +const originalPlatform = process.platform; + +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + jest.resetAllMocks(); + jest.mocked(spawn).mockResolvedValue({ stdout: '{"ok":true}' } as never); +}); +afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +it('runs the launcher version that start recorded and returns its output', async () => { + jest.spyOn(fs, 'readFile').mockResolvedValue('{"launcherVersion":"27.1.0"}'); + await expect(runSimulatorLauncherAsync(['claim', 'serve-sim'], { env: {} })).resolves.toBe( + '{"ok":true}' + ); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@27.1.0', 'claim', 'serve-sim'], + expect.objectContaining({ stdio: 'pipe' }) + ); + expect(jest.mocked(spawn).mock.calls[0][2]).not.toHaveProperty('logger'); +}); + +it.each([ + [{}, 'bun', ['x']], + [{ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' }, 'npx', ['--yes']], +])( + 'stops with the latest launcher and the configured package manager for %j', + async (env, command, prefix) => { + jest + .spyOn(fs, 'readFile') + .mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + await stopWarmSimulatorAsync(env); + expect(spawn).toHaveBeenCalledWith( + command, + [...prefix, '@expo/simulator-launcher@latest', 'stop'], + expect.objectContaining({ env }) + ); + } +); + +it('rejects when the launcher cannot confirm cleanup', async () => { + jest.spyOn(fs, 'readFile').mockResolvedValue('{}'); + jest.mocked(spawn).mockRejectedValue(new Error('cleanup failed')); + await expect(stopWarmSimulatorAsync({})).rejects.toThrow('cleanup failed'); +}); + +it('does nothing on other platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + await stopWarmSimulatorAsync({}); + expect(spawn).not.toHaveBeenCalled(); +}); diff --git a/packages/build-tools/src/utils/warmSimulator.ts b/packages/build-tools/src/utils/warmSimulator.ts new file mode 100644 index 0000000000..68b3d7ec06 --- /dev/null +++ b/packages/build-tools/src/utils/warmSimulator.ts @@ -0,0 +1,39 @@ +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; + +import { + PackageManager, + resolveConfiguredPackageManager, + resolvePackageExec, +} from './packageManager'; + +/** + * Runs a Simulator launcher command with the version that `start` recorded, which warmup cached. + * The output can contain credentials, so it is returned and never logged. + */ +export async function runSimulatorLauncherAsync( + launcherArgs: string[], + { env, signal }: { env: BuildStepEnv; signal?: AbortSignal } +): Promise { + const version = await fs + .readFile('/tmp/expo-simulator-startup.json', 'utf8') + .then(text => JSON.parse(text).launcherVersion) + .catch(() => undefined); + const { command, args } = resolvePackageExec( + resolveConfiguredPackageManager(env, PackageManager.BUN), + [ + `@expo/simulator-launcher@${typeof version === 'string' ? version : 'latest'}`, + ...launcherArgs, + ] + ); + const { stdout } = await spawn(command, args, { env, stdio: 'pipe', signal, timeout: 425_000 }); + return stdout; +} + +/** Stops warmup; resolves only after the launcher confirms cleanup. */ +export async function stopWarmSimulatorAsync(env: BuildStepEnv): Promise { + if (process.platform === 'darwin') { + await runSimulatorLauncherAsync(['stop'], { env }); + } +}