From 924024c67759947d2dc5b69b90edac1d8e060f77 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 23:45:47 -0700 Subject: [PATCH 1/2] [build-tools] Add warm iOS Simulator session steps Co-Authored-By: Claude Opus 5.5 Edited-by: Codex Edited-by: Codex --- .changeset/prepared-simulator-sessions.md | 5 + .../src/steps/__tests__/warmFunctions.test.ts | 18 + .../build-tools/src/steps/easFunctions.ts | 3 + ...tDeviceRemoteSession-orchestration.test.ts | 43 ++ .../__tests__/startAgentDeviceSession.test.ts | 453 ++++++------- ...tArgentRemoteSession-orchestration.test.ts | 72 ++ .../startWebPreviewRemoteSession.test.ts | 28 + .../startAgentDeviceRemoteSession.ts | 120 ++-- .../functions/startAgentDeviceSession.ts | 328 ++++++---- .../functions/startArgentRemoteSession.ts | 619 ++++++++++-------- .../functions/startWebPreviewRemoteSession.ts | 211 +++--- .../__tests__/warmDeviceSessionHost.test.ts | 56 +- .../utils/__tests__/warmIosSession.test.ts | 7 + .../src/steps/utils/remoteDeviceRunSession.ts | 2 +- .../src/steps/utils/warmDeviceSessionHost.ts | 27 +- .../src/steps/utils/warmIosSession.ts | 4 + 16 files changed, 1200 insertions(+), 796 deletions(-) create mode 100644 .changeset/prepared-simulator-sessions.md create mode 100644 packages/build-tools/src/steps/__tests__/warmFunctions.test.ts diff --git a/.changeset/prepared-simulator-sessions.md b/.changeset/prepared-simulator-sessions.md new file mode 100644 index 0000000000..2774780044 --- /dev/null +++ b/.changeset/prepared-simulator-sessions.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Add warm iOS session steps (`eas/start_warm_serve_sim_remote_session`, `eas/start_warm_agent_device_session`, `eas/start_warm_argent_remote_session`). They run the existing session on the warm Simulator, or start cold. `eas/start_agent_device_session` also accepts `install_app_path` and `launch_app_identifier`. diff --git a/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts b/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts new file mode 100644 index 0000000000..618aa526d1 --- /dev/null +++ b/packages/build-tools/src/steps/__tests__/warmFunctions.test.ts @@ -0,0 +1,18 @@ +import { type CustomBuildContext } from '../../customBuildContext'; +import { getEasFunctions } from '../easFunctions'; + +it('registers opt-in session functions with the existing inputs', () => { + const functions = getEasFunctions({ hasBuildJob: () => false } as CustomBuildContext); + for (const name of ['serve_sim_remote', 'agent_device', 'argent_remote']) { + const existing = functions.find(fn => fn.id === `start_${name}_session`)!; + const warm = functions.find(fn => fn.id === `start_warm_${name}_session`)!; + expect(warm.getFullId()).toBe(`eas/start_warm_${name}_session`); + const warmInputs = warm.inputProviders!.map(input => input(undefined as never, 'warm').id); + const existingInputs = existing.inputProviders!.map( + input => input(undefined as never, 'existing').id + ); + expect(warmInputs).toEqual(existingInputs); + } + const ids = functions.map(fn => fn.getFullId()); + expect(new Set(ids).size).toBe(ids.length); +}); diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index aaa8a91338..ac044eae08 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -111,7 +111,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { runFastlaneFunction(), parseXcactivitylogFunction(), createStartAgentDeviceSessionBuildFunction(ctx), + createStartAgentDeviceSessionBuildFunction(ctx, { warm: true }), createStartArgentRemoteSessionBuildFunction(ctx), + createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }), createStartAppiumRemoteSessionBuildFunction(ctx), createStartAndroidEmulatorBuildFunction(), createStartCuttlefishDeviceBuildFunction(), @@ -121,6 +123,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { createFinishIosSimulatorRecordingsBuildFunction(), createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx), createStartWebPreviewRemoteSessionBuildFunction(ctx), + createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }), createStartServeSimMetricsBuildFunction(), createStartServeSimLogsBuildFunction(), createCollectServeSimLogsBuildFunction(ctx), diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts index 1be5e434f2..5b8fe086ce 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceRemoteSession-orchestration.test.ts @@ -21,6 +21,8 @@ import { } from '../../utils/remoteDeviceRunSession'; import { createProcessOutput } from '../../utils/processOutput'; import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks'; +import { createWarmDeviceSessionServer } from '../../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../../utils/warmIosSession'; import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession'; // The daemon entry path and the state directory are resolved from the home directory when @@ -42,6 +44,7 @@ jest.mock('../../utils/agentDeviceEvents', () => ({ startAgentDeviceEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../utils/warmDeviceSessionHost'); jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), getDeviceRunSessionIdOrThrow: jest.fn(), @@ -195,6 +198,46 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1); }); + it('uses the warm daemon after adoption and starts no daemon of its own', async () => { + const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; + const warmServer = { port: 3200 } as never; + jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer); + const warm = { + agentDeviceStateDir: '/warm/agent', + tools: { pid: 20, port: 4310, token: 'warm-token' }, + } as WarmIosSession; + await runAgentDeviceRemoteSessionAsync(ctx, { + env: {}, + logger: logger as never, + runtimePlatform: BuildRuntimePlatform.DARWIN, + sessionEnv: { + deviceRunSessionId: 'device-run-session-id', + ngrokTunnelDomain: 'tunnel.example.com', + ngrokAuthtoken: 'ngrok-token', + }, + packageVersion: undefined, + maxIdleTimeMinutes: undefined, + maxDurationSeconds: undefined, + capture: NO_CAPTURE, + tasks: createStartupTasks(logger as never), + warm, + device: { iosSimulatorUdid: 'UDID', application: Promise.resolve() }, + }); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer); + expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 4310 })); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + remoteConfig: expect.objectContaining({ agentDeviceRemoteSessionToken: 'warm-token' }), + }) + ); + expect(startAgentDeviceEventCollectionAsync).toHaveBeenCalledWith( + expect.objectContaining({ stateDir: '/warm/agent' }) + ); + expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + }); + it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => { const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; const token = 'daemon secret/+'; diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts index 3661d164b2..0f19300a83 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts @@ -4,7 +4,6 @@ import spawn from '@expo/turtle-spawn'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; -import { DeviceRunSessionWorkerApi } from '../../utils/deviceRunSessionWorkerApi'; import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { stopLocalEgressResourcesAsync } from '../../utils/localEgress'; import { @@ -20,6 +19,7 @@ import { getAgentDeviceRemoteSessionEnvOrThrow, runAgentDeviceRemoteSessionAsync, } from '../startAgentDeviceRemoteSession'; +import { runOnWarmIosSimulatorAsync } from '../../utils/warmDeviceSessionHost'; import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession'; import { startAndroidEmulatorAsync } from '../startAndroidEmulator'; import { bootIosSimulatorAsync } from '../startIosSimulator'; @@ -42,6 +42,7 @@ jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn(), })); jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() })); +jest.mock('../../utils/warmDeviceSessionHost'); jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() })); jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() })); jest.mock('../../utils/iosAppArtifact', () => ({ @@ -102,9 +103,10 @@ function runStep( runtimePlatform: BuildRuntimePlatform, inputValues: Record = {}, signal?: AbortSignal, - stepCtx: CustomBuildContext = ctx + stepCtx: CustomBuildContext = ctx, + warm = false ): Promise { - const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx); + const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx, { warm }); const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() }; const inputs = Object.fromEntries( [ @@ -113,8 +115,10 @@ function runStep( 'lcd_width', 'lcd_height', 'lcd_density', + 'install_app_path', 'build_id', 'application_archive_url', + 'launch_app_identifier', 'launch_args', 'open_url', 'network_capture', @@ -172,7 +176,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { emulatorPromise: Promise.resolve(), shouldAdjustAnimationScale: true, }); - jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' }); jest .mocked(installBuildAsync) .mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' }); @@ -188,7 +191,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { }; jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv); await runStep(BuildRuntimePlatform.DARWIN, { - build_id: 'build-id', + install_app_path: '/tmp/App.app', launch_args: ['-flag'], open_url: 'exp://example.test', }); @@ -202,14 +205,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0] ); expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).toHaveBeenCalledWith( - expect.objectContaining({ - buildId: 'build-id', - extensions: ['app'], - graphqlClient, - robotAccessToken: 'robot-token', - }) - ); + expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1); const device = sessionDevice(); expect(device).toEqual({ @@ -253,63 +249,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { ); }); - it('downloads build_id through the device run session worker API when the job has a session token', async () => { - const sessionCtx = { - graphqlClient, - env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' }, - expoApiV2BaseUrl: 'https://api.expo.test/v2/', - } as unknown as CustomBuildContext; - - await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx); - - expect(downloadBuildAsync).toHaveBeenCalledWith( - expect.objectContaining({ - buildId: 'build-id', - deviceRunSessionWorkerApi: expect.any(DeviceRunSessionWorkerApi), - }) - ); - }); - - it('does not use the worker API without a session token or for an archive URL', async () => { - await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }); - expect(downloadBuildAsync).toHaveBeenLastCalledWith( - expect.objectContaining({ buildId: 'build-id', deviceRunSessionWorkerApi: null }) - ); - - const sessionCtx = { - graphqlClient, - env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' }, - expoApiV2BaseUrl: 'https://api.expo.test/v2/', - } as unknown as CustomBuildContext; - await runStep( - BuildRuntimePlatform.LINUX, - { application_archive_url: 'https://example.test/app.tar.gz' }, - undefined, - sessionCtx - ); - expect(downloadBuildAsync).toHaveBeenLastCalledWith( - expect.objectContaining({ - applicationArchiveUrl: 'https://example.test/app.tar.gz', - deviceRunSessionWorkerApi: null, - }) - ); - }); - - it('fails before download or host startup when guarded device shutdown fails', async () => { + it('fails before app installation or host startup when guarded device shutdown fails', async () => { jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', }); jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never); jest.mocked(spawn).mockRejectedValue(new Error('shutdown failed')); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'shutdown failed' - ); - expect(downloadBuildAsync).not.toHaveBeenCalled(); + await expect( + runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }) + ).rejects.toThrow('shutdown failed'); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1); }); - it('provides Simulator preparation and downloaded startup options to the remote session', async () => { + it('provides Simulator preparation and local app startup options to the remote session', async () => { const hostReady = deferred(); jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({ SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', @@ -324,7 +278,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { }); const step = runStep(BuildRuntimePlatform.DARWIN, { device_identifier: 'iPhone 17', - build_id: 'build-id', + install_app_path: '/tmp/App.app', launch_args: ['-flag'], open_url: 'exp://example.test', }); @@ -334,7 +288,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { env: {}, }); expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).toHaveBeenCalledTimes(1); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({ artifactPath: '/tmp/App.app', env: {}, @@ -352,15 +306,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { }); }); - it('fails a guarded session before download or host startup if its boot files are missing', async () => { + it('fails a guarded session before app installation or host startup if its boot files are missing', async () => { jest .mocked(resolveLocalEgressServeSimBootEnvironmentAsync) .mockRejectedValue(new Error('missing bin/egress-guard-check')); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'missing bin/egress-guard-check' - ); + await expect( + runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }) + ).rejects.toThrow('missing bin/egress-guard-check'); expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled(); expect(spawn).not.toHaveBeenCalled(); @@ -375,35 +329,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { jest .mocked(runAgentDeviceRemoteSessionAsync) .mockRejectedValueOnce(new Error('guard check failed')); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'guard check failed' - ); + await expect( + runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }) + ).rejects.toThrow('guard check failed'); expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1); expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1); expect(installBuildAsync).not.toHaveBeenCalled(); expect(launchApplicationAsync).not.toHaveBeenCalled(); }); - it('waits for the download before handing the app to the host', async () => { - const download = deferred<{ artifactPath: string }>(); - jest.mocked(downloadBuildAsync).mockReturnValue(download.promise); - - const step = runStep(BuildRuntimePlatform.DARWIN, { - application_archive_url: 'https://example.test/app.tar.gz', - }); - await flushAsync(); - expect(installBuildAsync).not.toHaveBeenCalled(); - - download.resolve({ artifactPath: '/tmp/App.app' }); - await step; - expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1); - expect(installBuildAsync).not.toHaveBeenCalled(); - }); - it('passes the selected Simulator to the host even when there is no app', async () => { await runStep(BuildRuntimePlatform.DARWIN); - expect(downloadBuildAsync).not.toHaveBeenCalled(); expect(installBuildAsync).not.toHaveBeenCalled(); expect(launchApplicationAsync).not.toHaveBeenCalled(); expect(sessionDevice()).toEqual({ @@ -420,7 +357,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { lcd_width: 720, lcd_height: 1600, lcd_density: 300, - build_id: 'build-id', + install_app_path: '/tmp/App.app', }); expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled(); @@ -433,131 +370,33 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { lcdDensity: 300, }) ); - expect(downloadBuildAsync).toHaveBeenCalledWith( - expect.objectContaining({ extensions: ['apk'] }) - ); + expect(launchApplicationAsync).toHaveBeenCalledWith( expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' }) ); }); - it('fails the session when the download fails, without an unhandled rejection', async () => { - jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed')); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'download failed' - ); - expect(installBuildAsync).not.toHaveBeenCalled(); - }); - - /** A download that runs until its abort signal fires, like a stalled one. */ - function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } { - let aborted = false; - let settled = false; - jest.mocked(downloadBuildAsync).mockImplementation( - ({ signal }) => - new Promise((_resolve, reject) => { - signal!.addEventListener('abort', () => { - aborted = true; - // Settles a bit later, like a request that winds down. - setImmediate(() => { - settled = true; - reject(new Error('The user aborted a request.')); - }); - }); - }) - ); - return { aborted: () => aborted, settled: () => settled }; - } - - /** Runs the session like the real one: fails with the daemon, then waits for every part. */ - function mockSessionWithFailingDaemon(beforeFailure?: () => Promise): void { - jest - .mocked(runAgentDeviceRemoteSessionAsync) - .mockImplementation(async (_ctx, { tasks, device }) => { - const ready = runDevicePreparationAsync(device); - await beforeFailure?.(); - const daemon = tasks.run('agent-device daemon', async () => { - throw new Error('daemon failed'); - }); - try { - await Promise.all([daemon, ready]); - } finally { - await Promise.allSettled([daemon, ready]); - } - }); - } - - it('stops the download and waits for it when Android boot fails', async () => { - const download = mockStalledDownload(); - jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed')); - - await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow( - 'boot failed' - ); - - expect(download.aborted()).toBe(true); - // The step returned only after the download stopped. - expect(download.settled()).toBe(true); - expect(installBuildAsync).not.toHaveBeenCalled(); - }); - - it('stops a stalled download and does not install when the daemon fails', async () => { - const download = mockStalledDownload(); - mockSessionWithFailingDaemon(); - - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'daemon failed' - ); - - expect(download.aborted()).toBe(true); - expect(download.settled()).toBe(true); - expect(installBuildAsync).not.toHaveBeenCalled(); - expect(launchApplicationAsync).not.toHaveBeenCalled(); - }); - - it('drains the download when host startup fails before app preparation', async () => { - const download = mockStalledDownload(); - jest.mocked(runAgentDeviceRemoteSessionAsync).mockRejectedValue(new Error('host failed')); - - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'host failed' - ); - expect(download.aborted()).toBe(true); - expect(download.settled()).toBe(true); - expect(installBuildAsync).not.toHaveBeenCalled(); - }); - it('does not start work for an already cancelled step', async () => { const controller = new AbortController(); controller.abort(new Error('cancelled')); await expect( - runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal) + runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app' }, controller.signal) ).rejects.toThrow('cancelled'); expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).not.toHaveBeenCalled(); - expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); - }); - - it('cancels and drains a pending download through the external signal', async () => { - const controller = new AbortController(); - const download = mockStalledDownload(); - const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal); - const failure = expect(step).rejects.toThrow('cancelled'); - await flushAsync(); - controller.abort(new Error('cancelled')); - await failure; - expect(download.aborted()).toBe(true); - expect(download.settled()).toBe(true); - expect(installBuildAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); - it('does not start device or download work after cancellation during Xcode selection', async () => { + it('does not start device or app work after cancellation during Xcode selection', async () => { const controller = new AbortController(); const selected = deferred(); jest.mocked(selectXcodeDeveloperDirectoryAsync).mockReturnValue(selected.promise); - const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }, controller.signal); + const step = runStep( + BuildRuntimePlatform.DARWIN, + { install_app_path: '/tmp/App.app' }, + controller.signal + ); const failure = expect(step).rejects.toThrow('cancelled'); await flushAsync(); controller.abort(new Error('cancelled')); @@ -566,42 +405,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { await failure; expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled(); expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).not.toHaveBeenCalled(); - expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); - }); - it('drains app preparation when the daemon fails', async () => { - const metadata = deferred(); - const metadataStarted = deferred(); - jest.mocked(readIosApplicationIdentifierAsync).mockImplementation(() => { - metadataStarted.resolve(); - return metadata.promise; - }); - mockSessionWithFailingDaemon(async () => { - await metadataStarted.promise; - setImmediate(() => metadata.resolve('dev.example.app')); - }); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( - 'daemon failed' - ); - expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1); - expect(installBuildAsync).not.toHaveBeenCalled(); - expect(launchApplicationAsync).not.toHaveBeenCalled(); - expect(deviceLaunch).toBeUndefined(); - }); - - it('rejects conflicting application inputs before it boots anything', async () => { - await expect( - runStep(BuildRuntimePlatform.DARWIN, { - build_id: 'build-id', - application_archive_url: 'https://example.test/app.tar.gz', - }) - ).rejects.toThrow('Pass only one of build_id or application_archive_url.'); - await expect( - runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' }) - ).rejects.toThrow('launch_args and open_url need an application'); - - expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); @@ -617,8 +421,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { 'lcd_width', 'lcd_height', 'lcd_density', + 'install_app_path', 'build_id', 'application_archive_url', + 'launch_app_identifier', 'launch_args', 'open_url', 'network_capture', @@ -678,12 +484,15 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { ['NUL URL', { open_url: 'example:\u0000screen' }], ['URL length', { open_url: `example://${'a'.repeat(8192)}` }], ])('rejects iOS launch %s before startup work', async (_name, inputs) => { - const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', ...inputs }); + const step = runStep(BuildRuntimePlatform.DARWIN, { + install_app_path: '/tmp/App.app', + ...inputs, + }); await expect(step).rejects.toBeInstanceOf(UserError); expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled(); expect(IosSimulatorUtils.resolveUdidAsync).not.toHaveBeenCalled(); expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); @@ -692,16 +501,22 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { ['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }], ['NUL URL', { open_url: 'example:\u0000screen' }], ])('rejects Android launch %s before startup work', async (_name, inputs) => { - const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs }); + const step = runStep(BuildRuntimePlatform.LINUX, { + install_app_path: '/tmp/App.app', + ...inputs, + }); await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }); expect(startAndroidEmulatorAsync).not.toHaveBeenCalled(); - expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); it('keeps Android launch inputs on its existing direct path', async () => { const launchArgs = ['a'.repeat(9000)]; - await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs }); + await runStep(BuildRuntimePlatform.LINUX, { + install_app_path: '/tmp/App.app', + launch_args: launchArgs, + }); expect(launchApplicationAsync).toHaveBeenCalledWith( expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs }) ); @@ -728,4 +543,170 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expect(startAndroidEmulatorAsync).not.toHaveBeenCalled(); expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); + it('requires an application for launch options before starting the device', async () => { + await expect( + runStep(BuildRuntimePlatform.DARWIN, { open_url: 'fixture://route' }) + ).rejects.toThrow('pass install_app_path or launch_app_identifier'); + expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + }); + + it('launches an installed iOS application without reading an app artifact', async () => { + await runStep(BuildRuntimePlatform.DARWIN, { + launch_app_identifier: 'dev.installed.app', + launch_args: ['--test'], + open_url: 'fixture://route', + }); + expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled(); + expect(deviceLaunch).toEqual({ + installAppPath: undefined, + launchAppIdentifier: 'dev.installed.app', + launchArgs: ['--test'], + openUrl: 'fixture://route', + }); + }); + + it('keeps explicit application identifier launch iOS-only', async () => { + await expect( + runStep(BuildRuntimePlatform.LINUX, { launch_app_identifier: 'dev.app' }) + ).rejects.toThrow('Pass install_app_path for Android'); + expect(startAndroidEmulatorAsync).not.toHaveBeenCalled(); + }); + + it('still downloads a build_id app and launches it on iOS', async () => { + jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' }); + await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'] }); + expect(downloadBuildAsync).toHaveBeenCalledWith( + expect.objectContaining({ buildId: 'build-id', extensions: ['app'] }) + ); + expect(deviceLaunch).toEqual({ + installAppPath: '/tmp/Downloaded.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['-flag'], + openUrl: undefined, + }); + }); + + it('still downloads an application_archive_url app and installs it on Android', async () => { + jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.apk' }); + await runStep(BuildRuntimePlatform.LINUX, { + application_archive_url: 'https://example.test/app.tar.gz', + }); + expect(downloadBuildAsync).toHaveBeenCalledWith( + expect.objectContaining({ + applicationArchiveUrl: 'https://example.test/app.tar.gz', + extensions: ['apk'], + }) + ); + expect(installBuildAsync).toHaveBeenCalledWith( + expect.objectContaining({ artifactPath: '/tmp/Downloaded.apk' }) + ); + expect(launchApplicationAsync).toHaveBeenCalledTimes(1); + }); + + it('runs the warm step on the warm Simulator for a local app only', async () => { + jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run()); + const runWarm = (inputs: Record) => + runStep(BuildRuntimePlatform.DARWIN, inputs, undefined, ctx, true); + await runWarm({ install_app_path: '/tmp/App.app', device_identifier: 'iPhone 17' }); + expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith( + expect.objectContaining({ engine: 'agent-device', requestedDevice: 'iPhone 17' }), + expect.any(Function) + ); + jest.mocked(runOnWarmIosSimulatorAsync).mockClear(); + jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/Downloaded.app' }); + await runWarm({ build_id: 'build-id' }); + expect(runOnWarmIosSimulatorAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(2); + }); + + it('accepts only one application source', async () => { + await expect( + runStep(BuildRuntimePlatform.DARWIN, { install_app_path: '/tmp/App.app', build_id: 'id' }) + ).rejects.toThrow('Pass only one of install_app_path, build_id or application_archive_url.'); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + }); + + it('waits for Android boot before installing the supplied local APK', async () => { + const boot = deferred(); + jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise as never); + const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' }); + await flushAsync(); + expect(installBuildAsync).not.toHaveBeenCalled(); + boot.resolve(); + await step; + expect(installBuildAsync).toHaveBeenCalledWith( + expect.objectContaining({ artifactPath: '/tmp/App.apk' }) + ); + expect(launchApplicationAsync).toHaveBeenCalledTimes(1); + }); + + it('drains an in-flight Android install on cancellation and never launches it', async () => { + const controller = new AbortController(); + const installation = deferred<{ applicationIdentifier: string; activityName: string }>(); + jest.mocked(installBuildAsync).mockReturnValue(installation.promise); + const step = runStep( + BuildRuntimePlatform.LINUX, + { install_app_path: '/tmp/App.apk' }, + controller.signal + ); + let settled = false; + void step.then( + () => (settled = true), + () => (settled = true) + ); + const failure = expect(step).rejects.toThrow('cancelled'); + await flushAsync(); + expect(installBuildAsync).toHaveBeenCalledTimes(1); + controller.abort(new Error('cancelled')); + await flushAsync(); + expect(settled).toBe(false); + installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' }); + await failure; + expect(launchApplicationAsync).not.toHaveBeenCalled(); + }); + + it('drains an in-flight Android install when session startup fails', async () => { + const entered = deferred(); + const installation = deferred<{ applicationIdentifier: string; activityName: string }>(); + jest.mocked(installBuildAsync).mockImplementation(() => { + entered.resolve(); + return installation.promise; + }); + jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async () => { + await entered.promise; + throw new Error('session startup failed'); + }); + const step = runStep(BuildRuntimePlatform.LINUX, { install_app_path: '/tmp/App.apk' }); + let settled = false; + void step.then( + () => (settled = true), + () => (settled = true) + ); + const failure = expect(step).rejects.toThrow('session startup failed'); + await entered.promise; + await flushAsync(); + expect(settled).toBe(false); + installation.resolve({ applicationIdentifier: 'dev.app', activityName: '.Main' }); + await failure; + expect(launchApplicationAsync).not.toHaveBeenCalled(); + }); + + it('drains iOS metadata reading on cancellation without starting the session', async () => { + const controller = new AbortController(); + const metadata = deferred(); + jest.mocked(readIosApplicationIdentifierAsync).mockReturnValue(metadata.promise); + const step = runStep( + BuildRuntimePlatform.DARWIN, + { install_app_path: '/tmp/App.app' }, + controller.signal + ); + const failure = expect(step).rejects.toThrow('cancelled'); + await flushAsync(); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1); + controller.abort(new Error('cancelled')); + metadata.resolve('dev.app'); + await failure; + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + }); }); diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index 8760713167..912b95edb4 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -24,6 +24,11 @@ import { uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, } from '../../utils/remoteDeviceRunSession'; +import { + createWarmDeviceSessionServer, + runOnWarmIosSimulatorAsync, +} from '../../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../../utils/warmIosSession'; import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession'; import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact'; @@ -50,6 +55,7 @@ jest.mock('../../utils/argentEvents', () => ({ startArgentEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../utils/warmDeviceSessionHost'); jest.mock('../../utils/iosAppArtifact', () => ({ ...jest.requireActual('../../utils/iosAppArtifact'), readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), @@ -605,6 +611,72 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => { expect(JSON.stringify(logger.info.mock.calls)).not.toContain('secret'); }); + it('uses the warm Argent server and drains its artifacts before stopping the host', async () => { + const ctx = {} as unknown as CustomBuildContext; + const warmServer = { port: 3200 } as never; + const warm = { + device: { udid: 'WARM-UDID', name: 'iPhone 17' }, + argentEventLogPath: '/warm/argent-events', + tools: { pid: 30, port: 3201, token: 'warm-token' }, + } as WarmIosSession; + jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer); + jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm)); + let releaseArtifacts = () => {}; + const artifactsDrained = new Promise(resolve => { + releaseArtifacts = resolve; + }); + let pollingStarted = () => {}; + const polling = new Promise(resolve => { + pollingStarted = resolve; + }); + jest.mocked(pollArgentArtifactsForUploadAsync).mockImplementationOnce(async () => { + pollingStarted(); + await artifactsDrained; + }); + const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx, { warm: true }); + expect(buildFunction.id).toBe('start_warm_argent_remote_session'); + if (!buildFunction.fn) { + throw new Error('Expected the Argent session function.'); + } + const running = buildFunction.fn( + { + logger: { info: jest.fn(), warn: jest.fn() }, + global: { runtimePlatform: BuildRuntimePlatform.DARWIN }, + } as unknown as BuildStepContext, + { + inputs: { + package_version: { value: undefined }, + max_idle_time_minutes: { value: undefined }, + max_duration_seconds: { value: undefined }, + }, + outputs: {}, + env: {}, + } as never + ); + try { + await polling; + await new Promise(resolve => setImmediate(resolve)); + expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalled(); + expect(mockPreviewStopAsync).not.toHaveBeenCalled(); + } finally { + releaseArtifacts(); + await running; + } + expect(spawn).not.toHaveBeenCalled(); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, expect.anything(), warmServer); + expect(startNgrokTunnelAsync).toHaveBeenCalledWith(expect.objectContaining({ port: 3201 })); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + remoteConfig: expect.objectContaining({ toolsAuthToken: 'warm-token' }), + }) + ); + expect(startArgentEventCollectionAsync).toHaveBeenCalledWith( + expect.objectContaining({ eventLogPath: '/warm/argent-events' }) + ); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + }); + it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => { const ctx = {} as unknown as CustomBuildContext; const logger = { info: jest.fn(), warn: jest.fn() }; diff --git a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts index b5fd5ee530..39a57fe512 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -12,9 +12,15 @@ import { uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, } from '../../utils/remoteDeviceRunSession'; +import { + createWarmDeviceSessionServer, + runOnWarmIosSimulatorAsync, +} from '../../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../../utils/warmIosSession'; import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession'; jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../utils/warmDeviceSessionHost'); jest.mock('../../../utils/IosSimulatorUtils', () => ({ IosSimulatorUtils: { resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'), @@ -89,6 +95,28 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { stopAsync.mockResolvedValue(undefined); }); + it('adopts the claimed warm Simulator when the warm step runs', async () => { + const warmServer = { port: 3200 } as never; + const warm = { device: { udid: 'SELECTED-IOS-UDID', name: 'iPhone 17' } } as WarmIosSession; + jest.mocked(createWarmDeviceSessionServer).mockReturnValue(warmServer); + jest.mocked(runOnWarmIosSimulatorAsync).mockImplementation((_claim, run) => run(warm)); + const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx, { warm: true }); + expect(buildFunction.id).toBe('start_warm_serve_sim_remote_session'); + await buildFunction.fn!( + { logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as never, + { inputs: { device_identifier: { value: 'iPhone 17' } }, outputs: {}, env } as never + ); + expect(runOnWarmIosSimulatorAsync).toHaveBeenCalledWith( + expect.objectContaining({ engine: 'serve-sim', requestedDevice: 'iPhone 17' }), + expect.any(Function) + ); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ iosSimulatorUdid: 'SELECTED-IOS-UDID' }), + warmServer + ); + }); + it('uses preview activity to reset the configured idle timeout', async () => { await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } }); const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0]; diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index 4f4bc31997..49d887ba9f 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -9,6 +9,8 @@ import path from 'node:path'; import { type CustomBuildContext } from '../../customBuildContext'; import { uploadRemoteSessionConfigWithLocalEgressAsync } from '../utils/localEgressSession'; import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { createWarmDeviceSessionServer } from '../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../utils/warmIosSession'; import { Sentry } from '../../sentry'; import { PackageManager, @@ -98,6 +100,7 @@ export async function runAgentDeviceRemoteSessionAsync( frameAncestors, corsOrigins, tasks, + warm, device, }: { env: BuildStepEnv; @@ -114,6 +117,8 @@ export async function runAgentDeviceRemoteSessionAsync( frameAncestors?: string[]; corsOrigins?: string[]; tasks: StartupTasks; + /** A claimed warm Simulator: adopt it and use its agent-device daemon. */ + warm?: WarmIosSession; device: | { booted: Promise; @@ -144,23 +149,56 @@ export async function runAgentDeviceRemoteSessionAsync( // Each task stores what it started, so the teardown below can stop it even when // another task failed first. + const sessionHostStartup = tasks.run('session host', async taskLogger => { + if (externallyBootedDevice) { + await tasks.untilAborted(externallyBootedDevice.booted); + } + tasks.signal.throwIfAborted(); + sessionHost = await startDeviceSessionHostAsync( + ctx, + { + separateLogPhase: true, + runtimePlatform, + env, + logger: taskLogger, + timeoutMs: STARTUP_TIMEOUT_MS, + preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, + disableShare, + privatePreviewUrl, + frameAncestors, + corsOrigins, + signal: tasks.signal, + onStartupError: tasks.abort, + networkCapture: capture.networkCapture, + networkCaptureFields: capture.networkCaptureFields, + iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid, + bootEnv: hostBootedDevice?.bootEnv, + application: hostBootedDevice?.application, + }, + ...(warm ? [createWarmDeviceSessionServer(ctx, taskLogger, warm)] : []) + ); + tasks.signal.throwIfAborted(); + const webPreview = await sessionHost.openPreviewAsync(); + tasks.signal.throwIfAborted(); + taskLogger.info( + webPreview.sharePageUrl + ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` + : 'Private preview URL unavailable for this session.' + ); + return webPreview; + }); const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => { - taskLogger.info('Launching agent-device daemon.'); - daemonProcess = await startAgentDeviceDaemonAsync({ - packageVersion, - env, - logger: taskLogger, - signal: tasks.signal, - secrets: daemonSecrets, - }); - - taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`); - // The daemon is stored above, so the teardown stops it. The wait itself is bounded - // (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background. - const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess })); - daemonSecrets.push(daemonInfo.token); - taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`); - + let daemonInfo: { port: number; token: string }; + if (warm) { + // Adoption prepares the launcher's daemon for the claimed device. + await tasks.untilAborted(sessionHostStartup); + if (!warm.tools) { + throw new Error('The warm agent-device daemon was not prepared.'); + } + daemonInfo = warm.tools; + } else { + daemonInfo = await startDaemonAsync(taskLogger); + } tasks.signal.throwIfAborted(); agentDeviceTunnel = await startNgrokTunnelAsync({ port: daemonInfo.port, @@ -174,40 +212,24 @@ export async function runAgentDeviceRemoteSessionAsync( taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`); return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url }; }); - const sessionHostStartup = tasks.run('session host', async taskLogger => { - if (externallyBootedDevice) { - await tasks.untilAborted(externallyBootedDevice.booted); - } - tasks.signal.throwIfAborted(); - sessionHost = await startDeviceSessionHostAsync(ctx, { - separateLogPhase: true, - runtimePlatform, + async function startDaemonAsync(taskLogger: bunyan) { + taskLogger.info('Launching agent-device daemon.'); + daemonProcess = await startAgentDeviceDaemonAsync({ + packageVersion, env, logger: taskLogger, - timeoutMs: STARTUP_TIMEOUT_MS, - preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, - disableShare, - privatePreviewUrl, - frameAncestors, - corsOrigins, signal: tasks.signal, - onStartupError: tasks.abort, - networkCapture: capture.networkCapture, - networkCaptureFields: capture.networkCaptureFields, - iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid, - bootEnv: hostBootedDevice?.bootEnv, - application: hostBootedDevice?.application, + secrets: daemonSecrets, }); - tasks.signal.throwIfAborted(); - const webPreview = await sessionHost.openPreviewAsync(); - tasks.signal.throwIfAborted(); - taskLogger.info( - webPreview.sharePageUrl - ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` - : 'Private preview URL unavailable for this session.' - ); - return webPreview; - }); + + taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`); + // The daemon is stored above, so the teardown stops it. The wait itself is bounded + // (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background. + const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess })); + daemonSecrets.push(daemonInfo.token); + taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`); + return daemonInfo; + } const deviceReady = externallyBootedDevice?.ready; try { @@ -252,7 +274,7 @@ export async function runAgentDeviceRemoteSessionAsync( eventCollection = await startAgentDeviceEventCollectionAsync({ ctx, deviceRunSessionId, - stateDir: AGENT_DEVICE_STATE_DIR, + stateDir: warm?.agentDeviceStateDir ?? AGENT_DEVICE_STATE_DIR, logger, }); @@ -287,7 +309,7 @@ export async function runAgentDeviceRemoteSessionAsync( ['agent-device tunnel', agentDeviceTunnel?.stopAsync()], [ 'agent-device daemon', - startedDaemon && + (startedDaemon || eventCollection) && (async () => { try { if (eventCollection) { @@ -298,7 +320,7 @@ export async function runAgentDeviceRemoteSessionAsync( }); } } finally { - await startedDaemon.stopAsync(); + await startedDaemon?.stopAsync(); } })(), ], diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index 472efce41a..01bd30865f 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -16,26 +16,29 @@ import { AndroidVirtualDeviceName, } from '../../utils/AndroidEmulatorUtils'; import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi'; +import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact'; +import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; +import { runOnWarmIosSimulatorAsync } from '../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../utils/warmIosSession'; import { withLocalEgressSession } from '../utils/localEgressSession'; import { + type ServeSimApplicationOptions, createCorsOriginsInputProvider, createFrameAncestorsInputProvider, createPrivatePreviewUrlInputProvider, createPublicPreviewInputProvider, + createServeSimLaunchInputProviders, omitNgrokAuthtoken, parseCorsOriginsInput, parseFrameAncestorsInput, selectXcodeDeveloperDirectoryAsync, + validateServeSimLaunchOptions, } from '../utils/remoteDeviceRunSession'; import { createNetworkCaptureInputProviders, parseNetworkCaptureInputs, } from '../utils/networkCaptureFields'; import { createStartupTasks } from '../utils/startupTasks'; -import { - type ServeSimApplicationOptions, - validateServeSimLaunchOptions, -} from '../utils/remoteDeviceRunSession'; import { downloadBuildAsync } from './downloadBuild'; import { installBuildAsync } from './installBuild'; @@ -49,22 +52,23 @@ import { runAgentDeviceRemoteSessionAsync, } from './startAgentDeviceRemoteSession'; import { startAndroidEmulatorAsync } from './startAndroidEmulator'; -import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; -import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact'; const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName; /** - * Starts a device session, downloading the app alongside daemon startup. + * Starts a device session using an optional local app artifact, or one it downloads. * On iOS, serve-sim boots the selected Simulator, then installs and launches - * the downloaded app. Build-tools verifies any egress guard before install and launch. + * the app. Build-tools verifies any egress guard before install and launch. */ -export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction { +export function createStartAgentDeviceSessionBuildFunction( + ctx: CustomBuildContext, + { warm = false }: { warm?: boolean } = {} +): BuildFunction { return new BuildFunction({ namespace: 'eas', - id: 'start_agent_device_session', + id: warm ? 'start_warm_agent_device_session' : 'start_agent_device_session', name: 'Start agent-device session', - __metricsId: 'eas/start_agent_device_session', + __metricsId: warm ? 'eas/start_warm_agent_device_session' : 'eas/start_agent_device_session', inputProviders: [ // Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile. BuildStepInput.createProvider({ @@ -93,27 +97,24 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte required: false, allowedValueTypeName: BuildStepInputValueTypeName.NUMBER, }), - // Application. Pass at most one of build_id and application_archive_url. + // Application. Pass at most one of install_app_path, build_id and application_archive_url. BuildStepInput.createProvider({ - id: 'build_id', + id: 'install_app_path', required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), + // Deprecated: remove once workflows pass install_app_path. BuildStepInput.createProvider({ - id: 'application_archive_url', + id: 'build_id', required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), BuildStepInput.createProvider({ - id: 'launch_args', - required: false, - allowedValueTypeName: BuildStepInputValueTypeName.JSON, - }), - BuildStepInput.createProvider({ - id: 'open_url', + id: 'application_archive_url', required: false, allowedValueTypeName: BuildStepInputValueTypeName.STRING, }), + ...createServeSimLaunchInputProviders(), // Session. ...createNetworkCaptureInputProviders(), createPublicPreviewInputProvider(), @@ -144,22 +145,41 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte // Validate everything before any expensive work starts. const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(stepEnv); const env = omitNgrokAuthtoken(stepEnv); + const installAppPath = inputs.install_app_path.value as string | undefined; const buildId = inputs.build_id.value as string | undefined; const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined; - if (buildId && applicationArchiveUrl) { + const launchAppIdentifier = inputs.launch_app_identifier.value as string | undefined; + const launchArgs = parseLaunchArgsInput(inputs.launch_args.value); + const openUrl = + inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value); + const download = buildId || applicationArchiveUrl; + if ([installAppPath, buildId, applicationArchiveUrl].filter(Boolean).length > 1) { throw new UserError( 'EAS_DOWNLOAD_BUILD_INVALID_SOURCE', - 'Pass only one of build_id or application_archive_url.' + 'Pass only one of install_app_path, build_id or application_archive_url.' ); } - const hasApplication = Boolean(buildId || applicationArchiveUrl); - const launchArgs = parseLaunchArgsInput(inputs.launch_args.value); - const openUrl = - inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value); - if (!hasApplication && (launchArgs.length > 0 || openUrl)) { + if (download && launchAppIdentifier !== undefined) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'launch_app_identifier cannot be combined with build_id or application_archive_url.' + ); + } + if ( + !installAppPath && + !download && + !launchAppIdentifier && + (launchArgs.length > 0 || openUrl) + ) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'launch_args and open_url need an application: pass install_app_path or launch_app_identifier.' + ); + } + if (!isIos && launchAppIdentifier !== undefined) { throw new UserError( 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', - 'launch_args and open_url need an application: pass build_id or application_archive_url.' + 'launch_app_identifier is only supported for iOS Simulator sessions. Pass install_app_path for Android.' ); } if (isIos) { @@ -176,87 +196,111 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte { runtimePlatform } ); - const tasks = createStartupTasks(logger, signal); - let downloaded: Promise | undefined; - let applicationReady: Promise | undefined; + const startSessionAsync = async (warmSession?: WarmIosSession): Promise => { + const tasks = createStartupTasks(logger, signal); + let downloaded: Promise | undefined; + let applicationReady: Promise | undefined; - try { - if (isIos) { - // Select Xcode before serve-sim boots the device or any Simulator tools run. - await selectXcodeDeveloperDirectoryAsync({ env, logger }); - } - tasks.signal.throwIfAborted(); - const { iosStartup } = isIos - ? await resolveIosSessionStartupAsync({ - runtimePlatform, - bootSimulator: true, - deviceIdentifier, - env, - logger, - signal: tasks.signal, - }) - : {}; - const iosSimulatorUdid = iosStartup?.iosSimulatorUdid; - if (iosSimulatorUdid) { - logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`); - } + try { + if (isIos) { + // Select Xcode before serve-sim boots the device or any Simulator tools run. + await selectXcodeDeveloperDirectoryAsync({ env, logger }); + } + tasks.signal.throwIfAborted(); + const { launch, iosStartup } = isIos + ? await resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator: true, + deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier, + installAppPath, + launchAppIdentifier, + // A downloaded app gets its launch options after the download. + launchArgs: download ? [] : launchArgs, + openUrl: download ? undefined : openUrl, + env, + logger, + signal: tasks.signal, + }) + : {}; + const iosSimulatorUdid = iosStartup?.iosSimulatorUdid; + if (iosSimulatorUdid) { + logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`); + } + + const booted = iosSimulatorUdid + ? Promise.resolve() + : tasks.run('Android Emulator boot', async taskLogger => { + const logcatDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'eas-android-emulator-logcat-') + ); + tasks.signal.throwIfAborted(); + await startAndroidEmulatorAsync({ + deviceName: ANDROID_DEVICE_NAME, + systemImagePackage: `${inputs.system_image_package.value}`, + deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined, + lcdWidth: inputs.lcd_width.value as number | undefined, + lcdHeight: inputs.lcd_height.value as number | undefined, + lcdDensity: inputs.lcd_density.value as number | undefined, + logcatDirectory, + env, + logger: taskLogger, + }); + }); + + downloaded = download + ? tasks.run('build download', async taskLogger => { + const source = buildId + ? { buildId } + : applicationArchiveUrl + ? { applicationArchiveUrl } + : undefined; + if (!source) { + throw new Error('No app source was provided for download.'); + } + const { artifactPath } = await downloadBuildAsync({ + logger: taskLogger, + ...source, + graphqlClient: ctx.graphqlClient, + deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null, + robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null, + extensions: isIos ? ['app'] : ['apk'], + signal: tasks.signal, + }); + return artifactPath; + }) + : undefined; + const downloadedApp = downloaded; - const booted = iosSimulatorUdid - ? undefined - : tasks.run('Android Emulator boot', async taskLogger => { - const logcatDirectory = await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'eas-android-emulator-logcat-') - ); + if (isIos && downloadedApp) { + applicationReady = tasks.run('app preparation', async () => { + const artifactPath = await downloadedApp; tasks.signal.throwIfAborted(); - await startAndroidEmulatorAsync({ - deviceName: ANDROID_DEVICE_NAME, - systemImagePackage: `${inputs.system_image_package.value}`, - deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined, - lcdWidth: inputs.lcd_width.value as number | undefined, - lcdHeight: inputs.lcd_height.value as number | undefined, - lcdDensity: inputs.lcd_density.value as number | undefined, - logcatDirectory, + const applicationIdentifier = await readIosApplicationIdentifierAsync({ + artifactPath, env, - logger: taskLogger, }); + tasks.signal.throwIfAborted(); + return { + installAppPath: artifactPath, + launchAppIdentifier: applicationIdentifier, + launchArgs, + openUrl, + }; }); - - downloaded = hasApplication - ? tasks.run('build download', async taskLogger => { - const { artifactPath } = await downloadBuildAsync({ - logger: taskLogger, - ...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }), - graphqlClient: ctx.graphqlClient, - deviceRunSessionWorkerApi: buildId ? getDeviceRunSessionWorkerApi(ctx) : null, - robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null, - extensions: isIos ? ['app'] : ['apk'], - signal: tasks.signal, - }); - return artifactPath; - }) - : undefined; - - const download = downloaded; - applicationReady = download - ? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => { - if (booted) { - await tasks.untilAborted(booted); + } else if (isIos && launch?.launchAppIdentifier) { + applicationReady = Promise.resolve({ + ...launch, + launchAppIdentifier: launch.launchAppIdentifier, + installAppPath, + }); + } else if (!isIos && (installAppPath || downloadedApp)) { + applicationReady = tasks.run('app install and launch', async taskLogger => { + await tasks.untilAborted(booted); + const artifactPath = installAppPath ?? (await downloadedApp); + if (!artifactPath) { + throw new Error('No app artifact was prepared.'); } - const artifactPath = await download; tasks.signal.throwIfAborted(); - if (isIos) { - const applicationIdentifier = await readIosApplicationIdentifierAsync({ - artifactPath, - env, - }); - tasks.signal.throwIfAborted(); - return { - installAppPath: artifactPath, - launchAppIdentifier: applicationIdentifier, - launchArgs, - openUrl, - }; - } const { applicationIdentifier, activityName } = await installBuildAsync({ artifactPath, runtimePlatform, @@ -273,39 +317,57 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte env, logger: taskLogger, }); - }) - : undefined; + }); + } - await runAgentDeviceRemoteSessionAsync(ctx, { - env, - logger, - signal, - runtimePlatform, - sessionEnv, - packageVersion: inputs.package_version.value as string | undefined, - // A missing or non-positive value disables the idle timeout (opt-in feature). - maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined, - maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined, - capture, - disableShare: inputs.public_preview?.value === false, - privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, - frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), - corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), - tasks, - device: iosSimulatorUdid - ? { - ...iosStartup, - iosSimulatorUdid, - application: applicationReady, - } - : { - booted: booted!, - ready: applicationReady ?? tasks.untilAborted(booted!), - }, - }); - } finally { - tasks.abort(new Error('Agent-device session ended.')); - await Promise.allSettled([downloaded, applicationReady]); + await runAgentDeviceRemoteSessionAsync(ctx, { + env, + logger, + signal, + runtimePlatform, + sessionEnv, + packageVersion: inputs.package_version.value as string | undefined, + // A missing or non-positive value disables the idle timeout (opt-in feature). + maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined, + maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined, + capture, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, + frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), + corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), + tasks, + warm: warmSession, + device: iosSimulatorUdid + ? { + iosSimulatorUdid, + bootEnv: iosStartup?.bootEnv, + application: applicationReady, + } + : { + booted, + ready: applicationReady ?? tasks.untilAborted(booted), + }, + }); + } finally { + tasks.abort(new Error('Agent-device session ended.')); + await Promise.allSettled([downloaded, applicationReady]); + } + }; + // The warm path installs only a local app; build_id and application_archive_url start cold. + if (warm && isIos && !download) { + await runOnWarmIosSimulatorAsync( + { + engine: 'agent-device', + requestedDevice: deviceIdentifier, + packageVersion: inputs.package_version.value as string | undefined, + env, + logger, + signal, + }, + startSessionAsync + ); + } else { + await startSessionAsync(); } }), }); diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index c815c1789f..62737139ef 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -20,6 +20,11 @@ import { withLocalEgressSession, } from '../utils/localEgressSession'; import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { + createWarmDeviceSessionServer, + runOnWarmIosSimulatorAsync, +} from '../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../utils/warmIosSession'; import { validateControllerVersion } from '../utils/controllerVersion'; import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { @@ -84,13 +89,14 @@ const ArgentToolServerStateSchema = z.object({ type ArgentToolServerState = z.infer; export function createStartArgentRemoteSessionBuildFunction( - ctx: CustomBuildContext + ctx: CustomBuildContext, + { warm = false }: { warm?: boolean } = {} ): BuildFunction { return new BuildFunction({ namespace: 'eas', - id: 'start_argent_remote_session', + id: warm ? 'start_warm_argent_remote_session' : 'start_argent_remote_session', name: 'Start argent remote session', - __metricsId: 'eas/start_argent_remote_session', + __metricsId: warm ? 'eas/start_warm_argent_remote_session' : 'eas/start_argent_remote_session', inputProviders: [ ...createServeSimLaunchInputProviders(), ...createIosSessionStartupInputProviders(), @@ -140,306 +146,351 @@ export function createStartArgentRemoteSessionBuildFunction( if (runtimePlatform === BuildRuntimePlatform.DARWIN) { await selectXcodeDeveloperDirectoryAsync({ env, logger }); } - const { launch, iosStartup } = await resolveIosSessionStartupAsync({ - runtimePlatform, - bootSimulator: inputs.boot_simulator?.value as boolean | undefined, - deviceIdentifier: inputs.device_identifier?.value as string | undefined, - installAppPath: inputs.install_app_path?.value as string | undefined, - launchAppIdentifier: inputs.launch_app_identifier?.value, - launchArgs: inputs.launch_args?.value, - openUrl: inputs.open_url?.value, - env, - logger, - signal, - }); - const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( - { - networkCapture: inputs.network_capture?.value, - networkCaptureFields: inputs.network_capture_fields?.value, - }, - { runtimePlatform } - ); - logger.info( - `Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` - ); - - // Start the potentially slow installation while Argent is being prepared. - // On Linux expo-device-hub calls this again and awaits the same in-flight - // setup before launching. On macOS this remains non-blocking, so only a - // recording started in the first moments may miss ffmpeg. - // Never rejects, so `void` is safe. - void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }); - - const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN); - const argentExec = (args: string[]): { command: string; args: string[] } => - resolvePackageExec(packageManager, args); - - const startupAbortController = new AbortController(); - const startupSignal = signal - ? AbortSignal.any([signal, startupAbortController.signal]) - : startupAbortController.signal; - const setupSignal = iosStartup ? startupSignal : undefined; - const artifactPollAbortController = new AbortController(); - let artifactPollingPromise: Promise | undefined; - let eventCollection: Awaited> | undefined; - let toolsTunnel: Awaited> | undefined; - let argentServer: ReturnType | undefined; - let hostStartup: Promise> | undefined; - let sessionHost: DeviceSessionHost | undefined; - let sessionFailed = false; - const startHost = () => - startDeviceSessionHostAsync(ctx, { - ...iosStartup, - separateLogPhase: true, + const deviceIdentifier = inputs.device_identifier?.value as string | undefined; + const startSessionAsync = async (warmSession?: WarmIosSession): Promise => { + const { launch, iosStartup } = await resolveIosSessionStartupAsync({ runtimePlatform, + bootSimulator: inputs.boot_simulator?.value as boolean | undefined, + deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier, + installAppPath: inputs.install_app_path?.value as string | undefined, + launchAppIdentifier: inputs.launch_app_identifier?.value, + launchArgs: inputs.launch_args?.value, + openUrl: inputs.open_url?.value, env, logger, - disableShare: inputs.public_preview?.value === false, - privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, - frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), - corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), - timeoutMs: STARTUP_TIMEOUT_MS, - preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, - signal: startupSignal, - onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, - ...launch, - networkCapture, - networkCaptureFields, - }).catch(error => { - if (iosStartup) { - startupAbortController.abort(error); - } - throw error; + signal, }); - try { - if (iosStartup) { - hostStartup = asyncResult(startHost()); - } - logger.info('Enabling the Argent artifacts list endpoint flag.'); - const enableArtifacts = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'enable', - ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG, - ]); - await runArgentSetupCommandAsync( - enableArtifacts.command, - enableArtifacts.args, - { env, logger }, - setupSignal + const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( + { + networkCapture: inputs.network_capture?.value, + networkCaptureFields: inputs.network_capture_fields?.value, + }, + { runtimePlatform } ); - - logger.info('Enabling the Argent tool-server event log flag.'); - const enableEventLog = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'enable', - ARGENT_EVENT_LOG_FLAG, - ]); - await runArgentSetupCommandAsync( - enableEventLog.command, - enableEventLog.args, - { env, logger }, - setupSignal + logger.info( + `Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` ); - logger.info('Disabling the Argent video watermark flag.'); - const disableVideoWatermark = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'disable', - ARGENT_VIDEO_WATERMARK_FLAG, - ]); - // The watermark is cosmetic, so a failure here must not fail the session. - try { - await runArgentSetupCommandAsync( - disableVideoWatermark.command, - disableVideoWatermark.args, - { env, logger }, - setupSignal - ); - } catch (err) { - setupSignal?.throwIfAborted(); - logger.warn( - { err }, - 'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.' - ); - } + // Start the potentially slow installation while Argent is being prepared. + // On Linux expo-device-hub calls this again and awaits the same in-flight + // setup before launching. On macOS this remains non-blocking, so only a + // recording started in the first moments may miss ffmpeg. + // Never rejects, so `void` is safe. + void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }); - setupSignal?.throwIfAborted(); - const startServer = argentExec([ - `${ARGENT_PACKAGE_NAME}@${versionSpec}`, - 'server', - 'start', - '--port', - '0', - '--idle-timeout', - '0', - '--force', - ]); - logger.info( - `Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.` - ); - // Keep Argent itself in foreground mode under the detached process. This preserves - // the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file. - const secrets: string[] = []; - argentServer = spawnDetached({ - command: startServer.command, - args: startServer.args, - env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' }, - logger, - secrets, - }); - if (argentServer.pid === undefined) { - throw new SystemError( - 'Failed to start Argent: could not determine the PID of the launched process.' - ); - } + const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN); + const argentExec = (args: string[]): { command: string; args: string[] } => + resolvePackageExec(packageManager, args); - logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`); - let toolServerPort: number; - let toolServerToken: string | undefined; - try { - const toolServerState = await waitForArgentToolServerStateAsync({ - stateDir: ARGENT_STATE_DIR, - ancestorPid: argentServer.pid, - timeoutMs: STARTUP_TIMEOUT_MS, - getExitError: argentServer.getExitError, - signal: setupSignal, + const startupAbortController = new AbortController(); + const startupSignal = signal + ? AbortSignal.any([signal, startupAbortController.signal]) + : startupAbortController.signal; + const setupSignal = iosStartup ? startupSignal : undefined; + const artifactPollAbortController = new AbortController(); + let artifactPollingPromise: Promise | undefined; + let eventCollection: + | Awaited> + | undefined; + let toolsTunnel: Awaited> | undefined; + let argentServer: ReturnType | undefined; + let hostStartup: Promise> | undefined; + let sessionHost: DeviceSessionHost | undefined; + let sessionFailed = false; + const startHost = () => + startDeviceSessionHostAsync( + ctx, + { + ...iosStartup, + separateLogPhase: true, + runtimePlatform, + env, + logger, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, + frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), + corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), + timeoutMs: STARTUP_TIMEOUT_MS, + preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, + signal: startupSignal, + onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, + ...launch, + networkCapture, + networkCaptureFields, + }, + ...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : []) + ).catch(error => { + if (iosStartup) { + startupAbortController.abort(error); + } + throw error; }); - toolServerPort = toolServerState.port; - toolServerToken = toolServerState.token; - if (toolServerToken) { - secrets.push(toolServerToken); + try { + if (iosStartup) { + hostStartup = asyncResult(startHost()); } - } catch (err) { - const output = argentServer.getOutput(); - throw new SystemError( - `${ - err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.` - }${output ? `\nArgent tool-server output:\n${output}` : ''}` - ); - } - if (hostStartup) { - sessionHost = (await hostStartup).enforceValue(); - } - setupSignal?.throwIfAborted(); - logger.info(`Argent tool-server is listening on port ${toolServerPort}.`); - const artifactPollSignal = signal - ? AbortSignal.any([signal, artifactPollAbortController.signal]) - : artifactPollAbortController.signal; - artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, { - deviceRunSessionId, - toolsUrl: `http://127.0.0.1:${toolServerPort}`, - toolsAuthToken: toolServerToken, - logger, - signal: artifactPollSignal, - }); + let toolServerPort: number; + let toolServerToken: string | undefined; + if (warmSession) { + // Adoption prepares the launcher's Argent server for the claimed device. + sessionHost = (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); + const tools = warmSession.tools; + if (!tools) { + throw new Error('The warm Argent server was not prepared.'); + } + toolServerPort = tools.port; + toolServerToken = tools.token; + } else { + logger.info('Enabling the Argent artifacts list endpoint flag.'); + const enableArtifacts = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'enable', + ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG, + ]); + await runArgentSetupCommandAsync( + enableArtifacts.command, + enableArtifacts.args, + { env, logger }, + setupSignal + ); - eventCollection = await startArgentEventCollectionAsync({ - ctx, - deviceRunSessionId, - eventLogPath: ARGENT_EVENT_LOG_PATH, - logger, - }); + logger.info('Enabling the Argent tool-server event log flag.'); + const enableEventLog = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'enable', + ARGENT_EVENT_LOG_FLAG, + ]); + await runArgentSetupCommandAsync( + enableEventLog.command, + enableEventLog.args, + { env, logger }, + setupSignal + ); - toolsTunnel = await startNgrokTunnelAsync({ - port: toolServerPort, - subdomainPrefix: 'argent', - baseDomain: ngrokTunnelDomain, - authtoken: ngrokAuthtoken, - rewriteHostHeader: true, - healthCheck: { path: '/tools', statuses: [200, 401] }, - logger, - }); - const publicToolsUrl = toolsTunnel.url; - logger.info(`Tunnel is ready at ${publicToolsUrl}.`); + logger.info('Disabling the Argent video watermark flag.'); + const disableVideoWatermark = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'disable', + ARGENT_VIDEO_WATERMARK_FLAG, + ]); + // The watermark is cosmetic, so a failure here must not fail the session. + try { + await runArgentSetupCommandAsync( + disableVideoWatermark.command, + disableVideoWatermark.args, + { env, logger }, + setupSignal + ); + } catch (err) { + setupSignal?.throwIfAborted(); + logger.warn( + { err }, + 'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.' + ); + } - const launchDescription = describeServeSimLaunch(launch); - if (launchDescription) { - logger.info(launchDescription); - } - sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); - const webPreview = await sessionHost.openPreviewAsync(); - logger.info( - webPreview.sharePageUrl - ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` - : 'Private preview URL unavailable for this session.' - ); + setupSignal?.throwIfAborted(); + const startServer = argentExec([ + `${ARGENT_PACKAGE_NAME}@${versionSpec}`, + 'server', + 'start', + '--port', + '0', + '--idle-timeout', + '0', + '--force', + ]); + logger.info( + `Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.` + ); + // Keep Argent itself in foreground mode under the detached process. This preserves + // the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file. + const secrets: string[] = []; + argentServer = spawnDetached({ + command: startServer.command, + args: startServer.args, + env: { + ...env, + ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, + ARGENT_EMULATOR_NO_WINDOW: '1', + }, + logger, + secrets, + }); + if (argentServer.pid === undefined) { + throw new SystemError( + 'Failed to start Argent: could not determine the PID of the launched process.' + ); + } - const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ - env, - signal, - ctx, - deviceRunSessionId, - remoteConfig: { - toolsUrl: publicToolsUrl, - ...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}), - webPreviewUrl: webPreview.previewPageUrl, - previewApiUrl: webPreview.apiUrl, - ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), - }, - logger, - }); - if (sessionStart === 'stopped') { - return; - } + logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`); + try { + const toolServerState = await waitForArgentToolServerStateAsync({ + stateDir: ARGENT_STATE_DIR, + ancestorPid: argentServer.pid, + timeoutMs: STARTUP_TIMEOUT_MS, + getExitError: argentServer.getExitError, + signal: setupSignal, + }); + toolServerPort = toolServerState.port; + toolServerToken = toolServerState.token; + if (toolServerToken) { + secrets.push(toolServerToken); + } + } catch (err) { + const output = argentServer.getOutput(); + throw new SystemError( + `${ + err instanceof Error + ? err.message + : `Timed out waiting for argent tool-server state.` + }${output ? `\nArgent tool-server output:\n${output}` : ''}` + ); + } + if (hostStartup) { + sessionHost = (await hostStartup).enforceValue(); + } + setupSignal?.throwIfAborted(); + } + logger.info(`Argent tool-server is listening on port ${toolServerPort}.`); + const artifactPollSignal = signal + ? AbortSignal.any([signal, artifactPollAbortController.signal]) + : artifactPollAbortController.signal; + artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, { + deviceRunSessionId, + toolsUrl: `http://127.0.0.1:${toolServerPort}`, + toolsAuthToken: toolServerToken, + logger, + signal: artifactPollSignal, + }); - await waitForDeviceRunSessionStoppedAsync({ - ctx, - deviceRunSessionId, - logger, - maxDurationSeconds, - signal, - idleTimeout: - maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0 - ? { - maxIdleTimeMinutes, - getLastEventObservedAt: eventCollection.getLastEventObservedAt, - getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(), - } - : undefined, - }); - } catch (error) { - sessionFailed = true; - throw setupSignal?.aborted ? setupSignal.reason : error; - } finally { - startupAbortController.abort(new Error('Argent session ended.')); - await finishRemoteSessionAsync({ - logger, - sessionFailed, - teardown: [ - ['Argent tunnel', toolsTunnel?.stopAsync()], - [ - 'Argent tool-server', - (async () => { - try { - if (eventCollection) { - await stopArgentEventCollectionSafelyAsync({ - eventCollection, - deviceRunSessionId, - logger, - }); + eventCollection = await startArgentEventCollectionAsync({ + ctx, + deviceRunSessionId, + eventLogPath: warmSession?.argentEventLogPath ?? ARGENT_EVENT_LOG_PATH, + logger, + }); + + toolsTunnel = await startNgrokTunnelAsync({ + port: toolServerPort, + subdomainPrefix: 'argent', + baseDomain: ngrokTunnelDomain, + authtoken: ngrokAuthtoken, + rewriteHostHeader: true, + healthCheck: { path: '/tools', statuses: [200, 401] }, + logger, + }); + const publicToolsUrl = toolsTunnel.url; + logger.info(`Tunnel is ready at ${publicToolsUrl}.`); + + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } + sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue(); + const webPreview = await sessionHost.openPreviewAsync(); + logger.info( + webPreview.sharePageUrl + ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` + : 'Private preview URL unavailable for this session.' + ); + + const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ + env, + signal, + ctx, + deviceRunSessionId, + remoteConfig: { + toolsUrl: publicToolsUrl, + ...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}), + webPreviewUrl: webPreview.previewPageUrl, + previewApiUrl: webPreview.apiUrl, + ...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}), + }, + logger, + }); + if (sessionStart === 'stopped') { + return; + } + + await waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId, + logger, + maxDurationSeconds, + signal, + idleTimeout: + maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0 + ? { + maxIdleTimeMinutes, + getLastEventObservedAt: eventCollection.getLastEventObservedAt, + getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(), } - artifactPollAbortController.abort(); - try { - await artifactPollingPromise; - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - Sentry.capture( - 'Could not finish Argent remote session artifact polling', - error - ); - logger.warn( - { err: error }, - 'Could not finish Argent remote session artifact polling.' - ); + : undefined, + }); + } catch (error) { + sessionFailed = true; + throw setupSignal?.aborted ? setupSignal.reason : error; + } finally { + startupAbortController.abort(new Error('Argent session ended.')); + const toolTeardown = (async () => { + try { + if (eventCollection) { + await stopArgentEventCollectionSafelyAsync({ + eventCollection, + deviceRunSessionId, + logger, + }); + } + artifactPollAbortController.abort(); + try { + await artifactPollingPromise; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + Sentry.capture('Could not finish Argent remote session artifact polling', error); + logger.warn( + { err: error }, + 'Could not finish Argent remote session artifact polling.' + ); + } + } finally { + await argentServer?.stopAsync(); + } + })(); + await finishRemoteSessionAsync({ + logger, + sessionFailed, + teardown: [ + ['Argent tunnel', toolsTunnel?.stopAsync()], + ['Argent tool-server', toolTeardown], + [ + 'session host', + hostStartup?.then(async result => { + if (warmSession) { + // The warm host owns Argent too; leave it alive until the final downloads finish. + await Promise.allSettled([toolTeardown]); } - } finally { - await argentServer?.stopAsync(); - } - })(), + await result.value?.finishAsync(); + }), + ], ], - ['session host', hostStartup?.then(result => result.value?.finishAsync())], - ], - }); + }); + } + }; + if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) { + await runOnWarmIosSimulatorAsync( + { + engine: 'argent', + requestedDevice: deviceIdentifier, + packageVersion, + env, + logger, + signal, + }, + startSessionAsync + ); + } else { + await startSessionAsync(); } }), }); diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index 8af92ba244..9cdfda9703 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -8,6 +8,11 @@ import { import { CustomBuildContext } from '../../customBuildContext'; import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { + createWarmDeviceSessionServer, + runOnWarmIosSimulatorAsync, +} from '../utils/warmDeviceSessionHost'; +import { type WarmIosSession } from '../utils/warmIosSession'; import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { createNetworkCaptureInputProviders, @@ -38,13 +43,16 @@ import { const STARTUP_TIMEOUT_MS = 60_000; export function createStartWebPreviewRemoteSessionBuildFunction( - ctx: CustomBuildContext + ctx: CustomBuildContext, + { warm = false }: { warm?: boolean } = {} ): BuildFunction { return new BuildFunction({ namespace: 'eas', - id: 'start_serve_sim_remote_session', + id: warm ? 'start_warm_serve_sim_remote_session' : 'start_serve_sim_remote_session', name: 'Start web preview remote session', - __metricsId: 'eas/start_serve_sim_remote_session', + __metricsId: warm + ? 'eas/start_warm_serve_sim_remote_session' + : 'eas/start_serve_sim_remote_session', inputProviders: [ ...createServeSimLaunchInputProviders(), ...createIosSessionStartupInputProviders(), @@ -91,98 +99,123 @@ export function createStartWebPreviewRemoteSessionBuildFunction( if (runtimePlatform === BuildRuntimePlatform.DARWIN) { await selectXcodeDeveloperDirectoryAsync({ env, logger }); } - const { launch, iosStartup } = await resolveIosSessionStartupAsync({ - runtimePlatform, - bootSimulator: inputs.boot_simulator?.value as boolean | undefined, - deviceIdentifier: inputs.device_identifier?.value as string | undefined, - installAppPath: inputs.install_app_path?.value as string | undefined, - launchAppIdentifier: inputs.launch_app_identifier?.value, - launchArgs: inputs.launch_args?.value, - openUrl: inputs.open_url?.value, - env, - logger, - signal, - }); - const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( - { - networkCapture: inputs.network_capture?.value, - networkCaptureFields: inputs.network_capture_fields?.value, - }, - { runtimePlatform } - ); - - logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`); - const launchDescription = describeServeSimLaunch(launch); - if (launchDescription) { - logger.info(launchDescription); - } - - const sessionHost = await startDeviceSessionHostAsync(ctx, { - ...iosStartup, - runtimePlatform, - env, - logger, - timeoutMs: STARTUP_TIMEOUT_MS, - preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, - signal, - packageVersion, - disableShare: inputs.public_preview?.value === false, - privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, - frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), - corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), - launchAppIdentifier: launch.launchAppIdentifier, - launchArgs: launch.launchArgs, - openUrl: launch.openUrl, - networkCapture, - networkCaptureFields, - }); - - try { - const webPreview = await sessionHost.openPreviewAsync(); - logger.info( - webPreview.sharePageUrl - ? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` - : 'Private preview URL unavailable for this session.' - ); - const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ + const deviceIdentifier = inputs.device_identifier?.value as string | undefined; + const startSessionAsync = async (warmSession?: WarmIosSession): Promise => { + const { launch, iosStartup } = await resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator: inputs.boot_simulator?.value as boolean | undefined, + deviceIdentifier: warmSession?.device.udid ?? deviceIdentifier, + installAppPath: inputs.install_app_path?.value as string | undefined, + launchAppIdentifier: inputs.launch_app_identifier?.value, + launchArgs: inputs.launch_args?.value, + openUrl: inputs.open_url?.value, env, - signal, - ctx, - deviceRunSessionId, - // WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM - // sessions read previewUrl and previewToken. The server drops the keys that the - // session type does not use. - remoteConfig: { - webPreviewUrl: webPreview.previewPageUrl, - previewUrl: webPreview.previewPageUrl, - previewApiUrl: webPreview.apiUrl, - ...(webPreview.previewToken - ? { webPreviewToken: webPreview.previewToken, previewToken: webPreview.previewToken } - : {}), - }, logger, + signal, }); - if (sessionStart === 'stopped') { - return; + const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( + { + networkCapture: inputs.network_capture?.value, + networkCaptureFields: inputs.network_capture_fields?.value, + }, + { runtimePlatform } + ); + + logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`); + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); } - await waitForDeviceRunSessionStoppedAsync({ + const sessionHost = await startDeviceSessionHostAsync( ctx, - deviceRunSessionId, - logger, - maxDurationSeconds, - signal, - idleTimeout: - maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0 - ? { - maxIdleTimeMinutes, - getLastEventObservedAt: () => undefined, - getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(), - } - : undefined, - }); - } finally { - await sessionHost.finishAsync(); + { + ...iosStartup, + runtimePlatform, + env, + logger, + timeoutMs: STARTUP_TIMEOUT_MS, + preview: { baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken }, + signal, + packageVersion, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, + frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), + corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), + launchAppIdentifier: launch.launchAppIdentifier, + launchArgs: launch.launchArgs, + openUrl: launch.openUrl, + networkCapture, + networkCaptureFields, + }, + ...(warmSession ? [createWarmDeviceSessionServer(ctx, logger, warmSession)] : []) + ); + + try { + const webPreview = await sessionHost.openPreviewAsync(); + logger.info( + webPreview.sharePageUrl + ? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` + : 'Private preview URL unavailable for this session.' + ); + const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({ + env, + signal, + ctx, + deviceRunSessionId, + // WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM + // sessions read previewUrl and previewToken. The server drops the keys that the + // session type does not use. + remoteConfig: { + webPreviewUrl: webPreview.previewPageUrl, + previewUrl: webPreview.previewPageUrl, + previewApiUrl: webPreview.apiUrl, + ...(webPreview.previewToken + ? { + webPreviewToken: webPreview.previewToken, + previewToken: webPreview.previewToken, + } + : {}), + }, + logger, + }); + if (sessionStart === 'stopped') { + return; + } + + await waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId, + logger, + maxDurationSeconds, + signal, + idleTimeout: + maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0 + ? { + maxIdleTimeMinutes, + getLastEventObservedAt: () => undefined, + getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(), + } + : undefined, + }); + } finally { + await sessionHost.finishAsync(); + } + }; + if (warm && runtimePlatform === BuildRuntimePlatform.DARWIN) { + await runOnWarmIosSimulatorAsync( + { + engine: 'serve-sim', + requestedDevice: deviceIdentifier, + packageVersion, + env, + logger, + signal, + }, + startSessionAsync + ); + } else { + await startSessionAsync(); } }), }); diff --git a/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts index 0cc7013685..ccaa85a3a8 100644 --- a/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts @@ -4,18 +4,23 @@ import spawn from '@expo/turtle-spawn'; import { type CustomBuildContext } from '../../../customBuildContext'; import { IosSimulatorRecordingUtils } from '../IosSimulatorRecordingUtils'; import { fetchWebPreviewTurnArgsAsync } from '../remoteDeviceRunSession'; -import { WarmSessionAdoptionError, createWarmDeviceSessionServer } from '../warmDeviceSessionHost'; -import { type WarmIosSession } from '../warmIosSession'; +import { + WarmSessionAdoptionError, + createWarmDeviceSessionServer, + runOnWarmIosSimulatorAsync, +} from '../warmDeviceSessionHost'; +import { type WarmIosSession, tryClaimWarmIosSessionAsync } from '../warmIosSession'; jest.mock('@expo/turtle-spawn'); jest.mock('../IosSimulatorRecordingUtils'); +jest.mock('../warmIosSession'); jest.mock('../remoteDeviceRunSession', () => ({ ...jest.requireActual('../remoteDeviceRunSession'), fetchWebPreviewTurnArgsAsync: jest.fn(), })); const ctx = {} as CustomBuildContext; -const logger = {} as bunyan; +const logger = { warn: jest.fn() } as unknown as bunyan; let warm: WarmIosSession; function launch(signal?: AbortSignal) { @@ -112,3 +117,48 @@ it('reports a cancellation as a cancellation', async () => { createWarmDeviceSessionServer(ctx, logger, warm).startAsync(launch(controller.signal)) ).rejects.toThrow('cancelled'); }); + +describe(runOnWarmIosSimulatorAsync, () => { + const claim = { engine: 'serve-sim' as const, env: {}, logger }; + + it('runs cold when there is no warm Simulator to claim', async () => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(undefined); + const run = jest.fn().mockResolvedValue('cold'); + await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold'); + expect(run).toHaveBeenCalledWith(undefined); + }); + + it('runs on the claimed warm Simulator', async () => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); + const run = jest.fn().mockResolvedValue('warm'); + await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('warm'); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(warm); + }); + + it('stops warmup and runs cold after an adoption failure', async () => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); + const run = jest + .fn() + .mockRejectedValueOnce(new WarmSessionAdoptionError()) + .mockResolvedValueOnce('cold'); + await expect(runOnWarmIosSimulatorAsync(claim, run)).resolves.toBe('cold'); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenLastCalledWith(undefined); + }); + + it('does not run cold after a failure during the session', async () => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); + const run = jest.fn().mockRejectedValue(new Error('session failed')); + await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toThrow('session failed'); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('does not run cold when warmup cleanup fails', async () => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); + jest.mocked(warm.process.stopAsync).mockRejectedValue(new Error('cleanup failed')); + const run = jest.fn().mockRejectedValue(new WarmSessionAdoptionError()); + await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toThrow('cleanup failed'); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts index 8863b0a97a..a3537865d0 100644 --- a/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/warmIosSession.test.ts @@ -3,9 +3,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../../utils/warmSimulator'; +import { readLocalEgressHandoffAsync } from '../localEgress'; import { tryClaimWarmIosSessionAsync } from '../warmIosSession'; jest.mock('../../../utils/warmSimulator'); +jest.mock('../localEgress'); const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan; const env = { EAS_USE_WARM_SIMULATOR: '1' }; @@ -113,6 +115,11 @@ it.each([ ], ['a pinned package version', { packageVersion: '1.2.3' }, () => {}], ['a job Simulator setting', { env: { ...env, SIMCTL_CHILD_FOO: '1' } }, () => {}], + [ + 'a local egress session', + {}, + () => jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({ port: 1 } as never), + ], ])('stops warmup and returns nothing for %s', async (_name, options, setup) => { setup(); await expect( diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 484c594921..72314689df 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -773,7 +773,7 @@ export function createIosSessionStartupInputProviders(): ReturnType< } export type ServeSimApplicationOptions = ServeSimLaunchOptions & { - installAppPath: string; + installAppPath?: string; launchAppIdentifier: string; }; diff --git a/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts index 3b70f7c1a5..fb5d2dd8dc 100644 --- a/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts @@ -5,7 +5,7 @@ import { type CustomBuildContext } from '../../customBuildContext'; import { IosSimulatorRecordingUtils } from './IosSimulatorRecordingUtils'; import { type DeviceSessionServer } from './deviceSessionHost'; import { fetchWebPreviewTurnArgsAsync } from './remoteDeviceRunSession'; -import { type WarmIosSession } from './warmIosSession'; +import { type WarmIosSession, tryClaimWarmIosSessionAsync } from './warmIosSession'; /** Adoption failed before app actions, so the caller can stop warmup and start cold. */ export class WarmSessionAdoptionError extends Error { @@ -61,3 +61,28 @@ export function createWarmDeviceSessionServer( }, }; } + +/** + * Runs an iOS session on the warm Simulator when the job can claim it, else cold. After an + * adoption failure, it stops warmup and runs the session cold. + */ +export async function runOnWarmIosSimulatorAsync( + claim: Parameters[0], + run: (warm?: WarmIosSession) => Promise +): Promise { + const warm = await tryClaimWarmIosSessionAsync(claim); + if (!warm) { + return await run(undefined); + } + try { + return await run(warm); + } catch (error) { + if (!(error instanceof WarmSessionAdoptionError)) { + throw error; + } + claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.'); + await warm.process.stopAsync(); + claim.signal?.throwIfAborted(); + return await run(undefined); + } +} diff --git a/packages/build-tools/src/steps/utils/warmIosSession.ts b/packages/build-tools/src/steps/utils/warmIosSession.ts index 279bf8d00a..f1920981c7 100644 --- a/packages/build-tools/src/steps/utils/warmIosSession.ts +++ b/packages/build-tools/src/steps/utils/warmIosSession.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { z } from 'zod'; import { runSimulatorLauncherAsync, stopWarmSimulatorAsync } from '../../utils/warmSimulator'; +import { readLocalEgressHandoffAsync } from './localEgress'; import { type DetachedProcessHandle, isProcessRunning } from './remoteDeviceRunSession'; const ServiceSchema = z.object({ pid: z.number(), port: z.number(), token: z.string() }); @@ -62,6 +63,9 @@ export async function tryClaimWarmIosSessionAsync({ if (setting) { throw new Error(`${setting} needs a cold start.`); } + if (await readLocalEgressHandoffAsync()) { + throw new Error('A local egress session needs a cold start.'); + } const claimArgs = ['claim', engine, ...(requestedDevice ? [requestedDevice] : [])]; const claim = ClaimSchema.parse( JSON.parse(await runSimulatorLauncherAsync(claimArgs, { env, signal })) From 41f0c5b7a63148267f6d28905e05c5533ed5953b Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 29 Oct 2026 15:35:37 -0700 Subject: [PATCH 2/2] [build-tools] Stop claimed warm sessions after callback failures --- .../__tests__/warmDeviceSessionHost.test.ts | 71 +++++++++++++++++-- .../src/steps/utils/warmDeviceSessionHost.ts | 13 +++- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts index ccaa85a3a8..5c16c3616b 100644 --- a/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/warmDeviceSessionHost.test.ts @@ -149,16 +149,77 @@ describe(runOnWarmIosSimulatorAsync, () => { it('does not run cold after a failure during the session', async () => { jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); - const run = jest.fn().mockRejectedValue(new Error('session failed')); - await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toThrow('session failed'); + const error = new Error('session failed'); + const run = jest.fn().mockRejectedValue(error); + await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toBe(error); + expect(warm.process.stopAsync).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledTimes(1); }); - it('does not run cold when warmup cleanup fails', async () => { + it.each([new Error('session failed'), new WarmSessionAdoptionError()])( + 'preserves both errors and does not run cold when cleanup fails (%s)', + async error => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); + const cleanupError = new Error('cleanup failed'); + jest.mocked(warm.process.stopAsync).mockRejectedValue(cleanupError); + const run = jest.fn().mockRejectedValue(error); + await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toMatchObject({ + errors: [error, cleanupError], + }); + expect(run).toHaveBeenCalledTimes(1); + } + ); + + it.each([false, true])( + 'waits for cleanup before settling (adoption failure: %s)', + async adoption => { + jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); + const error = adoption ? new WarmSessionAdoptionError() : new Error('session failed'); + let releaseCleanup = () => {}; + let cleanupStarted = () => {}; + const started = new Promise(resolve => { + cleanupStarted = resolve; + }); + jest.mocked(warm.process.stopAsync).mockImplementation(() => { + cleanupStarted(); + return new Promise(resolve => { + releaseCleanup = resolve; + }); + }); + const run = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('cold'); + const running = runOnWarmIosSimulatorAsync(claim, run); + let settled = false; + const result = running.then( + value => { + settled = true; + return value; + }, + error => { + settled = true; + return error; + } + ); + await started; + await Promise.resolve(); + expect(settled).toBe(false); + expect(run).toHaveBeenCalledTimes(1); + releaseCleanup(); + expect(await result).toBe(adoption ? 'cold' : error); + expect(run).toHaveBeenCalledTimes(adoption ? 2 : 1); + } + ); + + it('does not retry cold when cancellation occurs during cleanup', async () => { jest.mocked(tryClaimWarmIosSessionAsync).mockResolvedValue(warm); - jest.mocked(warm.process.stopAsync).mockRejectedValue(new Error('cleanup failed')); + const controller = new AbortController(); + const cancellation = new Error('cancelled'); + jest.mocked(warm.process.stopAsync).mockImplementation(async () => { + controller.abort(cancellation); + }); const run = jest.fn().mockRejectedValue(new WarmSessionAdoptionError()); - await expect(runOnWarmIosSimulatorAsync(claim, run)).rejects.toThrow('cleanup failed'); + await expect( + runOnWarmIosSimulatorAsync({ ...claim, signal: controller.signal }, run) + ).rejects.toBe(cancellation); expect(run).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts index fb5d2dd8dc..e5e9f00414 100644 --- a/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/warmDeviceSessionHost.ts @@ -63,8 +63,8 @@ export function createWarmDeviceSessionServer( } /** - * Runs an iOS session on the warm Simulator when the job can claim it, else cold. After an - * adoption failure, it stops warmup and runs the session cold. + * Runs an iOS session on the warm Simulator when the job can claim it, else cold. After a + * callback failure, it stops warmup. Only an adoption failure can retry cold. */ export async function runOnWarmIosSimulatorAsync( claim: Parameters[0], @@ -77,11 +77,18 @@ export async function runOnWarmIosSimulatorAsync( try { return await run(warm); } catch (error) { + try { + await warm.process.stopAsync(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Could not stop the warm Simulator after failure.' + ); + } if (!(error instanceof WarmSessionAdoptionError)) { throw error; } claim.logger.warn('Could not adopt the warm Simulator; starting a cold Simulator.'); - await warm.process.stopAsync(); claim.signal?.throwIfAborted(); return await run(undefined); }