diff --git a/.changeset/prepared-simulator-adoption-helpers.md b/.changeset/prepared-simulator-adoption-helpers.md new file mode 100644 index 0000000000..6403d6016c --- /dev/null +++ b/.changeset/prepared-simulator-adoption-helpers.md @@ -0,0 +1,13 @@ +--- +'@expo/build-tools': patch +--- + +Add internal helpers that claim the warm Simulator through the launcher and check the device that the claim returns. serve-sim adoption starts the job's video recording before app actions, and the job collects the recording from serve-sim without a separate recorder process. + +Tunnels open while adoption runs. A warm Simulator already has apsd disabled, so the job skips Simulator setup. + +When a warm session stops, finish pending agent-device artifact uploads and check for final artifacts. + +Every session host opens the web preview tunnel while it starts, so `openPreviewAsync()` returns the startup tunnel. + +Skip the Expo Go install on a warm Simulator when the claim record names the same Expo Go version, and launch Expo Go without the restart check. Also launch a just-installed app without the restart check when it is the app being launched. diff --git a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts index 80b3b89d29..76bf08de3f 100644 --- a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts +++ b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts @@ -4,11 +4,13 @@ import spawn from '@expo/turtle-spawn'; import { type ChildProcess } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { access, mkdir, mkdtemp } from 'node:fs/promises'; +import { z } from 'zod'; import os from 'node:os'; import path from 'node:path'; import { setTimeout } from 'node:timers/promises'; import { Sentry } from '../../sentry'; +import { turtleFetch } from '../../utils/turtleFetch'; import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; import { PackageManager, @@ -57,11 +59,22 @@ type ActiveIosSimulatorRecording = IosSimulatorRecording & { hasExited: () => boolean; }; +type AdoptedIosSimulatorRecording = { + udid: string; + serverToken: string; + output: string; + recordingId: string; + ready: Promise; + finishAsync(): 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 +94,80 @@ export namespace IosSimulatorRecordingUtils { serveSimPackageSpec ??= packageSpec; } + /** Reserve this job's output before adoption so the poller cannot start a second writer. */ + export async function prepareAdoptedRecordingAsync({ + udid, + deviceName, + serverToken, + recordingUrl, + }: { + udid: string; + deviceName: string; + serverToken: string; + recordingUrl: string; + }): Promise<{ output: string; recordingId: string; finishAsync(): Promise } | undefined> { + const session = activeIosSimulatorRecordingSession; + udid = udid.toUpperCase(); + if (!session) { + return undefined; + } + const existing = [...session.adoptedRecordings.values()].find( + recording => recording.udid === udid && recording.serverToken === serverToken + ); + if (existing) { + await existing.ready; + return existing; + } + if (session.activeRecordings.has(udid as IosSimulatorUuid)) { + throw new Error('This Simulator already has a recording managed by the job.'); + } + const recordingId = randomUUID(); + const output = path.join(session.recordingsRootDirectory, recordingId); + const startedAt = new Date(); + let finishing: Promise | undefined; + const recording: AdoptedIosSimulatorRecording = { + udid, + serverToken, + output, + recordingId, + ready: mkdir(output, { recursive: true }).then(() => {}), + finishAsync: () => + (finishing ??= (async () => { + await recording.ready; + const response = await turtleFetch(recordingUrl, 'DELETE', { + headers: { Authorization: `Bearer ${serverToken}`, 'x-recording-id': recordingId }, + retries: 0, + timeout: SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS, + }); + if (response.status !== 200) { + throw new Error( + `Could not finish the warm Simulator video recording (HTTP ${response.status}); complete footage is not confirmed.` + ); + } + const result = z.object({ manifest: z.string() }).parse(await response.json()); + if (result.manifest !== path.join(output, 'session.json')) { + throw new Error( + 'The warm Simulator recording returned an unexpected manifest path for this job.' + ); + } + await access(result.manifest); + session.completedRecordings.push({ + id: recordingId, + udid: udid as IosSimulatorUuid, + deviceName, + runtimeDisplayName: session.runtimeDisplayNames.get(udid) ?? 'Unknown iOS runtime', + outputDirectory: output, + startedAt, + getOutput: () => '', + hasStarted: () => true, + }); + })()), + }; + session.adoptedRecordings.set(recordingId, recording); + await recording.ready; + return recording; + } + 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 +182,8 @@ export namespace IosSimulatorRecordingUtils { logger, recordingsRootDirectory, activeRecordings: new Map(), + adoptedRecordings: new Map(), + runtimeDisplayNames: new Map(), completedRecordings: [], recordingFailureCounts: new Map(), failedAttemptCounts: new Map(), @@ -190,6 +279,17 @@ export namespace IosSimulatorRecordingUtils { }) ); + await Promise.all( + [...session.adoptedRecordings.values()].map(recording => + recording.finishAsync().catch(err => { + logger.warn( + { err }, + 'Could not finish the warm Simulator video recording; complete footage is not confirmed.' + ); + }) + ) + ); + const completedRecordings = [...session.completedRecordings].sort( (a, b) => a.startedAt.getTime() - b.startedAt.getTime() ); @@ -243,6 +343,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 +365,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..56cb6df993 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,139 @@ 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', + recordingUrl: 'http://127.0.0.1:3200/helper/device/recording/video', + }) + ).resolves.toBeUndefined(); + expect(turtleFetch).not.toHaveBeenCalled(); +}); + +test('reserves adoption against the poller and awaits exact owned finalization once', async () => { + 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 args = { + udid, + deviceName: 'iPhone 17 Pro', + serverToken: 'job-token', + recordingUrl: `http://127.0.0.1:3200/helper/${udid}/recording/video`, + }; + const [recording, reused] = await Promise.all([ + IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync(args), + IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ ...args, udid: udid.toLowerCase() }), + ]); + expect(reused).toBe(recording); + releaseDevices([{ udid, name: 'iPhone 17 Pro', runtimeDisplayName: 'iOS 26.4' }] as never); + await setTimeout(20); + expect(spawn).not.toHaveBeenCalled(); + const manifest = `${recording!.output}/session.json`; + await writeFile(manifest, JSON.stringify({ recording: 'recording.mp4' })); + let finalize!: () => void; + jest.mocked(turtleFetch).mockImplementation(async () => { + await new Promise(resolve => { + finalize = resolve; + }); + return { status: 200, json: async () => ({ manifest }) } as never; + }); + let collected = false; + const finishing = IosSimulatorRecordingUtils.finishAsync({ logger }).then(result => { + collected = true; + return result; + }); + await setTimeout(20); + expect(collected).toBe(false); + const sameFinalization = recording!.finishAsync(); + finalize(); + const result = await finishing; + await sameFinalization; + expect(result).toEqual([ + { + udid, + deviceName: 'iPhone 17 Pro', + runtimeDisplayName: 'iOS 26.4', + directory: recording!.output, + }, + ]); + expect(turtleFetch).toHaveBeenCalledTimes(1); + expect(turtleFetch).toHaveBeenCalledWith( + args.recordingUrl, + 'DELETE', + expect.objectContaining({ + retries: 0, + timeout: 120000, + headers: { Authorization: 'Bearer job-token', 'x-recording-id': recording!.recordingId }, + }) + ); + expect(spawn).not.toHaveBeenCalled(); +}); + +test.each(['cancelled', 'wrong-manifest', 'stop-failed'])( + 'does not publish adopted footage after %s even if a manifest file exists', + async failure => { + jest.mocked(readServeSimServersAsync).mockResolvedValue([]); + await IosSimulatorRecordingUtils.startAsync({ env, logger }); + const recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid, + deviceName: 'iPhone', + serverToken: 'job-token', + recordingUrl: `http://127.0.0.1:3200/helper/${udid}/recording/video`, + }); + await writeFile( + `${recording!.output}/session.json`, + JSON.stringify({ recording: 'recording.mp4' }) + ); + jest.mocked(turtleFetch).mockImplementation(async () => { + if (failure === 'stop-failed') { + throw new Error('native finalization failed'); + } + return { + status: failure === 'cancelled' ? 202 : 200, + json: async () => ({ manifest: '/foreign/session.json' }), + } as never; + }); + expect(await IosSimulatorRecordingUtils.finishAsync({ logger })).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + { err: expect.any(Error) }, + 'Could not finish the warm Simulator video recording; complete footage is not confirmed.' + ); + } +); + +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', + recordingUrl: `http://127.0.0.1:3200/helper/${udid}/recording/video`, + }); + 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__/agentDeviceArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/agentDeviceArtifacts.test.ts index bfcd1c0ee9..ad4959154b 100644 --- a/packages/build-tools/src/steps/utils/__tests__/agentDeviceArtifacts.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/agentDeviceArtifacts.test.ts @@ -1,6 +1,7 @@ import { bunyan } from '@expo/logger'; import fetch from 'node-fetch'; import { Readable } from 'node:stream'; +import timers from 'node:timers/promises'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; @@ -15,6 +16,7 @@ jest.mock('../deviceRunSessionArtifacts'); jest.mock('../../../sentry'); jest.mock('node-fetch'); +const sleepWithSignal = timers.setTimeout; const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch'); async function readStreamAsync(stream: NodeJS.ReadableStream): Promise { @@ -276,6 +278,14 @@ describe(uploadAgentDeviceArtifactAsync, () => { describe(pollAgentDeviceArtifactsForUploadAsync, () => { beforeEach(() => { jest.useFakeTimers(); + // Keep fake-clock tests deterministic; cancellation tests use the native abortable timer. + jest + .spyOn(timers, 'setTimeout') + .mockImplementation((delay, value, options) => + options?.signal + ? sleepWithSignal(delay, value, options) + : new Promise(resolve => setTimeout(resolve, delay, value)) + ); jest.mocked(fetch).mockReset(); jest.mocked(Sentry.capture).mockReset(); jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset(); @@ -284,6 +294,257 @@ describe(pollAgentDeviceArtifactsForUploadAsync, () => { afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('cancels a warm poller during its wait without another inventory request', async () => { + jest.useRealTimers(); + const controller = new AbortController(); + jest.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ artifacts: [] }))); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger: createLoggerMock(), + signal: controller.signal, + }); + await new Promise(resolve => setImmediate(resolve)); + controller.abort(); + await polling; + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ signal: controller.signal }) + ); + }); + + it('cancels an in-flight warm artifact transfer and drains the poller', async () => { + jest.useRealTimers(); + const controller = new AbortController(); + let downloading!: () => void; + const started = new Promise(resolve => { + downloading = resolve; + }); + jest + .mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ artifacts: [{ id: 'new-artifact', filename: 'new.png' }] })) + ) + .mockImplementationOnce(async (_url, options) => { + const signal = options?.signal as AbortSignal; + expect(signal).toBe(controller.signal); + downloading(); + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const logger = createLoggerMock(); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger, + signal: controller.signal, + }); + await started; + controller.abort(new Error('session ended')); + await polling; + expect(fetch).toHaveBeenCalledTimes(2); + expect(logger.warn).not.toHaveBeenCalled(); + expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled(); + }); + + it('finishes an active cloud upload and collects final artifacts after normal stop', async () => { + jest.useRealTimers(); + const stopPolling = new AbortController(); + const cancellation = new AbortController(); + let releaseUpload!: () => void; + let uploadStarted!: () => void; + const pendingUpload = new Promise(resolve => { + releaseUpload = resolve; + }); + const started = new Promise(resolve => { + uploadStarted = resolve; + }); + let inventories = 0; + const first = { id: 'first', filename: 'first.png' }; + const last = { id: 'last', filename: 'last.png' }; + jest + .mocked(fetch) + .mockImplementation(async url => + String(url).endsWith('/artifacts') + ? new Response( + JSON.stringify({ artifacts: ++inventories === 1 ? [first] : [first, last] }) + ) + : new Response(Readable.from(['png'])) + ); + let transferSignal: AbortSignal | undefined; + jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => { + await readStreamAsync(options.openStream()); + if (options.artifactId === 'first') { + transferSignal = options.signal; + uploadStarted(); + await pendingUpload; + } + }); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger: createLoggerMock(), + signal: cancellation.signal, + stopPollingSignal: stopPolling.signal, + }); + let finished = false; + void polling.then(() => { + finished = true; + }); + try { + await started; + stopPolling.abort(); + await new Promise(resolve => setImmediate(resolve)); + expect(transferSignal?.aborted).toBe(false); + expect(finished).toBe(false); + releaseUpload(); + await polling; + expect(inventories).toBe(2); + expect( + jest + .mocked(uploadDeviceRunSessionArtifactAsync) + .mock.calls.map(([, options]) => options.artifactId) + ).toEqual(['first', 'last']); + } finally { + releaseUpload(); + cancellation.abort(); + await polling; + } + }); + + it('checks for an artifact created between the last poll and normal stop', async () => { + jest.useRealTimers(); + const stopPolling = new AbortController(); + jest + .mocked(fetch) + .mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] }))) + .mockResolvedValueOnce( + new Response(JSON.stringify({ artifacts: [{ id: 'last', filename: 'last.png' }] })) + ) + .mockResolvedValueOnce(new Response(Readable.from(['png']))); + jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => { + await readStreamAsync(options.openStream()); + }); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger: createLoggerMock(), + stopPollingSignal: stopPolling.signal, + }); + await new Promise(resolve => setImmediate(resolve)); + stopPolling.abort(); + await polling; + expect(fetch).toHaveBeenCalledTimes(3); + expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ artifactId: 'last' }) + ); + }); + + it('reports a failed final inventory after normal stop', async () => { + jest.useRealTimers(); + const stopPolling = new AbortController(); + const logger = createLoggerMock(); + const error = new Error('daemon is not ready'); + jest + .mocked(fetch) + .mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [] }))) + .mockRejectedValueOnce(error); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger, + stopPollingSignal: stopPolling.signal, + }); + await new Promise(resolve => setImmediate(resolve)); + stopPolling.abort(); + await polling; + expect(fetch).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + { err: error, failedArtifactListCount: 1 }, + 'Could not list agent-device remote session artifacts.' + ); + expect(Sentry.capture).toHaveBeenCalledTimes(1); + }); + + it('cancels a graceful collector immediately when the job is cancelled', async () => { + jest.useRealTimers(); + const stopPolling = new AbortController(); + const cancellation = new AbortController(); + let entered!: () => void; + const started = new Promise(resolve => { + entered = resolve; + }); + jest.mocked(fetch).mockImplementation(async (_url, options) => { + const signal = options?.signal as AbortSignal; + entered(); + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger: createLoggerMock(), + signal: cancellation.signal, + stopPollingSignal: stopPolling.signal, + }); + await started; + cancellation.abort(); + await polling; + expect(stopPolling.signal.aborted).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('cancels a stuck transfer after the normal-stop drain budget expires', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + const stopPolling = new AbortController(); + const logger = createLoggerMock(); + let entered!: () => void; + const started = new Promise(resolve => { + entered = resolve; + }); + jest + .mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ artifacts: [{ id: 'large', filename: 'large.mp4' }] })) + ) + .mockResolvedValueOnce(new Response(Readable.from(['video']))); + jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, options) => { + await readStreamAsync(options.openStream()); + entered(); + return await new Promise((_resolve, reject) => { + options.signal!.addEventListener('abort', () => reject(options.signal!.reason), { + once: true, + }); + }); + }); + const polling = pollAgentDeviceArtifactsForUploadAsync({} as CustomBuildContext, { + deviceRunSessionId: 'drs-id', + daemonUrl: 'http://127.0.0.1:1234', + daemonToken: 'token', + logger, + stopPollingSignal: stopPolling.signal, + }); + await started; + stopPolling.abort(); + await jest.advanceTimersByTimeAsync(30_000); + await polling; + expect(fetch).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + 'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.' + ); }); it('reports artifact listing errors on every fifth consecutive failure', async () => { 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..c6e3df70ac --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts @@ -0,0 +1,991 @@ +import type { bunyan } from '@expo/logger'; +import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import * as ngrok from '@ngrok/ngrok'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { CustomBuildContext } from '../../../customBuildContext'; +import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; +import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils'; +import { verifyLocalEgressGuardAsync } from '../localEgressGuard'; + +import { turtleFetch } from '../../../utils/turtleFetch'; +import { + ensureMacosPreviewEncoderInstalledAsync, + startDeviceRunSessionPreview, +} from '../deviceRunSessionPreview'; +import { + findUnlistedDeviceScreenRecordingsAsync, + uploadDeviceRunSessionScreenRecordingsAsync, +} from '../deviceRunSessionScreenRecordings'; +import * as screenshotCollector from '../deviceRunSessionScreenshots'; +import { createServeSimArgs, startDeviceSessionHostAsync } from '../deviceSessionHost'; +import { isPreinstalledExpoGoAsync, readIosApplicationIdentifierAsync } from '../iosAppArtifact'; +import { readLocalEgressHandoffAsync } from '../localEgress'; +import { + ensureFfmpegInstalledOnceAsync, + fetchWebPreviewTurnArgsAsync, + spawnDetached, + stopDetachedProcessAsync, +} from '../remoteDeviceRunSession'; +import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; +import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; +import { + WarmSessionAdoptionError, + startWarmDeviceSessionHostAsync, +} from '../warmDeviceSessionHost'; +import { type WarmIosSession } from '../warmIosSession'; + +jest.mock('@ngrok/ngrok'); +jest.mock('@expo/turtle-spawn'); +jest.mock('../../../utils/IosSimulatorUtils', () => ({ + IosSimulatorUtils: { disableApsdAsync: jest.fn(), waitForReadyAsync: jest.fn() }, +})); +jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() })); +jest.mock('../serveSimActions'); +jest.mock('../iosAppArtifact', () => ({ + isPreinstalledExpoGoAsync: jest.fn(), + readIosApplicationIdentifierAsync: jest.fn(), +})); +jest.mock('../serveSimActivity'); +jest.mock('../localEgress', () => ({ + ...jest.requireActual('../localEgress'), + readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null), +})); +jest.mock('../deviceRunSessionArtifacts'); +jest.mock('../../../sentry'); +jest.mock('../serveSimMetricsRecorder', () => ({ + readServeSimServersAsync: jest + .fn() + .mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]), +})); +jest.mock('../../../utils/turtleFetch'); +jest.mock('../remoteDeviceRunSession', () => ({ + ...jest.requireActual('../remoteDeviceRunSession'), + ensureFfmpegInstalledOnceAsync: jest.fn(), + fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]), + spawnDetached: jest.fn(), + stopDetachedProcessAsync: jest.fn(), +})); +jest.mock('../deviceRunSessionPreview', () => ({ + ...jest.requireActual('../deviceRunSessionPreview'), + ensureMacosPreviewEncoderInstalledAsync: jest.fn(), + startDeviceRunSessionPreview: jest.fn(), +})); +jest.mock('../deviceRunSessionScreenRecordings', () => ({ + ...jest.requireActual('../deviceRunSessionScreenRecordings'), + findUnlistedDeviceScreenRecordingsAsync: jest.fn(), + uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(), +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((r, j) => { + resolve = r; + reject = j; + }); + return { promise, resolve, reject }; +} + +const env = { + DEVICE_RUN_SESSION_ID: 'drs-id', +} as BuildStepEnv; +const ctx = {} as CustomBuildContext; +const logger = { + info: jest.fn(), + warn: jest.fn(), + child: jest.fn().mockReturnThis(), +} as unknown as bunyan; +const stopServer = jest.fn(); +const closeTunnel = jest.fn(); +const directories: string[] = []; +const hubStateFiles: string[] = []; +const baseDomain = 'preview.example.test'; +const authtoken = 'ngrok-token'; + +// expo-device-hub records its session token in `server-.json` before it answers /readyz, +// in the state directory it resolves from its env. +function writeHubState({ + args, + env: hubEnv, +}: { + args: readonly string[]; + env: BuildStepEnv; +}): void { + const port = args[args.indexOf('--port') + 1]; + const stateDir = + hubEnv.EXPO_DEVICE_HUB_STATE_DIR || path.join(hubEnv.TMPDIR ?? os.tmpdir(), 'expo-device-hub'); + const file = path.join(stateDir, `server-${port}.json`); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-preview-token' })); + hubStateFiles.push(file); +} + +const stopSessionPreview = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(IosSimulatorRecordingUtils, 'prepareAdoptedRecordingAsync') + .mockReset() + .mockResolvedValue(undefined); + jest + .mocked(spawn) + .mockReset() + .mockResolvedValue({} as never); + jest.mocked(stopDetachedProcessAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(isPreinstalledExpoGoAsync).mockReset().mockResolvedValue(false); + const installedBundleIds: Record = { + '/artifacts/Application.app': 'test.application', + '/tmp/Example.app': 'dev.example.app', + '/tmp/App.app': 'dev.example.other', + '/tmp/Expo Go.app': 'host.exp.Exponent', + '/tmp/Fixture.app': 'com.example.fixture', + }; + jest + .mocked(readIosApplicationIdentifierAsync) + .mockReset() + .mockImplementation(async ({ artifactPath }) => installedBundleIds[artifactPath]); + jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null); + stopSessionPreview.mockResolvedValue(undefined); + jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview }); + jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); + jest.mocked(ensureMacosPreviewEncoderInstalledAsync).mockResolvedValue(undefined); + stopServer.mockResolvedValue(undefined); + closeTunnel.mockResolvedValue(undefined); + jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false); + jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]); + jest.mocked(spawnDetached).mockImplementation(options => { + const port = options.args[options.args.indexOf('--port') + 1]; + const device = options.args[options.args.indexOf('@expo/serve-sim@latest') + 1]; + jest.mocked(readServeSimServersAsync).mockResolvedValue([ + { + udid: device?.startsWith('--') ? 'emulator-5554' : (device ?? 'emulator-5554'), + token: 'preview-token', + url: `http://127.0.0.1:${port}`, + }, + ]); + const flag = options.args.indexOf('--android-recording-directory'); + if (flag >= 0) { + directories.push(options.args[flag + 1]); + } + if (options.args.includes('--platform')) { + writeHubState(options); + } + return { + pid: undefined, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }; + }); + jest.mocked(ngrok.forward).mockResolvedValue({ + url: () => 'https://public.example.test', + close: closeTunnel, + } as never); + jest.mocked(turtleFetch).mockImplementation( + async () => + ({ + ok: true, + json: async () => ({ status: 'ready', device: 'emulator-5554' }), + }) as Awaited> + ); +}); + +afterEach(async () => { + jest.useRealTimers(); + await Promise.all( + [...directories.splice(0), ...hubStateFiles.splice(0)].map(entry => + rm(entry, { recursive: true, force: true }) + ) + ); +}); + +function warmSimulator(): WarmIosSession { + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([ + { udid: 'emulator-5554', token: 'preview-token', url: 'http://127.0.0.1:3200' }, + ]); + return { + device: { udid: 'emulator-5554', name: 'Simulator fixture' }, + serveSim: { pid: 123, port: 3200, token: 'preview-token' }, + agentDeviceStateDir: '/warm/agent-device', + argentEventLogPath: '/warm/argent-events.jsonl', + process: { + pid: undefined, + getExitError: () => undefined, + getOutput: () => '', + stopAsync: jest.fn(async () => {}), + }, + }; +} + +it('adopts through the ordinary CLI arguments and environment', async () => { + const warm = warmSimulator(); + jest + .mocked(fetchWebPreviewTurnArgsAsync) + .mockResolvedValueOnce([ + '--stun-url', + 'stun:stun.example.test', + '--turn-url', + 'turn:turn.example.test', + '--turn-username', + 'fresh-user', + '--turn-credential', + 'fresh-credential', + ]); + jest.mocked(readLocalEgressHandoffAsync).mockResolvedValueOnce({ port: 8899 } as never); + jest.mocked(stageServeSimAppAsync).mockResolvedValueOnce({ + directory: '/tmp/serve-sim-uploads/staged', + path: '/tmp/serve-sim-uploads/staged/Application.app', + }); + const host = await startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + frameAncestors: ['https://embed.example.test'], + corsOrigins: ['https://client.example.test'], + networkCapture: true, + networkCaptureFields: ['header', 'query'], + installAppPath: '/artifacts/Application.app', + launchAppIdentifier: 'test.application', + launchArgs: ['--test'], + openUrl: 'test://launch', + }, + warm + ); + expect(spawnDetached).not.toHaveBeenCalled(); + const [command, args, options] = jest.mocked(spawn).mock.calls[0]; + expect(command).toBe('npx'); + expect(args).toEqual([ + '--yes', + ...createServeSimArgs({ + port: 3200, + iosSimulatorUdid: 'emulator-5554', + turnArgs: [ + '--stun-url', + 'stun:stun.example.test', + '--turn-url', + 'turn:turn.example.test', + '--turn-username', + 'fresh-user', + '--turn-credential', + 'fresh-credential', + ], + websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'], + frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'], + corsOriginArgs: ['--cors-origin', 'https://client.example.test'], + shareUrl: args[args.indexOf('--share-url') + 1], + networkCapture: true, + networkCaptureFields: ['header', 'query'], + networkCaptureProxy: 'http://127.0.0.1:8899', + }), + '--adopt', + ]); + expect(options).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + ...env, + EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: expect.any(String), + }), + detached: true, + stdio: 'pipe', + signal: expect.any(AbortSignal), + }) + ); + expect(jest.mocked(turtleFetch).mock.calls.some(([url]) => url.endsWith('/session'))).toBe(false); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'app.install', + params: { udid: 'emulator-5554', path: expect.anything() }, + }) + ); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'app.launch', + params: { + udid: 'emulator-5554', + bundleId: 'test.application', + launchArgs: ['--test'], + openUrl: 'test://launch', + restart: false, + }, + }) + ); + await host.openPreviewAsync(); + expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: 3200 })); + await host.finishAsync(); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); +}); + +it('stops incompatible preparation and returns a safe adoption error without spawning a cold host', async () => { + const warm = warmSimulator(); + const credential = 'do-not-log-this-turn-credential'; + const screenshotsFinished = jest.fn(async () => { + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + }); + jest.spyOn(screenshotCollector, 'startDeviceRunSessionScreenshotsAsync').mockResolvedValueOnce({ + directory: '/tmp/owned-adoption-screenshots', + finishAsync: screenshotsFinished, + }); + jest + .mocked(fetchWebPreviewTurnArgsAsync) + .mockResolvedValueOnce(['--turn-credential', credential]); + jest.mocked(spawn).mockRejectedValueOnce(new Error(`command --turn-credential ${credential}`)); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + }, + warm + ); + await expect(starting).rejects.toBeInstanceOf(WarmSessionAdoptionError); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(screenshotsFinished).toHaveBeenCalledWith(true); + expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(credential); +}); + +it('cancels adoption and cleans its process group without starting a cold host', async () => { + const warm = warmSimulator(); + const controller = new AbortController(); + const started = deferred(); + jest.mocked(spawn).mockImplementationOnce(() => { + started.resolve(); + return Object.assign(new Promise(() => {}), { child: { pid: 456 } }) as never; + }); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + signal: controller.signal, + }, + warm + ); + const rejected = expect(starting).rejects.toThrow('cancelled adoption'); + await started.promise; + controller.abort(new Error('cancelled adoption')); + await rejected; + expect(stopDetachedProcessAsync).toHaveBeenCalledWith(456, 1_000); + expect(warm.process.stopAsync).toHaveBeenCalled(); + expect(spawnDetached).not.toHaveBeenCalled(); +}); + +it('bounds a stalled adoption command and releases preparation before returning the fallback error', async () => { + jest.useFakeTimers(); + const warm = warmSimulator(); + const started = deferred(); + jest.mocked(spawn).mockImplementationOnce(() => { + started.resolve(); + return Object.assign(new Promise(() => {}), { child: { pid: 456 } }) as never; + }); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + }, + warm + ); + const rejected = expect(starting).rejects.toBeInstanceOf(WarmSessionAdoptionError); + await started.promise; + await jest.advanceTimersByTimeAsync(60_000); + await rejected; + expect(jest.mocked(spawn).mock.calls[0][2]?.signal?.aborted).toBe(true); + expect(stopDetachedProcessAsync).toHaveBeenCalledWith(456, 1_000); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(spawnDetached).not.toHaveBeenCalled(); +}); + +it('cleans started warm work on an unexpected pre-server error', async () => { + const warm = warmSimulator(); + jest.mocked(fetchWebPreviewTurnArgsAsync).mockRejectedValueOnce(new Error('TURN unavailable')); + await expect( + startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + }, + warm + ) + ).rejects.toThrow('TURN unavailable'); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(spawnDetached).not.toHaveBeenCalled(); +}); + +it('retains screenshot files after failed writer teardown and reports CLI handoff failure to its caller', async () => { + const warm = warmSimulator(); + const stopFailure = new Error('stop failed'); + jest.mocked(warm.process.stopAsync).mockRejectedValue(stopFailure); + jest.mocked(spawn).mockRejectedValueOnce(new Error('CLI lost its response')); + const screenshotsFinished = jest.fn(async () => {}); + jest.spyOn(screenshotCollector, 'startDeviceRunSessionScreenshotsAsync').mockResolvedValueOnce({ + directory: '/tmp/owned-adoption-screenshots', + finishAsync: screenshotsFinished, + }); + await expect( + startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + }, + warm + ) + ).rejects.toBeInstanceOf(WarmSessionAdoptionError); + expect(screenshotsFinished).toHaveBeenCalledWith(false); + expect(spawnDetached).not.toHaveBeenCalled(); +}); + +it.each([ + { server: 'warm', record: true, matches: true, installs: false }, + { server: 'warm', record: true, matches: false, installs: true }, + { server: 'warm', record: false, matches: true, installs: true }, + { server: 'cold', record: false, matches: true, installs: true }, +])( + 'installs Expo Go on a $server Simulator unless its preinstalled record matches (record: $record, matches: $matches)', + async ({ server, record, matches, installs }) => { + jest.mocked(isPreinstalledExpoGoAsync).mockResolvedValue(matches); + jest + .mocked(stageServeSimAppAsync) + .mockResolvedValue({ directory: '/staged', path: '/staged/Expo Go.app' }); + jest.mocked(spawn).mockResolvedValueOnce({ stdout: '{}' } as never); + const preinstalledExpoGo = { + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.7', + version: '58.0.7', + }; + const options = { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + iosSimulatorUdid: 'emulator-5554', + installAppPath: '/tmp/Expo Go.app', + launchAppIdentifier: 'host.exp.Exponent', + } as const; + const host = + server === 'warm' + ? await startWarmDeviceSessionHostAsync( + ctx, + options, + { ...warmSimulator(), ...(record ? { preinstalledExpoGo } : {}) }, + { turnArgs: Promise.resolve([]) } + ) + : await startDeviceSessionHostAsync(ctx, options); + const calls = jest.mocked(runServeSimActionAsync).mock.calls.map(([call]) => call); + expect(calls.map(call => call.action)).toEqual( + installs ? ['app.install', 'app.launch'] : ['app.launch'] + ); + // Either way the app has not run yet, so the launch skips its terminate check. + expect(calls.at(-1)?.params).toMatchObject({ restart: false }); + expect(stageServeSimAppAsync).toHaveBeenCalledTimes(installs ? 1 : 0); + expect(isPreinstalledExpoGoAsync).toHaveBeenCalledTimes(record ? 1 : 0); + if (record) { + expect(isPreinstalledExpoGoAsync).toHaveBeenCalledWith({ + appPath: '/tmp/Expo Go.app', + preinstalled: preinstalledExpoGo, + env, + }); + } + await host.finishAsync(); + } +); + +it('starts adopted video before app actions and finalizes before worker stop', async () => { + const warm = warmSimulator(); + const finishRecording = jest.fn(async () => {}); + const recording = { + output: '/job/video', + recordingId: 'job-recording', + finishAsync: finishRecording, + }; + jest + .mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync) + .mockResolvedValueOnce(recording); + let confirm!: () => void; + jest.mocked(spawn).mockImplementationOnce( + () => + new Promise(resolve => { + confirm = () => + resolve({ + stdout: JSON.stringify({ + recording: { output: recording.output, recordingId: recording.recordingId }, + }), + } as never); + }) as never + ); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'test.application', + }, + warm + ); + for (let i = 0; i < 50 && !confirm; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(confirm).toBeDefined(); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(jest.mocked(spawn).mock.calls[0][1].slice(-5)).toEqual([ + '--adopt', + '--recording-output', + '/job/video', + '--recording-id', + 'job-recording', + ]); + confirm(); + const host = await starting; + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ action: 'app.launch' }) + ); + await host.finishAsync(); + expect(finishRecording).toHaveBeenCalledTimes(1); + expect(finishRecording.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(warm.process.stopAsync).mock.invocationCallOrder[0] + ); +}); + +it.each(['missing', 'wrong-id', 'wrong-output'])( + 'stops before app actions when adopted video confirmation is %s', + async failure => { + const warm = warmSimulator(); + const finishRecording = jest.fn(async () => {}); + jest.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync).mockResolvedValueOnce({ + output: '/job/video', + recordingId: 'job-recording', + finishAsync: finishRecording, + }); + jest.mocked(spawn).mockResolvedValueOnce({ + stdout: JSON.stringify( + failure === 'missing' + ? {} + : { + recording: { + output: failure === 'wrong-output' ? '/other' : '/job/video', + recordingId: failure === 'wrong-id' ? 'another' : 'job-recording', + }, + } + ), + } as never); + await expect( + startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'test.application', + }, + warm + ) + ).rejects.toBeInstanceOf(WarmSessionAdoptionError); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(finishRecording).toHaveBeenCalledTimes(1); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + } +); + +it('still stops the worker when adopted recording finalization fails', async () => { + const warm = warmSimulator(); + const failure = new Error('recording finalization failed'); + jest.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync).mockResolvedValueOnce({ + output: '/job/video', + recordingId: 'job-recording', + finishAsync: jest.fn(async () => { + throw failure; + }), + }); + jest.mocked(spawn).mockResolvedValueOnce({ + stdout: JSON.stringify({ recording: { output: '/job/video', recordingId: 'job-recording' } }), + } as never); + const host = await startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + }, + warm + ); + await host.finishAsync(); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { err: failure }, + 'Could not finish the warm Simulator video recording; complete footage is not confirmed.' + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + 'Could not stop the serve-sim session host.' + ); +}); + +it('finalizes an adoption recording and stops the worker when adoption is cancelled', async () => { + const warm = warmSimulator(); + const finishRecording = jest.fn(async () => {}); + jest.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync).mockResolvedValueOnce({ + output: '/job/video', + recordingId: 'job-recording', + finishAsync: finishRecording, + }); + const controller = new AbortController(); + let started!: () => void; + const startingCommand = new Promise(resolve => { + started = resolve; + }); + jest.mocked(spawn).mockImplementationOnce((_command, _args, options) => { + started(); + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true, + }); + }) as never; + }); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'test.application', + signal: controller.signal, + }, + warm + ); + const rejected = expect(starting).rejects.toThrow('cancelled video adoption'); + await startingCommand; + controller.abort(new Error('cancelled video adoption')); + await rejected; + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(finishRecording).toHaveBeenCalledTimes(1); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); +}); + +it('overlaps the warm tunnel with TURN, skips Simulator setup and waits for native video before app operations', async () => { + const warm = warmSimulator(); + const turn = deferred(); + const adoption = deferred<{ stdout: string }>(); + jest.mocked(spawn).mockReturnValueOnce(adoption.promise as never); + const stageDirectory = await mkdtemp(path.join(os.tmpdir(), 'warm-overlap-test-')); + directories.push(stageDirectory); + jest.mocked(stageServeSimAppAsync).mockResolvedValueOnce({ + directory: stageDirectory, + path: path.join(stageDirectory, 'Fixture.app'), + }); + jest.mocked(IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync).mockResolvedValueOnce({ + output: '/job/video', + recordingId: 'job-recording', + finishAsync: jest.fn(async () => {}), + }); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + installAppPath: '/job/Fixture.app', + launchAppIdentifier: 'test.application', + }, + warm, + { turnArgs: turn.promise } + ); + let host: Awaited | undefined; + try { + for (let i = 0; i < 50 && !jest.mocked(ngrok.forward).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled(); + expect(ngrok.forward).toHaveBeenCalledTimes(1); + expect(spawn).not.toHaveBeenCalled(); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + turn.resolve([]); + for (let i = 0; i < 50 && !jest.mocked(spawn).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(spawn).toHaveBeenCalledTimes(1); + // The prewarmed server is already /readyz-ready; only CLI ACK proves the job writer started. + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + adoption.resolve({ + stdout: JSON.stringify({ + recording: { + output: '/job/video', + recordingId: 'job-recording', + }, + }), + }); + for (let i = 0; i < 50 && !jest.mocked(runServeSimActionAsync).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ action: 'app.install' }) + ); + host = await starting; + const preview = await host.openPreviewAsync(); + expect(preview.previewToken).toBe('preview-token'); + expect(ngrok.forward).toHaveBeenCalledTimes(1); + expect(fetchWebPreviewTurnArgsAsync).not.toHaveBeenCalled(); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ action: 'app.launch' }) + ); + await host.finishAsync(); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + } finally { + turn.resolve([]); + adoption.resolve({ + stdout: JSON.stringify({ recording: { output: '/job/video', recordingId: 'job-recording' } }), + }); + host ??= await starting; + await host.finishAsync(); + } +}); + +it.each(['adoption failure', 'cancellation'])( + 'closes a late warm tunnel before controller stop on %s', + async failure => { + const warm = warmSimulator(); + const tunnel = deferred>>(); + const adoption = deferred(); + const controller = new AbortController(); + jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise); + jest.mocked(spawn).mockImplementationOnce((_command, _args, options) => { + options?.signal?.addEventListener('abort', () => adoption.reject(options.signal!.reason), { + once: true, + }); + return adoption.promise as never; + }); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'test.application', + signal: controller.signal, + }, + warm, + { turnArgs: Promise.resolve([]) } + ); + const rejected = + failure === 'adoption failure' + ? expect(starting).rejects.toBeInstanceOf(WarmSessionAdoptionError) + : expect(starting).rejects.toThrow('cancel warm startup'); + try { + for (let i = 0; i < 50 && !jest.mocked(spawn).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(spawn).toHaveBeenCalledTimes(1); + if (failure === 'adoption failure') { + adoption.reject(new Error('adoption failed')); + } else { + controller.abort(new Error('cancel warm startup')); + } + for (let i = 0; i < 50 && !jest.mocked(stopDetachedProcessAsync).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + await new Promise(resolve => setImmediate(resolve)); + expect(warm.process.stopAsync).not.toHaveBeenCalled(); + tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never); + for (let i = 0; i < 50 && !closeTunnel.mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(closeTunnel).toHaveBeenCalledTimes(1); + await rejected; + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(closeTunnel.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(warm.process.stopAsync).mock.invocationCallOrder[0] + ); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + } finally { + tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never); + adoption.reject(new Error('test cleanup')); + await starting.catch(() => {}); + } + } +); + +it('keeps an app failure untyped and closes a late warm tunnel before stop', async () => { + const warm = warmSimulator(); + const tunnel = deferred>>(); + const failure = new Error('app install failed'); + jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise); + jest.mocked(spawn).mockResolvedValueOnce({ stdout: '{}' } as never); + const stageDirectory = await mkdtemp(path.join(os.tmpdir(), 'warm-overlap-test-')); + directories.push(stageDirectory); + jest.mocked(stageServeSimAppAsync).mockResolvedValueOnce({ + directory: stageDirectory, + path: path.join(stageDirectory, 'Fixture.app'), + }); + jest.mocked(runServeSimActionAsync).mockRejectedValueOnce(failure); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + installAppPath: '/job/Fixture.app', + launchAppIdentifier: 'test.application', + }, + warm, + { turnArgs: Promise.resolve([]) } + ); + const rejected = expect(starting).rejects.toBe(failure); + try { + for (let i = 0; i < 50 && !jest.mocked(runServeSimActionAsync).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ action: 'app.install' }) + ); + await new Promise(resolve => setImmediate(resolve)); + expect(warm.process.stopAsync).not.toHaveBeenCalled(); + tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never); + for (let i = 0; i < 50 && !closeTunnel.mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(closeTunnel).toHaveBeenCalledTimes(1); + await rejected; + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); + } finally { + tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never); + await starting.catch(() => {}); + } +}); + +it.each([ + { timing: 'opened', timeout: false }, + { timing: 'late', timeout: false }, + { timing: 'opened', timeout: true }, + { timing: 'late', timeout: true }, +])( + 'forbids cold retry after $timing warm tunnel retirement fails (timeout=$timeout), while stopping the host', + async ({ timing, timeout }) => { + const warm = warmSimulator(); + const tunnel = deferred>>(); + const adoption = deferred(); + const closeError = new Error('listener still alive'); + const screenshotsFinish = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(screenshotCollector, 'startDeviceRunSessionScreenshotsAsync') + .mockResolvedValueOnce({ directory: '/tmp/screenshots', finishAsync: screenshotsFinish }); + jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise); + const closing = deferred(); + if (timeout) { + closeTunnel.mockReturnValueOnce(closing.promise); + } else { + closeTunnel.mockRejectedValueOnce(closeError); + } + jest.mocked(spawn).mockReturnValueOnce(adoption.promise as never); + const starting = startWarmDeviceSessionHostAsync( + ctx, + { + preview: { baseDomain, authtoken }, + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'test.application', + }, + warm, + { turnArgs: Promise.resolve([]) } + ); + const outcome = starting.catch(error => error); + try { + if (timing === 'opened') { + tunnel.resolve({ url: () => 'https://open.example.test', close: closeTunnel } as never); + } + for (let i = 0; i < 50 && !jest.mocked(spawn).mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + adoption.reject(new Error('adoption failed')); + await new Promise(resolve => setImmediate(resolve)); + if (timing === 'late') { + tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never); + } + for (let i = 0; i < 50 && !closeTunnel.mock.calls.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + if (timing === 'late') { + expect(closeTunnel).toHaveBeenCalledTimes(1); + } + const error = await outcome; + expect(error).toBeInstanceOf(AggregateError); + expect(error).not.toBeInstanceOf(WarmSessionAdoptionError); + if (timeout) { + expect(error.errors.some((cause: Error) => cause.message.includes('timed out'))).toBe(true); + } else { + expect(error.errors).toContain(closeError); + } + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(screenshotsFinish).toHaveBeenCalled(); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + } finally { + closing.resolve(); + tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never); + adoption.reject(new Error('test cleanup')); + await outcome; + } + }, + 10_000 +); 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..692a8b1e17 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts @@ -0,0 +1,847 @@ +import { type bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { turtleFetch } from '../../../utils/turtleFetch'; +import { listAgentDeviceArtifactsAsync } from '../agentDeviceArtifacts'; +import { tryClaimWarmIosSessionAsync } from '../warmIosSession'; + +jest.mock('@expo/turtle-spawn'); +jest.mock('../../../utils/turtleFetch'); +jest.mock('../agentDeviceArtifacts'); +jest.mock('../remoteDeviceRunSession', () => ({ isProcessRunning: jest.fn(() => true) })); + +const UDID = '592047E9-7027-4394-8213-8924EFCD53F6'; +const CONTROLLER_PID = 9000; +const CONTROLLER_START_TIME = 'Thu Oct 22 10:00:00 2026'; +const SCRIPT = path.join(os.homedir(), '.expo/simulator-tools/run'); +const STATE = '/tmp/expo-simulator-startup.json'; +const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan; +const env = { EAS_USE_WARM_SIMULATOR: '1' }; +const stateDir = path.join(os.homedir(), 'Library/Caches/expo-simulator-startup/agent-device'); + +const originalPlatform = process.platform; +beforeAll(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); +}); +afterAll(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +async function writeAsync(file: string, value: unknown): Promise { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, typeof value === 'string' ? value : JSON.stringify(value)); +} + +beforeEach(async () => { + jest.resetAllMocks(); + jest.mocked(spawn).mockImplementation((command, args, options) => { + if (args[1] === 'claim') { + return fs.readFile(STATE, 'utf8').then(contents => ({ + stdout: JSON.stringify({ ...JSON.parse(contents), status: 'claimed', engine: args[2] }), + })) as ReturnType; + } + const stdout = + command === '/usr/sbin/sysctl' + ? 'boot-1\n' + : command === 'ps' && args.includes('lstart=') + ? CONTROLLER_START_TIME + : command === 'ps' + ? `${CONTROLLER_PID}\n` + : ''; + return Promise.resolve({ + stdout: options?.stdio === 'pipe' || options?.logger ? stdout : '', + }) as ReturnType; + }); + jest.mocked(turtleFetch).mockImplementation( + async url => + ({ + json: async () => + url.endsWith('/readyz') + ? { status: 'ready', device: UDID } + : url.endsWith('/tools') + ? { tools: [] } + : { version: '1.2.3' }, + }) as Awaited> + ); + jest.mocked(listAgentDeviceArtifactsAsync).mockResolvedValue([]); + await writeAsync(SCRIPT, '# fixture'); + await writeAsync(STATE, { + bootUuid: 'boot-1', + status: 'ready', + device: { udid: UDID, name: 'iPhone 17' }, + controllerPid: CONTROLLER_PID, + controllerStartTime: CONTROLLER_START_TIME, + agentDeviceStateDir: stateDir, + argentEventLogPath: path.join(os.homedir(), '.argent/tool-server-events.jsonl'), + }); + await writeAsync(path.join(os.tmpdir(), 'serve-sim', `server-${UDID}.json`), { + pid: 10, + port: 3200, + token: 'serve-secret', + device: UDID, + url: 'http://127.0.0.1:3200', + }); +}); + +it('does not inspect or stop preparation without the job opt-in', async () => { + expect( + await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: UDID, + env: {}, + logger, + }) + ).toBeUndefined(); + expect(spawn).not.toHaveBeenCalled(); +}); + +it.each([ + 'TMPDIR', + 'SERVE_SIM_STATE_DIR', + 'SERVE_SIM_ADDITIONAL_DYLIBS', + 'SIMCTL_CHILD_HTTP_PROXY', +])('uses the cold path for a job-specific %s override', async variable => { + expect( + await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: UDID, + env: { ...env, [variable]: '/custom/job-directory' }, + logger, + }) + ).toBeUndefined(); + expect(turtleFetch).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); + const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as [{ err: Error }]; + expect(err.message).toContain(variable); + expect(err.message).not.toContain('/custom/job-directory'); +}); + +it('accepts job directory overrides that match the warmup defaults', async () => { + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + env: { + ...env, + TMPDIR: os.tmpdir(), + SERVE_SIM_STATE_DIR: path.join(os.tmpdir(), 'serve-sim'), + }, + logger, + }); + expect(warm?.device.udid).toBe(UDID); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('prepares the exact current-boot device and stops through the supervisor once', async () => { + const signal = new AbortController().signal; + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: UDID, + packageVersion: '1.2.3', + env, + logger, + signal, + }); + expect(warm?.serveSim).toEqual({ pid: 10, port: 3200, token: 'serve-secret' }); + expect(turtleFetch).toHaveBeenCalledWith('http://127.0.0.1:3200/readyz', 'GET', { + headers: { Authorization: 'Bearer serve-secret' }, + retries: 0, + timeout: 5_000, + signal, + }); + expect(spawn).toHaveBeenCalledWith('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { + stdio: 'pipe', + }); + expect(jest.mocked(turtleFetch).mock.calls.some(([url]) => url.endsWith('/session'))).toBe(false); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + [SCRIPT, 'claim', 'serve-sim', UDID], + expect.anything() + ); + await Promise.all([warm!.process.stopAsync(), warm!.process.stopAsync()]); + expect(jest.mocked(spawn).mock.calls.filter(([, args]) => args.includes('stop'))).toHaveLength(1); +}); + +it.each(['stale-boot', 'wrong-device', 'wrong-url'])( + 'cleans up %s before the caller falls back', + async failure => { + if (failure === 'stale-boot') { + await writeAsync(STATE, { + bootUuid: 'previous-boot', + status: 'ready', + }); + } + if (failure !== 'stale-boot') { + await writeAsync(path.join(os.tmpdir(), 'serve-sim', `server-${UDID}.json`), { + pid: 10, + port: 3200, + token: 'serve-secret', + device: failure === 'wrong-device' ? 'another-device' : UDID, + url: failure === 'wrong-url' ? 'http://127.0.0.1:9999' : 'http://127.0.0.1:3200', + }); + } + expect( + await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: UDID, + env, + logger, + }) + ).toBeUndefined(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); + expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[1] === 'claim')).toBe( + failure !== 'stale-boot' + ); + if (failure !== 'stale-boot') { + expect(logger.warn).toHaveBeenCalledWith( + { + err: new Error( + failure === 'wrong-device' + ? `Warm serve-sim reports device another-device, but claim returned ${UDID}.` + : 'The warm serve-sim address does not match http://127.0.0.1:3200; the session cannot be reused.' + ), + }, + 'The warm Simulator session cannot be used; cleanup is required before normal startup.' + ); + } + } +); + +it('prepares a detached agent-device using its native process start time, custom state and auth', async () => { + const stateDir = '/warm custom/agent'; + await writeAsync(STATE, { + status: 'ready', + device: { udid: UDID, name: 'iPhone 17' }, + bootUuid: 'boot-1', + controllerPid: CONTROLLER_PID, + controllerStartTime: CONTROLLER_START_TIME, + agentDeviceStateDir: stateDir, + argentEventLogPath: '/argent-events.jsonl', + }); + await writeAsync(path.join(stateDir, 'daemon.json'), { + pid: 30, + httpPort: 12345, + token: 'agent-secret', + version: '1.2.3', + stateDir, + processStartTime: 'Thu Oct 22 10:00:00 2026', + }); + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'agent-device', + requestedDevice: UDID, + packageVersion: '1.2.3', + env, + logger, + }); + expect(warm?.tools?.pid).toBe(30); + expect(spawn).toHaveBeenCalledWith('ps', ['-p', '30', '-o', 'lstart='], { stdio: 'pipe' }); + expect(listAgentDeviceArtifactsAsync).toHaveBeenCalledWith({ + daemonUrl: 'http://127.0.0.1:12345', + daemonToken: 'agent-secret', + signal: expect.any(AbortSignal), + }); + expect( + jest + .mocked(spawn) + .mock.calls.some( + ([command, args]) => command === 'ps' && args[1] === '30' && args.includes('ppid=') + ) + ).toBe(false); +}); + +it('rejects a reused agent-device PID before claiming', async () => { + await writeAsync(path.join(stateDir, 'daemon.json'), { + pid: 30, + httpPort: 12345, + token: 'agent-secret', + version: '1.2.3', + stateDir, + processStartTime: 'older process', + }); + expect( + await tryClaimWarmIosSessionAsync({ + engine: 'agent-device', + requestedDevice: UDID, + env, + logger, + }) + ).toBeUndefined(); + expect(listAgentDeviceArtifactsAsync).not.toHaveBeenCalled(); +}); + +it('bounds the agent-device preflight and cleans up after an aborted inventory request', async () => { + await writeAsync(path.join(stateDir, 'daemon.json'), { + pid: 30, + httpPort: 12345, + token: 'agent-secret', + version: '1.2.3', + stateDir, + processStartTime: 'Thu Oct 22 10:00:00 2026', + }); + const timeout = jest.spyOn(AbortSignal, 'timeout'); + jest + .mocked(listAgentDeviceArtifactsAsync) + .mockRejectedValueOnce(new Error('inventory request aborted')); + try { + expect( + await tryClaimWarmIosSessionAsync({ + engine: 'agent-device', + requestedDevice: UDID, + env, + logger, + }) + ).toBeUndefined(); + expect(timeout).toHaveBeenCalledWith(5_000); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); + } finally { + timeout.mockRestore(); + } +}); + +it('prepares Argent through its native state and bundle manifest', async () => { + const bundlePath = '/cache/argent/dist/tool-server.cjs'; + await writeAsync(path.join(os.homedir(), '.argent/tool-server-hash.json'), { + pid: 40, + port: 3201, + token: 'argent-secret', + bundlePath, + }); + await writeAsync('/cache/argent/package.json', { version: '1.2.3' }); + const eventFile = '/warm custom/argent-events.jsonl'; + await writeAsync(STATE, { + status: 'ready', + device: { udid: UDID, name: 'iPhone 17' }, + bootUuid: 'boot-1', + controllerPid: CONTROLLER_PID, + controllerStartTime: CONTROLLER_START_TIME, + agentDeviceStateDir: stateDir, + argentEventLogPath: eventFile, + }); + await writeAsync(path.join(os.homedir(), '.argent/tool-server-stale.json'), { port: 9999 }); + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'argent', + requestedDevice: UDID, + packageVersion: '1.2.3', + env, + logger, + }); + expect(warm?.tools).toEqual({ pid: 40, port: 3201, token: 'argent-secret' }); + expect(turtleFetch).toHaveBeenCalledWith('http://127.0.0.1:3201/tools', 'GET', { + headers: { Authorization: 'Bearer argent-secret' }, + retries: 0, + timeout: 5_000, + signal: undefined, + }); + expect(warm?.argentEventLogPath).toBe(eventFile); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + [SCRIPT, 'claim', 'argent', UDID], + expect.anything() + ); +}); + +it('skips Argent state left by another process on the same port', async () => { + await writeAsync(path.join(os.homedir(), '.argent/tool-server-aaa.json'), { + pid: 41, + port: 3201, + token: 'stale-secret', + bundlePath: '/old/argent/dist/tool-server.cjs', + version: '1.0.0', + }); + await writeAsync(path.join(os.homedir(), '.argent/tool-server-hash.json'), { + pid: 40, + port: 3201, + token: 'argent-secret', + bundlePath: '/cache/argent/dist/tool-server.cjs', + }); + await writeAsync('/cache/argent/package.json', { version: '1.2.3' }); + const original = jest.mocked(spawn).getMockImplementation()!; + jest + .mocked(spawn) + .mockImplementation((file, args, options) => + file === 'ps' && args[1] === '41' && args.includes('ppid=') + ? (Promise.resolve({ stdout: '1' }) as ReturnType) + : original(file, args, options) + ); + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'argent', + requestedDevice: UDID, + packageVersion: '1.2.3', + env, + logger, + }); + expect(warm?.tools).toEqual({ pid: 40, port: 3201, token: 'argent-secret' }); +}); + +it.each(['missing', 'malformed', 'wrong-boot'])( + 'stops before cold fallback when effective config is %s', + async failure => { + const file = STATE; + if (failure === 'missing') { + await fs.rm(file); + } else { + await writeAsync( + file, + failure === 'malformed' + ? 'not JSON' + : { + bootUuid: 'previous-boot', + agentDeviceStateDir: stateDir, + argentEventLogPath: '/events.jsonl', + } + ); + } + expect( + await tryClaimWarmIosSessionAsync({ + engine: 'agent-device', + requestedDevice: UDID, + env, + logger, + }) + ).toBeUndefined(); + expect(listAgentDeviceArtifactsAsync).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); + expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[1] === 'claim')).toBe(false); + } +); + +it('propagates failed stop instead of permitting cold fallback', async () => { + await fs.rm(STATE); + const original = jest.mocked(spawn).getMockImplementation()!; + const failure = new Error('worker stop timed out'); + jest.mocked(spawn).mockImplementation((command, args, options) => { + if (args.includes('stop')) { + return Promise.reject(failure) as ReturnType; + } + return original(command, args, options); + }); + await expect( + tryClaimWarmIosSessionAsync({ engine: 'serve-sim', requestedDevice: UDID, env, logger }) + ).rejects.toBe(failure); +}); + +it('lets claim choose the device, then runs preflight against the returned identity', async () => { + const actual = 'A8E19807-41B2-4785-9558-08C1D52833C9'; + const original = jest.mocked(spawn).getMockImplementation()!; + jest.mocked(spawn).mockImplementation((command, args, options) => + args[1] === 'claim' + ? (writeAsync(STATE, { + bootUuid: 'boot-1', + status: 'claimed', + engine: args[2], + controllerPid: CONTROLLER_PID, + controllerStartTime: CONTROLLER_START_TIME, + agentDeviceStateDir: stateDir, + argentEventLogPath: path.join(os.homedir(), '.argent/tool-server-events.jsonl'), + device: { udid: actual, name: 'iPhone 17 Pro' }, + }).then(() => ({ + stdout: JSON.stringify({ + bootUuid: 'boot-1', + status: 'claimed', + engine: args[2], + controllerPid: CONTROLLER_PID, + controllerStartTime: CONTROLLER_START_TIME, + agentDeviceStateDir: stateDir, + argentEventLogPath: path.join(os.homedir(), '.argent/tool-server-events.jsonl'), + device: { udid: actual, name: 'iPhone 17 Pro' }, + }), + })) as ReturnType) + : original(command, args, options) + ); + await writeAsync(path.join(os.tmpdir(), 'serve-sim', `server-${actual}.json`), { + pid: 10, + port: 3200, + token: 'serve-secret', + device: actual, + url: 'http://127.0.0.1:3200', + }); + jest + .mocked(turtleFetch) + .mockResolvedValue({ json: async () => ({ status: 'ready', device: actual }) } as Awaited< + ReturnType + >); + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: 'iPhone 17 Pro', + env, + logger, + }); + expect(warm?.device).toEqual({ udid: actual, name: 'iPhone 17 Pro' }); + const calls = jest.mocked(spawn).mock.calls; + expect(calls.findIndex(([, args]) => args[1] === 'claim')).toBeLessThan( + calls.findIndex(([command]) => command === 'ps') + ); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + [SCRIPT, 'claim', 'serve-sim', 'iPhone 17 Pro'], + expect.objectContaining({ timeout: 425_000 }) + ); + expect(calls.some(([command]) => command === 'xcrun')).toBe(false); +}); + +it('passes no selector when the caller wants the current warm device', async () => { + const warm = await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger }); + expect(warm?.device).toEqual({ udid: UDID, name: 'iPhone 17' }); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + [SCRIPT, 'claim', 'serve-sim'], + expect.objectContaining({ timeout: 425_000 }) + ); +}); + +it.each([ + 'boot-1\n', + { bootUuid: 'boot-1', status: 'disabled' }, + { + bootUuid: 'boot-1', + status: 'claimed', + engine: 'serve-sim', + device: { udid: UDID, name: 'iPhone 17' }, + }, +])('does not claim an old-format or unavailable initial status %j', async status => { + await writeAsync(STATE, status); + expect(await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).toBeUndefined(); + expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[1] === 'claim')).toBe(false); + expect(turtleFetch).not.toHaveBeenCalled(); +}); + +it.each([ + { bootUuid: 'other-boot' }, + { engine: 'argent' }, + { status: 'ready' }, + { controllerPid: 0 }, + { controllerStartTime: '' }, + { agentDeviceStateDir: '' }, + { argentEventLogPath: '' }, + { device: { udid: '', name: 'iPhone' } }, +])('rejects invalid claimed metadata response %j after positive stop', async invalid => { + const original = jest.mocked(spawn).getMockImplementation()!; + jest.mocked(spawn).mockImplementation((command, args, options) => { + const result = original(command, args, options); + return ( + args[1] === 'claim' + ? result.then(value => ({ + ...value, + stdout: JSON.stringify({ ...JSON.parse(value.stdout), ...invalid }), + })) + : result + ) as ReturnType; + }); + await expect(tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).rejects.toThrow(); + expect(turtleFetch).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); +}); + +it.each(['', 'not-json', '{"udid":"","name":"iPhone"}', '{"udid":"PHONE","name":""}'])( + 'terminal-stops and fails claim for invalid CLI device output %s', + async stdout => { + const original = jest.mocked(spawn).getMockImplementation()!; + jest + .mocked(spawn) + .mockImplementation((command, args, options) => + args[1] === 'claim' + ? (Promise.resolve({ stdout }) as ReturnType) + : original(command, args, options) + ); + await expect( + tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger }) + ).rejects.toThrow(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.objectContaining({ timeout: 150_000 }) + ); + expect(turtleFetch).not.toHaveBeenCalled(); + } +); + +it.each(['missing', 'wrong-boot'])( + 'does not enter the long claim wait for a %s initial candidate', + async failure => { + if (failure === 'missing') { + await fs.rm(STATE); + } else { + await writeAsync(STATE, { + bootUuid: 'other-boot', + status: 'ready', + }); + } + expect(await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).toBeUndefined(); + expect(jest.mocked(spawn).mock.calls.some(([, args]) => args[1] === 'claim')).toBe(false); + } +); + +it.each(['request-timeout', 'command-cancelled'])( + 'awaits positive terminal stop after %s before resolving or rejecting claim', + async failure => { + let acknowledge!: () => void; + const cleanup = new Promise(resolve => { + acknowledge = resolve; + }); + const controller = new AbortController(); + const original = jest.mocked(spawn).getMockImplementation()!; + let stopEntered = false; + let completed = false; + const reason = new Error(failure); + jest.mocked(spawn).mockImplementation((command, args, options) => { + if (args[1] === 'claim') { + if (failure === 'command-cancelled') { + controller.abort(reason); + } + return Promise.reject(reason) as ReturnType; + } + if (args.includes('stop')) { + stopEntered = true; + expect(options).not.toHaveProperty('signal'); + return cleanup.then(() => ({ stdout: '' })) as ReturnType; + } + return original(command, args, options); + }); + const preparation = tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + env, + logger, + signal: controller.signal, + }).then( + value => { + completed = true; + return value; + }, + error => { + completed = true; + return error; + } + ); + try { + for (let i = 0; i < 100 && !stopEntered; i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + expect(stopEntered).toBe(true); + expect(completed).toBe(false); + acknowledge(); + expect(await preparation).toBe(reason); + } finally { + acknowledge(); + await preparation; + } + } +); + +it('propagates a terminal-stop failure after the accepted request times out', async () => { + const original = jest.mocked(spawn).getMockImplementation()!; + const teardownError = new Error('no positive stopped ack'); + jest.mocked(spawn).mockImplementation((command, args, options) => { + if (args[1] === 'claim') { + return Promise.reject(new Error('request timed out')) as ReturnType; + } + if (args.includes('stop')) { + return Promise.reject(teardownError) as ReturnType; + } + return original(command, args, options); + }); + await expect(tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).rejects.toBe( + teardownError + ); +}); + +it.each([ + 'reused PID', + 'blank native start time', + 'wrong parent', + 'malformed parent', + 'cycle', + 'parent query failure', +])('rejects controller ancestry with %s and awaits stop before fallback', async failure => { + const original = jest.mocked(spawn).getMockImplementation()!; + jest.mocked(spawn).mockImplementation((file, args, options) => { + if ( + file === 'ps' && + args[1] === String(CONTROLLER_PID) && + args.includes('lstart=') && + (failure === 'reused PID' || failure === 'blank native start time') + ) { + return Promise.resolve({ + stdout: failure === 'blank native start time' ? '' : 'a different native start time', + }) as ReturnType; + } + if (file === 'ps' && args.includes('ppid=')) { + if (failure === 'parent query failure') { + return Promise.reject(new Error('ps failed')) as ReturnType; + } + const stdout = + failure === 'wrong parent' + ? '1' + : failure === 'malformed parent' + ? `1 node ${SCRIPT}` + : failure === 'cycle' + ? '10' + : `${CONTROLLER_PID}`; + return Promise.resolve({ stdout }) as ReturnType; + } + return original(file, args, options); + }); + expect(await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).toBeUndefined(); + expect(turtleFetch).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); +}); + +it('walks native parents to the recorded controller and verifies its start time', async () => { + const original = jest.mocked(spawn).getMockImplementation()!; + jest + .mocked(spawn) + .mockImplementation((file, args, options) => + file === 'ps' && args.includes('ppid=') + ? (Promise.resolve({ stdout: args[1] === '10' ? '20' : `${CONTROLLER_PID}` }) as ReturnType< + typeof spawn + >) + : original(file, args, options) + ); + expect(await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).toBeDefined(); + expect(spawn).toHaveBeenCalledWith('ps', ['-p', '10', '-o', 'ppid='], { stdio: 'pipe' }); + expect(spawn).toHaveBeenCalledWith('ps', ['-p', '20', '-o', 'ppid='], { stdio: 'pipe' }); + expect(spawn).toHaveBeenCalledWith('ps', ['-p', String(CONTROLLER_PID), '-o', 'lstart='], { + stdio: 'pipe', + }); +}); + +it('captures all four preflight outputs through the real turtle-spawn wrapper', async () => { + const realSpawn = + jest.requireActual('@expo/turtle-spawn').default; + const original = jest.mocked(spawn).getMockImplementation()!; + await writeAsync(path.join(stateDir, 'daemon.json'), { + pid: 30, + httpPort: 12345, + token: 'agent-secret', + version: '1.2.3', + stateDir, + processStartTime: CONTROLLER_START_TIME, + }); + jest.mocked(spawn).mockImplementation((file, args, options) => { + if (file !== '/usr/sbin/sysctl' && file !== 'ps') { + return original(file, args, options); + } + const output = + file === '/usr/sbin/sysctl' + ? 'boot-1\n' + : args.includes('lstart=') + ? `${CONTROLLER_START_TIME}\n` + : `${CONTROLLER_PID}\n`; + // Execute only disposable Node children. Preserve options so inherited stdio cannot fake capture. + return realSpawn( + process.execPath, + ['-e', `process.stdout.write(${JSON.stringify(output)})`], + options + ); + }); + const warm = await tryClaimWarmIosSessionAsync({ engine: 'agent-device', env, logger }); + expect(warm?.tools?.pid).toBe(30); + const preflightCalls = jest + .mocked(spawn) + .mock.calls.filter(([file]) => file === '/usr/sbin/sysctl' || file === 'ps'); + expect(preflightCalls).toHaveLength(4); + for (const [, , options] of preflightCalls) { + expect(options).toEqual({ stdio: 'pipe' }); + } + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('rejects a valid-looking controller PID that is not an ancestor', async () => { + await writeAsync(STATE, { + status: 'ready', + device: { udid: UDID, name: 'iPhone 17' }, + bootUuid: 'boot-1', + controllerPid: CONTROLLER_PID + 1, + controllerStartTime: CONTROLLER_START_TIME, + agentDeviceStateDir: stateDir, + argentEventLogPath: '/events.jsonl', + }); + expect(await tryClaimWarmIosSessionAsync({ engine: 'serve-sim', env, logger })).toBeUndefined(); + expect(turtleFetch).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); +}); + +it('rejects Argent outside the recorded controller even when serve-sim has valid ancestry', async () => { + await writeAsync(path.join(os.homedir(), '.argent/tool-server.json'), { + pid: 40, + port: 3201, + token: 'argent-token', + bundlePath: '/cache/argent/dist/cli.mjs', + version: '1.2.3', + }); + await writeAsync('/cache/argent/package.json', { version: '1.2.3' }); + const original = jest.mocked(spawn).getMockImplementation()!; + jest + .mocked(spawn) + .mockImplementation((file, args, options) => + file === 'ps' && args[1] === '40' && args.includes('ppid=') + ? (Promise.resolve({ stdout: '1' }) as ReturnType) + : original(file, args, options) + ); + expect(await tryClaimWarmIosSessionAsync({ engine: 'argent', env, logger })).toBeUndefined(); + expect(spawn).toHaveBeenCalledWith('ps', ['-p', '40', '-o', 'ppid='], { stdio: 'pipe' }); + expect(jest.mocked(turtleFetch).mock.calls.some(([url]) => url.endsWith('/tools'))).toBe(false); + expect(spawn).toHaveBeenCalledWith( + 'bun', + ['x', '@expo/simulator-launcher@latest', 'stop'], + expect.anything() + ); +}); + +it.each([ + [UDID, true], + ['OTHER-DEVICE', false], +])( + 'takes the preinstalled Expo Go from a claim record for %s only when it names the claimed device', + async (recordUdid, kept) => { + const state = JSON.parse(await fs.readFile(STATE, 'utf8')); + await writeAsync(STATE, { + ...state, + preinstalledExpoGo: { + udid: recordUdid, + bundleIdentifier: 'host.exp.Exponent', + shortVersion: '58.0.7', + version: '58.0.7', + }, + }); + const warm = await tryClaimWarmIosSessionAsync({ + engine: 'serve-sim', + requestedDevice: UDID, + env, + logger, + }); + expect(warm?.preinstalledExpoGo).toEqual( + kept + ? { bundleIdentifier: 'host.exp.Exponent', shortVersion: '58.0.7', version: '58.0.7' } + : undefined + ); + } +); diff --git a/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts b/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts index 0717e01fdd..2ddcb94520 100644 --- a/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts +++ b/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts @@ -6,15 +6,16 @@ import fetch from 'node-fetch'; import os from 'node:os'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; import { z } from 'zod'; import { CustomBuildContext } from '../../customBuildContext'; import { Sentry } from '../../sentry'; import { formatBytes } from '../../utils/artifacts'; -import { sleepAsync } from '../../utils/retry'; import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts'; const AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000; +const AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS = 30_000; const AgentDeviceArtifactSchema = z.object({ id: z.string(), @@ -45,69 +46,128 @@ export async function pollAgentDeviceArtifactsForUploadAsync( daemonUrl, daemonToken, logger, + signal, + stopPollingSignal, }: { deviceRunSessionId: string; daemonUrl: string; daemonToken: string; logger: bunyan; + signal?: AbortSignal; + stopPollingSignal?: AbortSignal; } ): Promise { logger.info('Started polling agent-device daemon for artifacts.'); const uploadedArtifactIds = new Set(); let listArtifactsErrorCount = 0; - for (;;) { - try { - const artifacts = await listAgentDeviceArtifactsAsync({ daemonUrl, daemonToken }); - listArtifactsErrorCount = 0; - for (const artifact of artifacts) { - if (uploadedArtifactIds.has(artifact.id)) { - continue; + // Ending normal polling must not cancel a transfer that the session already produced. + const cleanup = new AbortController(); + const transferSignal = stopPollingSignal + ? AbortSignal.any([cleanup.signal, ...(signal ? [signal] : [])]) + : signal; + const pollingSignal = stopPollingSignal + ? AbortSignal.any([stopPollingSignal, ...(signal ? [signal] : [])]) + : signal; + let cleanupTimer: NodeJS.Timeout | undefined; + const startCleanupDeadline = (): void => { + cleanupTimer ??= setTimeout(() => cleanup.abort(), AGENT_DEVICE_ARTIFACT_CLEANUP_TIMEOUT_MS); + }; + stopPollingSignal?.addEventListener('abort', startCleanupDeadline, { once: true }); + if (stopPollingSignal?.aborted) { + startCleanupDeadline(); + } + + try { + for (;;) { + const finalInventory = stopPollingSignal?.aborted; + if (transferSignal?.aborted) { + return; + } + try { + const artifacts = await listAgentDeviceArtifactsAsync({ + daemonUrl, + daemonToken, + signal: transferSignal, + }); + listArtifactsErrorCount = 0; + for (const artifact of artifacts) { + if (uploadedArtifactIds.has(artifact.id)) { + continue; + } + try { + await uploadAgentDeviceArtifactAsync(ctx, { + deviceRunSessionId, + daemonUrl, + daemonToken, + artifact, + logger, + ...(transferSignal ? { signal: transferSignal } : {}), + }); + uploadedArtifactIds.add(artifact.id); + } catch (err) { + if (transferSignal?.aborted) { + return; + } + const error = err instanceof Error ? err : new Error(String(err)); + Sentry.capture('Could not upload agent-device remote session artifact', error); + logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.'); + } + } + } catch (err) { + if (transferSignal?.aborted) { + return; } - try { - await uploadAgentDeviceArtifactAsync(ctx, { - deviceRunSessionId, - daemonUrl, - daemonToken, - artifact, - logger, - }); - uploadedArtifactIds.add(artifact.id); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - Sentry.capture('Could not upload agent-device remote session artifact', error); - logger.warn({ err: error }, 'Could not upload agent-device remote session artifact.'); + if (err instanceof AgentDeviceArtifactsUnsupportedError) { + logger.warn( + 'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.' + ); + return; + } + const error = err instanceof Error ? err : new Error(String(err)); + listArtifactsErrorCount += 1; + // The final inventory has no later retry, so always report its failure. + if (finalInventory || listArtifactsErrorCount % 5 === 0) { + Sentry.capture('Could not list agent-device remote session artifacts', error); + logger.warn( + { err: error, failedArtifactListCount: listArtifactsErrorCount }, + 'Could not list agent-device remote session artifacts.' + ); } } - } catch (err) { - if (err instanceof AgentDeviceArtifactsUnsupportedError) { - logger.warn( - 'agent-device daemon does not expose artifact inventory; remote session artifact uploads are disabled.' - ); + if (finalInventory) { return; } - const error = err instanceof Error ? err : new Error(String(err)); - listArtifactsErrorCount += 1; - if (listArtifactsErrorCount % 5 === 0) { - Sentry.capture('Could not list agent-device remote session artifacts', error); - logger.warn( - { err: error, failedArtifactListCount: listArtifactsErrorCount }, - 'Could not list agent-device remote session artifacts.' - ); - } + await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS, undefined, { + signal: pollingSignal, + }).catch(error => { + if (!pollingSignal?.aborted) { + throw error; + } + }); + } + } finally { + clearTimeout(cleanupTimer); + stopPollingSignal?.removeEventListener('abort', startCleanupDeadline); + if (cleanup.signal.aborted) { + logger.warn( + 'Timed out draining agent-device artifacts; some session artifacts may not have been uploaded.' + ); } - await sleepAsync(AGENT_DEVICE_ARTIFACT_UPLOAD_POLL_INTERVAL_MS); } } export async function listAgentDeviceArtifactsAsync({ daemonUrl, daemonToken, + signal, }: { daemonUrl: string; daemonToken: string; + signal?: AbortSignal; }): Promise { const response = await fetch(new URL('/artifacts', daemonUrl).toString(), { + ...(signal ? { signal } : {}), headers: { Authorization: `Bearer ${daemonToken}` }, }); if (response.status === 404) { @@ -133,14 +193,17 @@ export async function uploadAgentDeviceArtifactAsync( daemonToken, artifact, logger, + signal, }: { deviceRunSessionId: string; daemonUrl: string; daemonToken: string; artifact: AgentDeviceArtifact; logger: bunyan; + signal?: AbortSignal; } ): Promise { + signal?.throwIfAborted(); logger.info(`Downloading artifact ${artifact.filename}.`); const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'agent-device-artifact-')); try { @@ -150,6 +213,7 @@ export async function uploadAgentDeviceArtifactAsync( daemonUrl, daemonToken, destinationPath: temporaryArtifactPath, + signal, }); const { size } = await stat(temporaryArtifactPath); logger.info(`Uploading artifact ${artifact.filename} (${formatBytes(size)}).`); @@ -164,6 +228,7 @@ export async function uploadAgentDeviceArtifactAsync( : {}), size, openStream: () => createReadStream(temporaryArtifactPath), + ...(signal ? { signal } : {}), }); } finally { await rm(temporaryDirectory, { recursive: true, force: true }); @@ -175,16 +240,19 @@ async function downloadAgentDeviceArtifactToFileAsync({ daemonUrl, daemonToken, destinationPath, + signal, }: { artifact: AgentDeviceArtifact; daemonUrl: string; daemonToken: string; destinationPath: string; + signal?: AbortSignal; }): Promise { const response = await fetch( new URL(`/artifacts/${encodeURIComponent(artifact.id)}`, daemonUrl).toString(), { headers: { Authorization: `Bearer ${daemonToken}` }, + ...(signal ? { signal } : {}), } ); if (!response.ok) { @@ -197,5 +265,9 @@ async function downloadAgentDeviceArtifactToFileAsync({ `Agent-device artifact ${artifact.id} response did not include a readable body.` ); } - await pipeline(response.body, createWriteStream(destinationPath)); + if (signal) { + await pipeline(response.body, createWriteStream(destinationPath), { signal }); + } else { + await pipeline(response.body, createWriteStream(destinationPath)); + } } diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 9dce8195b6..a61c9e83c9 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; @@ -874,7 +880,18 @@ async function startDeviceSessionHostInternalAsync( }); // The bundle that has not run yet: just installed, or preinstalled and never launched. let notYetLaunched: string | undefined; - if (app.installAppPath) { + 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 c75babbce3..18c8ecc4a1 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -588,7 +588,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..07e525c892 --- /dev/null +++ b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts @@ -0,0 +1,132 @@ +import type { bunyan } from '@expo/logger'; +import { BuildRuntimePlatform } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import { z } from 'zod'; + +import { type CustomBuildContext } from '../../customBuildContext'; +import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils'; +import { + type DeviceSessionHost, + type DeviceSessionHostOptions, + type DeviceSessionServer, + startDeviceSessionHostAsync, +} from './deviceSessionHost'; +import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout'; +import { type WarmIosSession } from './warmIosSession'; +import { fetchWebPreviewTurnArgsAsync, stopDetachedProcessAsync } from './remoteDeviceRunSession'; + +/** Only CLI adoption failures are eligible for a cold retry, before any app actions run. */ +export class WarmSessionAdoptionError extends Error { + constructor() { + // Package-manager errors can contain the whole command, including fresh TURN credentials. + super('Could not configure the warm serve-sim session for this job.'); + this.name = 'WarmSessionAdoptionError'; + } +} + +/** The caller owns failure cleanup and may retry cold only after the Simulator launcher confirms cleanup. */ +export async function startWarmDeviceSessionHostAsync( + ctx: CustomBuildContext, + options: DeviceSessionHostOptions, + warm: WarmIosSession, + startup?: { turnArgs?: Promise } +): Promise { + if (options.runtimePlatform !== BuildRuntimePlatform.DARWIN || !options.iosSimulatorUdid) { + throw new Error( + 'A warm session requires macOS and the UDID of the iOS Simulator being handed over.' + ); + } + return await startDeviceSessionHostAsync( + ctx, + options, + createWarmDeviceSessionServer(ctx, options.logger, warm, startup) + ); +} + +/** Adopts the serve-sim prepared before the job instead of launching one. */ +function createWarmDeviceSessionServer( + ctx: CustomBuildContext, + logger: bunyan, + warm: WarmIosSession, + startup?: { turnArgs?: Promise } +): DeviceSessionServer { + let recording: Awaited< + ReturnType + >; + async function stopSessionAsync(): Promise { + try { + await recording?.finishAsync(); + } catch (err) { + // Only a failed stop may reject: the host treats a rejection as a still-running server. + logger.warn( + { err }, + 'Could not finish the warm Simulator video recording; complete footage is not confirmed.' + ); + } + await warm.process.stopAsync(); + } + return { + port: warm.serveSim.port, + // Warm startup can fetch TURN while claiming the device. + turnArgs: startup?.turnArgs ?? fetchWebPreviewTurnArgsAsync(ctx, { logger }), + ...(warm.preinstalledExpoGo ? { preinstalledApp: warm.preinstalledExpoGo } : {}), + requiresPreviewRetirement: true, + stopAsync: stopSessionAsync, + async startAsync({ command, args, env, signal }) { + let adoption: ReturnType | undefined; + try { + recording = await IosSimulatorRecordingUtils.prepareAdoptedRecordingAsync({ + udid: warm.device.udid, + deviceName: warm.device.name, + serverToken: warm.serveSim.token, + recordingUrl: `http://127.0.0.1:${warm.serveSim.port}/helper/${encodeURIComponent(warm.device.udid)}/recording/video`, + }); + try { + await withDeviceRunSessionTimeoutAsync( + { name: 'Warm session adoption', timeoutMs: 60_000, signal }, + async adoptionSignal => { + adoption = spawn( + command, + [ + ...args, + '--adopt', + ...(recording + ? [ + '--recording-output', + recording.output, + '--recording-id', + recording.recordingId, + ] + : []), + ], + { + env, + detached: true, + stdio: 'pipe', + signal: adoptionSignal, + } + ); + const result = await adoption; + adoptionSignal.throwIfAborted(); + if (recording) { + z.object({ + recording: z.object({ + output: z.literal(recording.output), + recordingId: z.literal(recording.recordingId), + }), + }).parse(JSON.parse(result.stdout)); + // CLI success confirms native writer start; never infer it from generic active status. + } + } + ); + } finally { + await stopDetachedProcessAsync(adoption?.child?.pid, 1_000); + } + } catch { + signal?.throwIfAborted(); + throw new WarmSessionAdoptionError(); + } + return { ...warm.process, stopAsync: stopSessionAsync }; + }, + }; +} 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..5d52bd2170 --- /dev/null +++ b/packages/build-tools/src/steps/utils/warmIosSession.ts @@ -0,0 +1,359 @@ +import { type bunyan } from '@expo/logger'; +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; + +import { stopWarmSimulatorAsync } from '../../utils/warmSimulator'; +import { turtleFetch } from '../../utils/turtleFetch'; +import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession'; +import { listAgentDeviceArtifactsAsync } from './agentDeviceArtifacts'; + +const STARTUP_SCRIPT = path.join(os.homedir(), '.expo/simulator-tools/run'); +const SESSION_STATE_FILE = '/tmp/expo-simulator-startup.json'; +const SERVE_SIM_STATE_DIRECTORY = 'serve-sim'; +const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent'); +const SERVE_SIM_PORT = 3200; +const ARGENT_PORT = 3201; +const LOOPBACK_ORIGIN = 'http://127.0.0.1'; +const SERVE_SIM_URL = `${LOOPBACK_ORIGIN}:${SERVE_SIM_PORT}`; + +type ControllerIdentity = { controllerPid: number; controllerStartTime: string }; +const ServiceStateSchema = z.object({ + pid: z.number().int().gt(1), + port: z.number().int().min(1).max(65535), + token: z.string().min(1), +}); +const ServeStateSchema = ServiceStateSchema.extend({ device: z.string(), url: z.string() }); +const AgentStateSchema = z.object({ + pid: z.number().int().gt(1), + httpPort: z.number().int().min(1).max(65535), + token: z.string().min(1), + version: z.string(), + processStartTime: z.string(), + stateDir: z.string(), +}); +const ArgentStateSchema = ServiceStateSchema.extend({ + bundlePath: z.string(), + version: z.string().optional(), +}); +const ReadySchema = z.object({ status: z.literal('ready'), device: z.string() }); +const DeviceSchema = z.object({ udid: z.string().trim().min(1), name: z.string().trim().min(1) }); +/** Warm capture and selected tooling for this VM's job. */ +export type WarmIosSession = { + device: z.infer; + serveSim: z.infer; + tools?: z.infer; + agentDeviceStateDir: string; + argentEventLogPath: string; + /** The Expo Go the launcher preinstalled on this device, read from the app it installed. */ + preinstalledExpoGo?: { bundleIdentifier: string; shortVersion: string; version: string }; + process: DetachedProcessHandle; +}; + +function matchesRequestedPackageVersion(requested: string | undefined, actual: unknown): boolean { + return !requested || requested === 'latest' || requested === actual; +} + +async function readJsonAsync(file: string): Promise { + return JSON.parse(await fs.readFile(file, 'utf8')); +} + +async function isProcessOwnedByWarmSessionAsync( + pid: unknown, + controller: ControllerIdentity +): Promise { + if (!Number.isSafeInteger(pid) || (pid as number) <= 1) { + return false; + } + for (let parent = pid as number, depth = 0; parent > 1 && depth < 32; depth++) { + if (parent === controller.controllerPid) { + const result = await spawn('ps', ['-p', String(parent), '-o', 'lstart='], { + stdio: 'pipe', + }); + return result.stdout.trim() === controller.controllerStartTime; + } + const result = await spawn('ps', ['-p', String(parent), '-o', 'ppid='], { stdio: 'pipe' }); + const value = result.stdout.trim(); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) { + return false; + } + parent = Number(value); + } + return false; +} + +async function fetchWarmServiceJsonAsync( + state: z.infer, + port: number, + controller: ControllerIdentity, + route: string, + signal?: AbortSignal +): Promise { + if (state.port !== port || !(await isProcessOwnedByWarmSessionAsync(state.pid, controller))) { + throw new Error( + 'The warm service port or process does not match the Simulator launcher that started it.' + ); + } + return ( + await turtleFetch(`${LOOPBACK_ORIGIN}:${port}${route}`, 'GET', { + headers: { Authorization: `Bearer ${state.token}` }, + retries: 0, + timeout: 5_000, + signal, + }) + ).json(); +} + +async function preflightAgentDeviceAsync( + agentDeviceStateDir: string, + packageVersion?: string, + signal?: AbortSignal +) { + const state = AgentStateSchema.parse( + await readJsonAsync(path.join(agentDeviceStateDir, 'daemon.json')) + ); + // prepare starts a detached daemon, so PPID ancestry cannot identify it. Match its + // native registration's process start time and isolated state directory instead. + const processStart = ( + await spawn('ps', ['-p', String(state.pid), '-o', 'lstart='], { stdio: 'pipe' }) + ).stdout.trim(); + if ( + !processStart || + processStart !== state.processStartTime || + path.resolve(state.stateDir) !== path.resolve(agentDeviceStateDir) + ) { + throw new Error( + 'The warm agent-device process could not be verified: its start time or state directory does not match the saved state.' + ); + } + const tools = { pid: state.pid, port: state.httpPort, token: state.token }; + if (!matchesRequestedPackageVersion(packageVersion, state.version)) { + throw new Error( + "The warm agent-device version does not match the job's requested package_version." + ); + } + // This authenticated request verifies that the registered daemon accepts its token. + await listAgentDeviceArtifactsAsync({ + daemonUrl: `${LOOPBACK_ORIGIN}:${tools.port}`, + daemonToken: tools.token, + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(5_000)]) + : AbortSignal.timeout(5_000), + }); + return tools; +} + +async function preflightArgentAsync( + controller: ControllerIdentity, + packageVersion?: string, + signal?: AbortSignal +) { + for (const entry of await fs.readdir(ARGENT_STATE_DIR)) { + if (!/^tool-server.*\.json$/.test(entry)) { + continue; + } + const parsed = ArgentStateSchema.safeParse( + await readJsonAsync(path.join(ARGENT_STATE_DIR, entry)).catch(() => null) + ); + if (!parsed.success) { + continue; + } + const state = parsed.data; + // A file left by another Argent process can name the same port; only ours can fail adoption. + if ( + state.port !== ARGENT_PORT || + !(await isProcessOwnedByWarmSessionAsync(state.pid, controller)) + ) { + continue; + } + const pkg = z + .object({ version: z.string() }) + .parse(await readJsonAsync(path.join(path.dirname(state.bundlePath), '../package.json'))); + if (!matchesRequestedPackageVersion(packageVersion, state.version ?? pkg.version)) { + throw new Error( + "The warm Argent version does not match the job's requested package_version." + ); + } + const response = await fetchWarmServiceJsonAsync( + state, + ARGENT_PORT, + controller, + '/tools', + signal + ); + if (!z.object({ tools: z.array(z.unknown()) }).safeParse(response).success) { + throw new Error('The warm Argent server is missing or did not return its tool list.'); + } + return { pid: state.pid, port: state.port, token: state.token }; + } + throw new Error('The warm Argent server is missing or did not return its tool list.'); +} + +export async function tryClaimWarmIosSessionAsync({ + engine, + requestedDevice, + packageVersion, + env, + logger, + signal, +}: { + engine: 'serve-sim' | 'agent-device' | 'argent'; + requestedDevice?: string; + packageVersion?: string; + env: BuildStepEnv; + logger: bunyan; + signal?: AbortSignal; +}): Promise { + if (env.EAS_USE_WARM_SIMULATOR !== '1') { + return undefined; + } + let claiming = false; + try { + signal?.throwIfAborted(); + if (env.TMPDIR && path.resolve(env.TMPDIR) !== path.resolve(os.tmpdir())) { + throw new Error( + "The job's TMPDIR differs from the warm Simulator environment; normal startup requires cleanup first." + ); + } + const serveSimStateDirectory = path.join(os.tmpdir(), SERVE_SIM_STATE_DIRECTORY); + if ( + env.SERVE_SIM_STATE_DIR && + path.resolve(env.SERVE_SIM_STATE_DIR) !== serveSimStateDirectory + ) { + throw new Error( + "The job's SERVE_SIM_STATE_DIR differs from the warm Simulator environment; normal startup requires cleanup first." + ); + } + const unsupportedVariable = Object.keys(env).find( + name => name === 'SERVE_SIM_ADDITIONAL_DYLIBS' || name.startsWith('SIMCTL_CHILD_') + ); + if (unsupportedVariable) { + throw new Error( + `The job's ${unsupportedVariable} requires normal Simulator startup; the warm session must be cleaned up first.` + ); + } + const boot = ( + await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { stdio: 'pipe' }) + ).stdout.trim(); + if (!boot) { + throw new Error('No warm Simulator session is available for this VM boot.'); + } + const initialStatus = z + .object({ bootUuid: z.literal(boot), status: z.literal('ready') }) + .safeParse(await readJsonAsync(SESSION_STATE_FILE)); + if (!initialStatus.success) { + throw new Error('No warm Simulator session is available for this VM boot.', { + cause: initialStatus.error, + }); + } + claiming = true; + const result = await spawn( + process.execPath, + [ + STARTUP_SCRIPT, + 'claim', + engine, + ...(requestedDevice === undefined ? [] : [requestedDevice]), + ], + { logger, timeout: 425_000, signal } + ); + signal?.throwIfAborted(); + const config = z + .object({ + bootUuid: z.literal(boot), + status: z.literal('claimed'), + engine: z.literal(engine), + device: DeviceSchema, + controllerPid: z.number().int().gt(1), + controllerStartTime: z.string().trim().min(1), + agentDeviceStateDir: z.string().min(1), + argentEventLogPath: z.string().min(1), + preinstalledExpoGo: z + .object({ + udid: z.string(), + bundleIdentifier: z.string(), + shortVersion: z.string(), + version: z.string(), + }) + .optional(), + }) + .parse(JSON.parse(result.stdout)); + claiming = false; + const { device } = config; + const { udid } = device; + const serveState = ServeStateSchema.parse( + await readJsonAsync(path.join(os.tmpdir(), SERVE_SIM_STATE_DIRECTORY, `server-${udid}.json`)) + ); + if (serveState.device.toUpperCase() !== udid.toUpperCase()) { + throw new Error( + `Warm serve-sim reports device ${serveState.device}, but claim returned ${udid}.` + ); + } + if (serveState.url !== SERVE_SIM_URL) { + throw new Error( + `The warm serve-sim address does not match ${SERVE_SIM_URL}; the session cannot be reused.` + ); + } + const ready = ReadySchema.parse( + await fetchWarmServiceJsonAsync(serveState, SERVE_SIM_PORT, config, '/readyz', signal) + ); + if (ready.device.toUpperCase() !== udid.toUpperCase()) { + throw new Error( + 'The warm serve-sim server reports a different Simulator than the device handed over to this job.' + ); + } + const serveSim = { pid: serveState.pid, port: serveState.port, token: serveState.token }; + const { agentDeviceStateDir, argentEventLogPath } = config; + const tools = + engine === 'agent-device' + ? await preflightAgentDeviceAsync(agentDeviceStateDir, packageVersion, signal) + : engine === 'argent' + ? await preflightArgentAsync(config, packageVersion, signal) + : undefined; + signal?.throwIfAborted(); + logger.info(`Claimed warm ${engine} on Simulator ${udid}.`); + let stopping: Promise | undefined; + const preinstalled = config.preinstalledExpoGo; + return { + device, + serveSim, + tools, + agentDeviceStateDir, + argentEventLogPath, + ...(preinstalled && preinstalled.udid.toUpperCase() === udid.toUpperCase() + ? { + preinstalledExpoGo: { + bundleIdentifier: preinstalled.bundleIdentifier, + shortVersion: preinstalled.shortVersion, + version: preinstalled.version, + }, + } + : {}), + process: { + pid: serveSim.pid, + getOutput: () => '', + getExitError: () => + isProcessRunning(serveSim.pid) && (!tools || isProcessRunning(tools.pid)) + ? undefined + : new Error('A service required by the warm Simulator session has exited.'), + stopAsync: () => (stopping ??= stopWarmSimulatorAsync(logger, env)), + }, + }; + } catch (error) { + logger.warn( + { err: error }, + claiming + ? 'Warm Simulator session claim did not complete; stopping its services. This claim will not be retried.' + : 'The warm Simulator session cannot be used; cleanup is required before normal startup.' + ); + await stopWarmSimulatorAsync(logger, env); + signal?.throwIfAborted(); + if (claiming) { + throw error; + } + return undefined; + } +} 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..2bed133adb --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/warmSimulator.test.ts @@ -0,0 +1,77 @@ +import { type bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; + +import { 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: '' } as Awaited>); +}); +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +it('does nothing on non-Darwin workers', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + await stopWarmSimulatorAsync(); + expect(spawn).not.toHaveBeenCalled(); +}); + +it.each([ + [{}, 'bun', ['x']], + [{ EAS_FALLBACK_PACKAGE_MANAGER: 'npm' }, 'npx', ['--yes']], + [{ EAS_OVERRIDE_PACKAGE_MANAGER: 'pnpm' }, 'pnpm', ['dlx']], + [{ EAS_OVERRIDE_PACKAGE_MANAGER: 'yarn' }, 'npx', ['--yes']], + [{ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm', EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, 'npx', ['--yes']], +])('uses the configured package manager for %j', async (env, command, prefix) => { + const logger = {} as bunyan; + await stopWarmSimulatorAsync(logger, env); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledWith( + command, + [...prefix, '@expo/simulator-launcher@latest', 'stop'], + { env, logger, stdio: 'pipe', timeout: 150_000 } + ); +}); + +it('awaits the CLI stop result before allowing job setup', async () => { + let finish!: () => void; + const stopped = new Promise>>(resolve => { + finish = () => resolve({ stdout: '' } as Awaited>); + }); + jest.mocked(spawn).mockReturnValue(stopped as ReturnType); + let complete = false; + const stopping = stopWarmSimulatorAsync(undefined, {}).then(() => { + complete = true; + }); + try { + await Promise.resolve(); + expect(complete).toBe(false); + finish(); + await stopping; + expect(complete).toBe(true); + } finally { + finish(); + await stopping; + } +}); + +it('propagates download or teardown failures instead of permitting setup', async () => { + const error = new Error('launcher stop failed'); + jest.mocked(spawn).mockRejectedValue(error); + await expect(stopWarmSimulatorAsync(undefined, {})).rejects.toBe(error); +}); + +it('rejects an invalid package manager preference', async () => { + await expect( + stopWarmSimulatorAsync(undefined, { + EAS_OVERRIDE_PACKAGE_MANAGER: 'invalid', + }) + ).rejects.toThrow('Invalid EAS_OVERRIDE_PACKAGE_MANAGER'); + 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..c52749cebe --- /dev/null +++ b/packages/build-tools/src/utils/warmSimulator.ts @@ -0,0 +1,24 @@ +import { type bunyan } from '@expo/logger'; +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; + +import { + PackageManager, + resolveConfiguredPackageManager, + resolvePackageExec, +} from './packageManager'; + +/** Stop warmup through the launcher CLI and await its cleanup acknowledgement. */ +export async function stopWarmSimulatorAsync( + logger?: bunyan, + env: BuildStepEnv = process.env +): Promise { + if (process.platform !== 'darwin') { + return; + } + const { command, args } = resolvePackageExec( + resolveConfiguredPackageManager(env, PackageManager.BUN), + ['@expo/simulator-launcher@latest', 'stop'] + ); + await spawn(command, args, { env, logger, stdio: 'pipe', timeout: 150_000 }); +}