diff --git a/.changeset/serve-sim-pr-4544.md b/.changeset/serve-sim-pr-4544.md new file mode 100644 index 0000000000..c01e051e61 --- /dev/null +++ b/.changeset/serve-sim-pr-4544.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. diff --git a/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts b/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts index 25dd7e79fa..92a341142f 100644 --- a/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts @@ -14,6 +14,12 @@ import { registerLocalEgressResources, stopLocalEgressResourcesAsync, } from '../../utils/localEgress'; +import { + reportLocalEgressGuardCoverageAsync, + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, + verifyLocalEgressGuardAsync, +} from '../../utils/localEgressGuard'; import { uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, @@ -51,6 +57,13 @@ jest.mock('../../utils/localEgress', () => ({ readLocalEgressHandoffAsync: jest.fn(), monitorLocalEgressAsync: jest.fn(), })); +jest.mock('../../utils/localEgressGuard', () => ({ + ...jest.requireActual('../../utils/localEgressGuard'), + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), + startLocalEgressGuardRelayAsync: jest.fn(), + reportLocalEgressGuardCoverageAsync: jest.fn(), + verifyLocalEgressGuardAsync: jest.fn(), +})); jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), getDeviceRunSessionIdOrThrow: () => 'session-id', @@ -126,9 +139,20 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true); jest .mocked(IosSimulatorUtils.getAvailableDevicesAsync) - .mockResolvedValue([{ udid: 'sim' }] as never); + .mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never); + jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never); jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never); jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff); + jest + .mocked(resolveLocalEgressServeSimBootEnvironmentAsync) + .mockImplementation(async () => + (await readLocalEgressHandoffAsync()) + ? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' } + : null + ); + jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined); + jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null); + jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined); jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined); openPreview.mockResolvedValue({ previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', 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 f4baf9f676..28cae9576d 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 @@ -70,7 +70,7 @@ const mockEventCollectionStopAsync = jest.fn(); type CaptureInputs = Parameters[1]['capture']; const NO_CAPTURE: CaptureInputs = { networkCapture: false, networkCaptureFields: [] }; -/** Runs the session for a device that is already booted, with the app (if any) launched. */ +/** Runs an Android session that is ready, or an iOS session with no application preparation. */ async function runAsync( logger: { info: jest.Mock; warn: jest.Mock; child: jest.Mock }, runtimePlatform: BuildRuntimePlatform, @@ -94,7 +94,10 @@ async function runAsync( disableShare, privatePreviewUrl, tasks: createStartupTasks(logger as never), - device: { booted: Promise.resolve(), ready: Promise.resolve() }, + device: + runtimePlatform === BuildRuntimePlatform.DARWIN + ? { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() } + : { booted: Promise.resolve(), ready: Promise.resolve() }, }); } @@ -124,13 +127,23 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { subdomainId: 'agent-device-abc', stopAsync: mockTunnelStopAsync, }); - jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({ + const host = { openPreviewAsync: jest.fn().mockResolvedValue({ previewPageUrl: 'https://expo.dev/simulator-preview/preview-id', apiUrl: 'https://web-preview.tunnel.example.com', closeAsync: jest.fn(), }), finishAsync: mockPreviewStopAsync, + }; + jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => { + try { + await options.application; + options.signal?.throwIfAborted(); + } catch (error) { + await mockPreviewStopAsync(); + throw error; + } + return host; }); jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined); jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); @@ -150,7 +163,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( ctx, - expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX }) + expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, timeoutMs: 60_000 }) ); expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -289,14 +302,14 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { }); describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => { - function deferred(): { - promise: Promise; - resolve: () => void; + function deferred(): { + promise: Promise; + resolve: (value: T) => void; reject: (e: Error) => void; } { - let resolve!: () => void; + let resolve!: (value: T) => void; let reject!: (e: Error) => void; - const promise = new Promise((res, rej) => { + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); @@ -304,18 +317,26 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { return { promise, resolve, reject }; } - type Device = { booted: Promise; ready: Promise }; - function startSession(device: Device | ((tasks: StartupTasks) => Device)) { + type Device = Parameters[1]['device']; + function startSession( + device: Device | ((tasks: StartupTasks) => Device), + signal?: AbortSignal + ) { const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis(), } as never; - const tasks = createStartupTasks(logger); + const tasks = createStartupTasks(logger, signal); + const sessionDevice = typeof device === 'function' ? device(tasks) : device; return runAgentDeviceRemoteSessionAsync(ctx, { env: {}, logger, - runtimePlatform: BuildRuntimePlatform.DARWIN, + signal, + runtimePlatform: + 'iosSimulatorUdid' in sessionDevice + ? BuildRuntimePlatform.DARWIN + : BuildRuntimePlatform.LINUX, sessionEnv: { deviceRunSessionId: 'device-run-session-id', ngrokTunnelDomain: 'tunnel.example.com', @@ -326,7 +347,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { maxDurationSeconds: undefined, capture: NO_CAPTURE, tasks, - device: typeof device === 'function' ? device(tasks) : device, + device: sessionDevice, }); } @@ -336,7 +357,31 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { } } - it('starts the daemon during the boot and the session host after it', async () => { + it('aborts parallel startup before host failure cleanup finishes', async () => { + const failure = new Error('guard refused startup'); + const cleanup = deferred(); + const notified = deferred(); + let startupTasks!: StartupTasks; + jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => { + options.onStartupError?.(failure); + notified.resolve(); + await cleanup.promise; + throw failure; + }); + const session = startSession(tasks => { + startupTasks = tasks; + return { iosSimulatorUdid: 'selected-udid' }; + }); + const rejected = expect(session).rejects.toBe(failure); + await notified.promise; + expect(startupTasks.signal.aborted).toBe(true); + expect(startupTasks.signal.reason).toBe(failure); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + cleanup.resolve(); + await rejected; + }); + + it('starts the daemon during the Android boot and the session host after it', async () => { const booted = deferred(); const session = startSession({ booted: booted.promise, ready: booted.promise }); await flushAsync(); @@ -348,10 +393,237 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { booted.resolve(); await session; expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + runtimePlatform: BuildRuntimePlatform.LINUX, + timeoutMs: 60_000, + iosSimulatorUdid: undefined, + }) + ); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1); + }); + + it('preserves a failed download when aborting daemon installation rejects first', async () => { + const failure = new Error('download failed: build not found'); + const installStarted = deferred(); + jest.mocked(spawn).mockImplementation((async (_command, _args, options) => { + return await new Promise((_resolve, reject) => { + options!.signal!.addEventListener( + 'abort', + () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }, + { once: true } + ); + installStarted.resolve(); + }); + }) as typeof spawn); + jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, { signal }) => { + return await new Promise((_resolve, reject) => { + signal!.addEventListener( + 'abort', + () => { + setTimeout(() => reject(signal!.reason), 20); + }, + { once: true } + ); + }); + }); + const applicationReady = Promise.resolve(); + const session = startSession(tasks => { + void tasks.run('build download', async () => { + await installStarted.promise; + throw failure; + }); + return { iosSimulatorUdid: 'selected-udid', application: applicationReady }; + }); + await expect(session).rejects.toBe(failure); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + + it('boots the host during download and releases startup only after preparation', async () => { + const hostReady = deferred(); + const application = deferred<{ + installAppPath: string; + launchAppIdentifier: string; + launchArgs: string[]; + openUrl: string; + }>(); + const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!( + ctx, + {} as never + ); + const release = jest.fn(); + jest + .mocked(startDeviceSessionHostAsync) + .mockClear() + .mockImplementation(async (_ctx, options) => { + release(await options.application); + await hostReady.promise; + return host; + }); + const applicationReady = application.promise; + const session = startSession({ + iosSimulatorUdid: 'selected-udid', + application: applicationReady, + }); + await flushAsync(); + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); + expect(release).not.toHaveBeenCalled(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + const startup = { + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--flag'], + openUrl: 'example://screen', + }; + application.resolve(startup); + await flushAsync(); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + application: applicationReady, + iosSimulatorUdid: 'selected-udid', + timeoutMs: 60_000, + signal: expect.any(AbortSignal), + }) + ); + expect(release).toHaveBeenCalledWith(startup); + expect(host.openPreviewAsync).not.toHaveBeenCalled(); + hostReady.resolve(); + await session; expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + }); + + it('passes the guard boot environment with the startup installation and launch', async () => { + const startup = { + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--flag'], + openUrl: 'example://screen', + }; + const bootEnv = { + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block', + SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/egress-guard.log', + SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899', + }; + await startSession({ + iosSimulatorUdid: 'guarded-udid', + bootEnv, + application: Promise.resolve(startup), + }); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + application: expect.any(Promise), + bootEnv, + iosSimulatorUdid: 'guarded-udid', + }) + ); + const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1]; + expect(hostOptions.bootEnv).toBe(bootEnv); + expect(hostOptions.bootEnv).not.toHaveProperty('DYLD_INSERT_LIBRARIES'); + expect(hostOptions.bootEnv).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES'); + }); + + it('cleans up a host returned after cancellation without configuring the device', async () => { + const hostReady = deferred(); + const controller = new AbortController(); + const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!( + ctx, + {} as never + ); + jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => { + await hostReady.promise; + return host; + }); + const applicationReady = Promise.resolve(); + const session = startSession( + { iosSimulatorUdid: 'selected-udid', application: applicationReady }, + controller.signal + ); + const failure = expect(session).rejects.toThrow('cancelled'); + await flushAsync(); + controller.abort(new Error('cancelled')); + hostReady.resolve(); + + await failure; + expect(host.openPreviewAsync).not.toHaveBeenCalled(); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1); + expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + + it('drains in-flight application preparation and stops the waiting host after cancellation', async () => { + const controller = new AbortController(); + const appReady = deferred(); + const session = startSession( + { iosSimulatorUdid: 'selected-udid', application: appReady.promise }, + controller.signal + ); + const failure = expect(session).rejects.toThrow('cancelled'); + await flushAsync(); + controller.abort(new Error('cancelled')); + await flushAsync(); + expect(mockPreviewStopAsync).not.toHaveBeenCalled(); + + appReady.resolve(); + await failure; + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + + it('does not report readiness when cancellation arrives during preview startup', async () => { + const controller = new AbortController(); + const previewReady = deferred(); + const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!( + ctx, + {} as never + ); + const preview = await host.openPreviewAsync({ + baseDomain: 'tunnel.example.com', + authtoken: 'ngrok-token', + }); + jest.mocked(host.openPreviewAsync).mockImplementation(async () => { + await previewReady.promise; + return preview; + }); + const session = startSession( + { iosSimulatorUdid: 'selected-udid', application: Promise.resolve() }, + controller.signal + ); + const failure = expect(session).rejects.toThrow('cancelled'); + await flushAsync(); + controller.abort(new Error('cancelled')); + previewReady.resolve(); + + await failure; + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + }); + + it('does not start resources for an already cancelled session', async () => { + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + const applicationReady = Promise.resolve(); + + await expect( + startSession( + { iosSimulatorUdid: 'selected-udid', application: applicationReady }, + controller.signal + ) + ).rejects.toThrow('cancelled'); + expect(spawn).not.toHaveBeenCalled(); + expect(startDeviceSessionHostAsync).not.toHaveBeenCalled(); }); - it('reports the session as ready only after the app is launched', async () => { + it('starts the Android host before application readiness and waits before reporting ready', async () => { const ready = deferred(); const session = startSession({ booted: Promise.resolve(), ready: ready.promise }); await flushAsync(); @@ -361,10 +633,11 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { ready.resolve(); await session; + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1); }); - it('stops waiting for the boot when the daemon fails', async () => { + it('stops waiting for the Android boot when the daemon fails', async () => { jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials')); const neverBooted = new Promise(() => {}); @@ -378,7 +651,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); }); - it('stops the session host when the app fails while the daemon install hangs', async () => { + it('cancels daemon installation and stops the Android host when the download fails', async () => { jest.mocked(spawn).mockImplementation((( command: string, args: string[], @@ -394,21 +667,22 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { await flushAsync(); expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); - ready.reject(new Error('simctl install failed')); + ready.reject(new Error('download failed')); - await expect(session).rejects.toThrow('simctl install failed'); + await expect(session).rejects.toThrow('download failed'); expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); expect(spawnDetached).not.toHaveBeenCalled(); expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); }); - it('stops everything it started when the app install fails', async () => { + it('stops the Android host, daemon and tunnel when the download fails', async () => { const ready = deferred(); const session = startSession({ booted: Promise.resolve(), ready: ready.promise }); await flushAsync(); - ready.reject(new Error('simctl install failed')); + expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1); + ready.reject(new Error('download failed')); - await expect(session).rejects.toThrow('simctl install failed'); + await expect(session).rejects.toThrow('download failed'); expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1); expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1); 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 3727d644aa..15d01dd6c8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts @@ -1,11 +1,20 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps'; +import { UserError } from '@expo/eas-build-job'; +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 { + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, +} from '../../utils/localEgressGuard'; import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession'; import { downloadBuildAsync } from '../downloadBuild'; import { installBuildAsync } from '../installBuild'; +import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact'; import { launchApplicationAsync } from '../launchApplication'; import { getAgentDeviceRemoteSessionEnvOrThrow, @@ -15,17 +24,30 @@ import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceS import { startAndroidEmulatorAsync } from '../startAndroidEmulator'; import { bootIosSimulatorAsync } from '../startIosSimulator'; -jest.mock('../../utils/localEgressSession', () => ({ - withLocalEgressSession: (fn: unknown) => fn, +jest.mock('../../utils/localEgress', () => ({ + stopLocalEgressResourcesAsync: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../utils/localEgressGuard', () => ({ + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), + startLocalEgressGuardRelayAsync: jest.fn(), })); +jest.mock('@expo/turtle-spawn'); +jest.mock('../../../utils/IosSimulatorUtils'); jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), selectXcodeDeveloperDirectoryAsync: jest.fn(), })); -jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() })); +jest.mock('../startIosSimulator', () => ({ + ...jest.requireActual('../startIosSimulator'), + bootIosSimulatorAsync: jest.fn(), +})); jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() })); jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() })); jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() })); +jest.mock('../../utils/iosAppArtifact', () => ({ + ...jest.requireActual('../../utils/iosAppArtifact'), + readIosApplicationIdentifierAsync: jest.fn(), +})); jest.mock('../launchApplication', () => ({ ...jest.requireActual('../launchApplication'), launchApplicationAsync: jest.fn(), @@ -43,7 +65,17 @@ const sessionEnv = { ngrokAuthtoken: 'ngrok-token', }; -type Device = { booted: Promise; ready: Promise }; +type Device = Parameters[1]['device']; +let deviceLaunch: unknown; + +function runDevicePreparationAsync(device: Device): Promise { + return ( + 'iosSimulatorUdid' in device ? (device.application ?? Promise.resolve()) : device.ready + ).then(async launch => { + deviceLaunch = launch; + return launch; + }); +} function deferred(): { promise: Promise; @@ -69,6 +101,7 @@ async function flushAsync(): Promise { function runStep( runtimePlatform: BuildRuntimePlatform, inputValues: Record = {}, + signal?: AbortSignal, stepCtx: CustomBuildContext = ctx ): Promise { const buildFunction = createStartAgentDeviceSessionBuildFunction(stepCtx); @@ -103,22 +136,32 @@ function runStep( staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } }, }, } as unknown as BuildStepContext, - { inputs, outputs: {}, env: {}, signal: undefined } as never + { inputs, outputs: {}, env: {}, signal } as never ) as Promise; } -/** The `device` promises handed to the shared agent-device session code. */ function sessionDevice(): Device { return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device; } describe(createStartAgentDeviceSessionBuildFunction, () => { beforeEach(() => { + deviceLaunch = undefined; jest.clearAllMocks(); + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null); + jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined); + jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Shutdown' } as never); + jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never); + jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('selected-udid' as never); + jest + .mocked(IosSimulatorUtils.getAvailableDevicesAsync) + .mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never); jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv); - jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => { - await device.ready; - }); + jest + .mocked(runAgentDeviceRemoteSessionAsync) + .mockImplementation(async (_ctx, { device, tasks }) => { + await tasks.untilAborted(runDevicePreparationAsync(device)); + }); jest.mocked(bootIosSimulatorAsync).mockResolvedValue({ deviceIdentifier: 'iPhone 17' as never, udid: 'udid' as never, @@ -134,22 +177,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { .mocked(installBuildAsync) .mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' }); jest.mocked(launchApplicationAsync).mockResolvedValue(undefined); + jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app'); }); - it('downloads the build and starts the session while the iOS Simulator boots', async () => { - const boot = deferred>>(); - jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise); - - const step = runStep(BuildRuntimePlatform.DARWIN, { + it('hands guarded iOS boot to serve-sim and starts reporting before host startup', async () => { + const bootEnv = { + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block', + SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899', + }; + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv); + await runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id', launch_args: ['-flag'], open_url: 'exp://example.test', }); - await flushAsync(); - expect( jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0] - ).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]); + ).toBeLessThan( + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mock.invocationCallOrder[0] + ); + expect(startLocalEgressGuardRelayAsync).toHaveBeenCalledTimes(1); + expect(jest.mocked(startLocalEgressGuardRelayAsync).mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0] + ); + expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); expect(downloadBuildAsync).toHaveBeenCalledWith( expect.objectContaining({ buildId: 'build-id', @@ -159,28 +211,45 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { }) ); expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1); - // The install needs the booted Simulator. + const device = sessionDevice(); + expect(device).toEqual({ + iosSimulatorUdid: 'SELECTED-UDID', + bootEnv, + application: expect.any(Promise), + }); + expect(spawn).not.toHaveBeenCalled(); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({ + artifactPath: '/tmp/App.app', + env: {}, + }); expect(installBuildAsync).not.toHaveBeenCalled(); - - boot.resolve({ - deviceIdentifier: 'iPhone 17' as never, - udid: 'udid' as never, - displayName: '', + expect(launchApplicationAsync).not.toHaveBeenCalled(); + expect(deviceLaunch).toEqual({ + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['-flag'], + openUrl: 'exp://example.test', }); - await step; + }); - expect(installBuildAsync).toHaveBeenCalledWith( - expect.objectContaining({ artifactPath: '/tmp/App.app' }) - ); - expect(launchApplicationAsync).toHaveBeenCalledWith( - expect.objectContaining({ - applicationIdentifier: 'dev.example.app', - launchArgs: ['-flag'], - openUrl: 'exp://example.test', - }) - ); - expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan( - jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0] + it('shuts down only the selected guarded Booted device before host startup', async () => { + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({ + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + }); + jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ state: 'Booted' } as never); + await runStep(BuildRuntimePlatform.DARWIN); + expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({ + udid: 'SELECTED-UDID', + env: {}, + }); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledWith('xcrun', ['simctl', 'shutdown', 'SELECTED-UDID'], { + env: {}, + logger: expect.any(Object), + signal: expect.any(AbortSignal), + }); + expect(jest.mocked(spawn).mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(runAgentDeviceRemoteSessionAsync).mock.invocationCallOrder[0] ); }); @@ -191,7 +260,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expoApiV2BaseUrl: 'https://api.expo.test/v2/', } as unknown as CustomBuildContext; - await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, sessionCtx); + await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }, undefined, sessionCtx); expect(downloadBuildAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -215,6 +284,7 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { await runStep( BuildRuntimePlatform.LINUX, { application_archive_url: 'https://example.test/app.tar.gz' }, + undefined, sessionCtx ); expect(downloadBuildAsync).toHaveBeenLastCalledWith( @@ -225,7 +295,96 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { ); }); - it('waits for the download when the Simulator boots first', async () => { + it('fails before download 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(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1); + }); + + it('provides Simulator preparation and downloaded startup options to the remote session', async () => { + const hostReady = deferred(); + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({ + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + }); + jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => { + expect('iosSimulatorUdid' in device).toBe(true); + if (!('iosSimulatorUdid' in device)) { + return; + } + await hostReady.promise; + deviceLaunch = await device.application; + }); + const step = runStep(BuildRuntimePlatform.DARWIN, { + device_identifier: 'iPhone 17', + build_id: 'build-id', + launch_args: ['-flag'], + open_url: 'exp://example.test', + }); + await flushAsync(); + expect(IosSimulatorUtils.resolveUdidAsync).toHaveBeenCalledWith({ + deviceIdentifier: 'iPhone 17', + env: {}, + }); + expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); + expect(downloadBuildAsync).toHaveBeenCalledTimes(1); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({ + artifactPath: '/tmp/App.app', + env: {}, + }); + expect(installBuildAsync).not.toHaveBeenCalled(); + expect(launchApplicationAsync).not.toHaveBeenCalled(); + expect(deviceLaunch).toBeUndefined(); + hostReady.resolve(); + await step; + expect(deviceLaunch).toEqual({ + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['-flag'], + openUrl: 'exp://example.test', + }); + }); + + it('fails a guarded session before download 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' + ); + expect(startLocalEgressGuardRelayAsync).not.toHaveBeenCalled(); + expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(bootIosSimulatorAsync).not.toHaveBeenCalled(); + expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1); + }); + + it('releases guard reporting and egress resources when host startup fails', async () => { + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue({ + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + }); + jest + .mocked(runAgentDeviceRemoteSessionAsync) + .mockRejectedValueOnce(new Error('guard check failed')); + await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).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); @@ -237,16 +396,21 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { download.resolve({ artifactPath: '/tmp/App.app' }); await step; - expect(installBuildAsync).toHaveBeenCalledTimes(1); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledTimes(1); + expect(installBuildAsync).not.toHaveBeenCalled(); }); - it('makes the session ready when the device boots if there is no app', async () => { + 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(); - await expect(sessionDevice().ready).resolves.toBeUndefined(); + expect(sessionDevice()).toEqual({ + iosSimulatorUdid: 'SELECTED-UDID', + bootEnv: undefined, + application: undefined, + }); }); it('boots the Android Emulator with the device inputs', async () => { @@ -278,23 +442,10 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { }); it('fails the session when the download fails, without an unhandled rejection', async () => { - const boot = deferred>>(); - jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise); jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed')); - - const stepFailure = expect( - runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }) - ).rejects.toThrow('download failed'); - // The download fails while the boot still runs. Nothing inside the step may leave - // that rejection unhandled; Jest fails the test if it does. - await flushAsync(); - boot.resolve({ - deviceIdentifier: 'iPhone 17' as never, - udid: 'udid' as never, - displayName: '', - }); - - await stepFailure; + await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( + 'download failed' + ); expect(installBuildAsync).not.toHaveBeenCalled(); }); @@ -323,23 +474,24 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { 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, device.ready]); + await Promise.all([daemon, ready]); } finally { - await Promise.allSettled([daemon, device.ready]); + await Promise.allSettled([daemon, ready]); } }); } - it('stops the download and waits for it when the boot fails', async () => { + it('stops the download and waits for it when Android boot fails', async () => { const download = mockStalledDownload(); - jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed')); + jest.mocked(startAndroidEmulatorAsync).mockRejectedValue(new Error('boot failed')); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( + await expect(runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' })).rejects.toThrow( 'boot failed' ); @@ -363,25 +515,79 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expect(launchApplicationAsync).not.toHaveBeenCalled(); }); - it('does not launch the app when startup fails during the install', async () => { - const install = deferred>>(); - const installStarted = deferred(); - jest.mocked(installBuildAsync).mockImplementation(() => { - installStarted.resolve(); - return install.promise; + 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) + ).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(); + }); + + it('does not start device or download 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 failure = expect(step).rejects.toThrow('cancelled'); + await flushAsync(); + controller.abort(new Error('cancelled')); + selected.resolve(); + + 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 installStarted.promise; - // The install finishes only after the daemon failed. - setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' })); + await metadataStarted.promise; + setImmediate(() => metadata.resolve('dev.example.app')); }); - await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow( 'daemon failed' ); - - expect(installBuildAsync).toHaveBeenCalledTimes(1); + 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 () => { @@ -464,6 +670,31 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { ); }); + it.each([ + ['argument count', { launch_args: Array(257).fill('a') }], + ['argument length', { launch_args: ['a'.repeat(9000)] }], + ['NUL argument', { launch_args: ['a\u0000b'] }], + ['total UTF-8 bytes', { launch_args: Array(8).fill('😃'.repeat(4096)) }], + ['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 }); + 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(); + }); + + 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 }); + expect(launchApplicationAsync).toHaveBeenCalledWith( + expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX, launchArgs }) + ); + }); + it('passes network capture to the session', async () => { await runStep(BuildRuntimePlatform.DARWIN, { network_capture: true, diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index 0ca57600ce..f793f6bc1d 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -1,6 +1,6 @@ import { SystemError } from '@expo/eas-build-job'; import { type bunyan } from '@expo/logger'; -import { type BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; +import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import fs from 'node:fs'; import os from 'node:os'; @@ -19,6 +19,7 @@ import { import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts'; import { type parseNetworkCaptureInputs } from '../utils/networkCaptureFields'; import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents'; +import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession'; import { type StartupTasks } from '../utils/startupTasks'; import { type DetachedProcessHandle, @@ -76,13 +77,9 @@ export function getAgentDeviceRemoteSessionEnvOrThrow( * Starts the agent-device daemon, its tunnel, the session host and the web preview, * reports the session as ready, and keeps it alive until it stops. * - * The daemon does not need the device, so it starts at once. The session host starts - * when `device.booted` resolves. The session is reported as ready only when both are - * up and `device.ready` (the app is installed and launched) resolved too. - * - * The first failure aborts `tasks.signal`: each part stops before its next stage, and - * the teardown then stops whatever was started. `device.ready` must settle soon after - * an abort, because the teardown waits for it. + * Serve-sim boots while the iOS download runs. After device preparation verifies + * any egress guard, its control API installs and launches the app. + * Startup failures abort the other tasks; teardown drains them before stopping acquired resources. */ export async function runAgentDeviceRemoteSessionAsync( ctx: CustomBuildContext, @@ -117,9 +114,21 @@ export async function runAgentDeviceRemoteSessionAsync( frameAncestors?: string[]; corsOrigins?: string[]; tasks: StartupTasks; - device: { booted: Promise; ready: Promise }; + device: + | { + booted: Promise; + ready: Promise; + } + | { + iosSimulatorUdid: string; + bootEnv?: Record; + application?: Promise; + }; } ): Promise { + signal?.throwIfAborted(); + tasks.signal.throwIfAborted(); + logger.info( `Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).` ); @@ -130,6 +139,8 @@ export async function runAgentDeviceRemoteSessionAsync( let eventCollection: Awaited> | undefined; let sessionFailed = false; const daemonSecrets = [ngrokAuthtoken]; + const hostBootedDevice = 'iosSimulatorUdid' in device ? device : undefined; + const externallyBootedDevice = 'booted' in device ? device : undefined; // Each task stores what it started, so the teardown below can stop it even when // another task failed first. @@ -158,12 +169,14 @@ export async function runAgentDeviceRemoteSessionAsync( authtoken: ngrokAuthtoken, logger: taskLogger, }); + tasks.signal.throwIfAborted(); taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`); return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url }; }); const sessionHostStartup = tasks.run('session host', async taskLogger => { - // A boot cannot be cancelled, so stop waiting for it when startup is aborted. - await tasks.untilAborted(device.booted); + if (externallyBootedDevice) { + await tasks.untilAborted(externallyBootedDevice.booted); + } tasks.signal.throwIfAborted(); sessionHost = await startDeviceSessionHostAsync(ctx, { separateLogPhase: true, @@ -175,14 +188,20 @@ export async function runAgentDeviceRemoteSessionAsync( privatePreviewUrl, frameAncestors, corsOrigins, + signal: tasks.signal, + onStartupError: tasks.abort, networkCapture: capture.networkCapture, networkCaptureFields: capture.networkCaptureFields, + iosSimulatorUdid: hostBootedDevice?.iosSimulatorUdid, + bootEnv: hostBootedDevice?.bootEnv, + application: hostBootedDevice?.application, }); tasks.signal.throwIfAborted(); const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, }); + tasks.signal.throwIfAborted(); taskLogger.info( webPreview.sharePageUrl ? `Web preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).` @@ -190,18 +209,20 @@ export async function runAgentDeviceRemoteSessionAsync( ); return webPreview; }); + const deviceReady = externallyBootedDevice?.ready; try { const [ { port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl }, webPreview, - ] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch( + ] = await Promise.all([agentDeviceStartup, sessionHostStartup, deviceReady]).catch( (err: unknown) => { // Also aborts for a `device.ready` that does not come from `tasks.run`. tasks.abort(err); - throw err; + throw tasks.signal.reason; } ); + tasks.signal.throwIfAborted(); logger.info(tasks.summary()); await uploadRemoteSessionConfigWithLocalEgressAsync({ @@ -218,6 +239,7 @@ export async function runAgentDeviceRemoteSessionAsync( }, logger, }); + tasks.signal.throwIfAborted(); void pollAgentDeviceArtifactsForUploadAsync(ctx, { deviceRunSessionId, daemonUrl: `http://127.0.0.1:${daemonPort}`, @@ -253,7 +275,7 @@ export async function runAgentDeviceRemoteSessionAsync( // Promise.all rejects on the first failure while other tasks can still be starting. // They stop at their next abort check. Wait for all of them, so the teardown sees // everything that was started. - await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]); + await Promise.allSettled([agentDeviceStartup, sessionHostStartup, deviceReady]); const startedDaemon = daemonProcess; await finishRemoteSessionAsync({ logger, @@ -297,6 +319,7 @@ export async function startAgentDeviceDaemonAsync({ signal?: AbortSignal; secrets?: string[]; }): Promise { + signal?.throwIfAborted(); const packageSpec = createAgentDevicePackageSpec(packageVersion); const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN); const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-')); @@ -306,6 +329,7 @@ export async function startAgentDeviceDaemonAsync({ ); try { + signal?.throwIfAborted(); const add = resolvePackageAdd(packageManager, packageSpec); logger.info(`Installing ${packageSpec} with ${add.command}.`); await spawn(add.command, add.args, { cwd: installDir, env, logger, signal }); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index 2b499fd20d..a2cc2535de 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -15,7 +15,6 @@ import { AndroidEmulatorUtils, AndroidVirtualDeviceName, } from '../../utils/AndroidEmulatorUtils'; -import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi'; import { withLocalEgressSession } from '../utils/localEgressSession'; import { @@ -33,6 +32,10 @@ import { parseNetworkCaptureInputs, } from '../utils/networkCaptureFields'; import { createStartupTasks } from '../utils/startupTasks'; +import { + type ServeSimApplicationOptions, + validateServeSimLaunchOptions, +} from '../utils/remoteDeviceRunSession'; import { downloadBuildAsync } from './downloadBuild'; import { installBuildAsync } from './installBuild'; @@ -46,26 +49,15 @@ import { runAgentDeviceRemoteSessionAsync, } from './startAgentDeviceRemoteSession'; import { startAndroidEmulatorAsync } from './startAndroidEmulator'; -import { bootIosSimulatorAsync } from './startIosSimulator'; +import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; +import { readIosApplicationIdentifierAsync } from '../utils/iosAppArtifact'; const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName; /** - * One step for a whole agent-device session: boot the device, download, install and - * launch the app, and start the agent-device daemon and the web preview. - * - * It does the work of eas/start_ios_simulator or eas/start_android_emulator, - * eas/download_build, eas/install_build and eas/launch_application, plus the agent-device - * session, so the parts that do not depend on each other can run at the same time: - * - * boot ─────────────┬─► install ─► launch ─┐ - * download ─────────┘ │ - * boot ─► session host ─► web preview ─────┼─► ready - * agent-device daemon ─► tunnel ───────────┘ - * - * The first failure aborts the rest: the download stops, and nothing installs, launches - * or starts after it. A boot cannot be cancelled, so it can still run when a failed - * step returns. + * Starts a device session, downloading the app alongside daemon startup. + * 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. */ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction { return new BuildFunction({ @@ -170,6 +162,9 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte 'launch_args and open_url need an application: pass build_id or application_archive_url.' ); } + if (isIos) { + validateServeSimLaunchOptions({ launchArgs, openUrl }); + } const deviceIdentifier = inputs.device_identifier.value as string | undefined; const capture = parseNetworkCaptureInputs( { @@ -179,109 +174,137 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte { runtimePlatform } ); - if (isIos) { - // Before the boot, so every Xcode tool below uses the same developer directory. - await selectXcodeDeveloperDirectoryAsync({ env, logger }); - } + const tasks = createStartupTasks(logger, signal); + let downloaded: Promise | undefined; + let applicationReady: Promise | undefined; - const tasks = createStartupTasks(logger); - - const booted = tasks.run( - isIos ? 'iOS Simulator boot' : 'Android Emulator boot', - async taskLogger => { - if (isIos) { - await bootIosSimulatorAsync({ - deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | 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: taskLogger, - }); - return; - } - 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: await fs.promises.mkdtemp( - path.join(os.tmpdir(), 'eas-android-emulator-logcat-') - ), - env, - logger: taskLogger, - }); + logger, + signal: tasks.signal, + }) + : {}; + const iosSimulatorUdid = iosStartup?.iosSimulatorUdid; + if (iosSimulatorUdid) { + logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`); } - ); - const 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, + 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-') + ); + 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, + }); }); - return artifactPath; - }) - : undefined; - // `ready` settles only after everything it started has settled, so the session - // teardown, which waits for it, never leaves a download running. It must also settle - // soon after an abort: it stops waiting for the boot then, and the download stops. - const ready = downloaded - ? tasks.run('app install and launch', async taskLogger => { - const [download, boot] = await Promise.allSettled([ - downloaded, - tasks.untilAborted(booted), - ]); - // After a failure elsewhere, report that failure, not the abort it caused here. - tasks.signal.throwIfAborted(); - if (download.status === 'rejected') { - throw download.reason; - } - if (boot.status === 'rejected') { - throw boot.reason; - } - const { applicationIdentifier, activityName } = await installBuildAsync({ - artifactPath: download.value, - runtimePlatform, - env, - logger: taskLogger, - }); - tasks.signal.throwIfAborted(); - await launchApplicationAsync({ - applicationIdentifier, - activityName, - launchArgs, - openUrl, - runtimePlatform, - env, - logger: taskLogger, - }); - }) - : tasks.untilAborted(booted); + 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; - 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: { booted, ready }, - }); + const download = downloaded; + applicationReady = download + ? tasks.run(isIos ? 'app preparation' : 'app install and launch', async taskLogger => { + if (booted) { + await tasks.untilAborted(booted); + } + 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, + env, + logger: taskLogger, + }); + tasks.signal.throwIfAborted(); + await launchApplicationAsync({ + applicationIdentifier, + activityName, + launchArgs, + openUrl, + runtimePlatform, + 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]); + } }), }); }