From d71dca75d732e2a4bd5efb2ba6db2d4def50ae56 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 01:23:41 -0700 Subject: [PATCH 1/4] [build-tools] Let serve-sim start the iOS Simulator for web preview Co-Authored-By: GPT-6.1-Sol --- .../resources/egress-guard/README.md | 11 + .../remoteSessionLocalEgress.test.ts | 2 +- .../startWebPreviewRemoteSession.test.ts | 51 +- .../src/steps/functions/startLocalEgress.ts | 10 +- .../functions/startWebPreviewRemoteSession.ts | 33 +- .../utils/__tests__/deviceSessionHost.test.ts | 738 +++++++++++++++++- .../__tests__/remoteDeviceRunSession.test.ts | 8 + .../utils/__tests__/startupTasks.test.ts | 19 + .../src/steps/utils/deviceSessionHost.ts | 237 +++++- .../src/steps/utils/startupTasks.ts | 8 +- 10 files changed, 1075 insertions(+), 42 deletions(-) diff --git a/packages/build-tools/resources/egress-guard/README.md b/packages/build-tools/resources/egress-guard/README.md index 2e8e1b6792..4a0e02b967 100644 --- a/packages/build-tools/resources/egress-guard/README.md +++ b/packages/build-tools/resources/egress-guard/README.md @@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both `packages/worker/package.sh` for the iOS worker tarball and by the `test-egress-guard` EAS workflow, not committed. +For serve-sim-owned iOS session startup, build-tools passes the guard through +`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through +inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected +device so every process inherits the guard. Serve-sim boots that device while +build-tools prepares the app. After serve-sim's control endpoint is available, +build-tools runs the packaged guard self-check before requesting installation +and launch through the authenticated host API. Preview readiness and application +startup both complete before reporting the remote session live. Build-tools keeps +the event relay and egress cleanup without rewriting launchd's dylib list after +serve-sim adds its capability loader. + Failure semantics: a missing library, a failed `launchctl setenv`, or a failed self-check fails the session, since a `--egress local` session without the guard would silently leak. Only an unwritable event log is a warning, because 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 2a94eba8ca..25dd7e79fa 100644 --- a/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts @@ -29,8 +29,8 @@ jest.mock('../../../utils/processes'); jest.mock('../../../utils/turtleFetch'); jest.mock('../../utils/deviceSessionHost'); jest.mock('../../utils/agentDeviceArtifacts'); -// The agent-device session step boots the Simulator itself; this test covers local egress. jest.mock('../startIosSimulator', () => ({ + ...jest.requireActual('../startIosSimulator'), bootIosSimulatorAsync: jest .fn() .mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }), 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 7766a710de..210e0f8f47 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -3,6 +3,7 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from ' import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; +import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; import { getDeviceRunSessionIdOrThrow, @@ -14,6 +15,21 @@ import { import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession'; jest.mock('../../utils/deviceSessionHost'); +jest.mock('../../../utils/IosSimulatorUtils', () => ({ + IosSimulatorUtils: { + resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'), + getAvailableDevicesAsync: jest + .fn() + .mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]), + }, +})); +jest.mock('../installBuild', () => ({ + readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), +})); +jest.mock('../../utils/localEgressGuard', () => ({ + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null), +})); + jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), getDeviceRunSessionIdOrThrow: jest.fn(), @@ -115,7 +131,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { [BuildRuntimePlatform.DARWIN, true], [BuildRuntimePlatform.LINUX, false], ])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => { - await runAsync(runtimePlatform); + await runAsync(runtimePlatform, selectsXcode ? { boot_simulator: { value: true } } : {}); expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0); expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, { @@ -123,6 +139,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { env, logger, timeoutMs: 60_000, + signal: undefined, + ...(selectsXcode + ? { + iosSimulatorUdid: 'SELECTED-IOS-UDID', + installAppPath: undefined, + bootEnv: undefined, + } + : {}), packageVersion: '1.2.3', launchAppIdentifier: undefined, launchArgs: [], @@ -160,6 +184,9 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { 'launch_app_identifier', 'launch_args', 'open_url', + 'boot_simulator', + 'device_identifier', + 'install_app_path', 'network_capture', 'network_capture_fields', 'package_version', @@ -188,6 +215,28 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { ); }); + it('hands the downloaded app and selected device to serve-sim with initial launch options', async () => { + await runAsync(BuildRuntimePlatform.DARWIN, { + device_identifier: { value: 'chosen-device' }, + install_app_path: { value: '/tmp/App.app' }, + launch_args: { value: ['--literal', 'value with spaces'] }, + open_url: { value: 'example://home' }, + }); + expect( + jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0] + ).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]); + expect(startDeviceSessionHostAsync).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + iosSimulatorUdid: 'SELECTED-IOS-UDID', + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--literal', 'value with spaces'], + openUrl: 'example://home', + }) + ); + }); + it('hands network capture to the session host', async () => { await runAsync(BuildRuntimePlatform.DARWIN, { network_capture: { value: true }, diff --git a/packages/build-tools/src/steps/functions/startLocalEgress.ts b/packages/build-tools/src/steps/functions/startLocalEgress.ts index e03cb8321b..5b834f6f86 100644 --- a/packages/build-tools/src/steps/functions/startLocalEgress.ts +++ b/packages/build-tools/src/steps/functions/startLocalEgress.ts @@ -76,11 +76,11 @@ function awaitLocalEgressAcquisitionAsync( /** * Points the device host's system HTTP(S) proxy at the EAS CLI's egress client. - * Must run before `eas/start_ios_simulator`: the simulator reads the system proxy - * at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy - * environment variables inside it for clients that read them (gRPC, libcurl) - * and installs the local egress guard, which refuses connections that ignore - * both. On Linux it starts the Android emulator relay and fence instead; see + * Must run before iOS simulator startup: the simulator reads the system proxy + * at boot. Startup supplies proxy variables for gRPC/libcurl and the guard that + * refuses connections bypassing the proxy. Serve-sim-owned startup passes this + * boot configuration to serve-sim. + * On Linux it starts the Android emulator relay and fence instead; see * androidLocalEgress.ts. The shared session cleanup releases the resources * started here when the session ends, with a job finalizer as a fallback. */ diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index 4a10db2fb6..fc26cd174e 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -7,6 +7,7 @@ import { import { CustomBuildContext } from '../../customBuildContext'; import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { resolveIosSessionStartupAsync } from './startIosSimulator'; import { createNetworkCaptureInputProviders, parseNetworkCaptureInputs, @@ -16,11 +17,11 @@ import { withLocalEgressSession, } from '../utils/localEgressSession'; import { + createIosSessionStartupInputProviders, createServeSimLaunchInputProviders, describeServeSimLaunch, getDeviceRunSessionIdOrThrow, getNgrokTunnelDomainOrThrow, - parseServeSimLaunchInputs, selectXcodeDeveloperDirectoryAsync, waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; @@ -37,6 +38,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction( __metricsId: 'eas/start_serve_sim_remote_session', inputProviders: [ ...createServeSimLaunchInputProviders(), + ...createIosSessionStartupInputProviders(), ...createNetworkCaptureInputProviders(), BuildStepInput.createProvider({ id: 'package_version', @@ -55,14 +57,21 @@ export function createStartWebPreviewRemoteSessionBuildFunction( const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined; const packageVersion = inputs.package_version?.value as string | undefined; const { runtimePlatform } = global; - const launch = parseServeSimLaunchInputs( - { - launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined, - launchArgs: inputs.launch_args?.value, - openUrl: inputs.open_url?.value as string | undefined, - }, - { runtimePlatform } - ); + 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, @@ -77,15 +86,13 @@ export function createStartWebPreviewRemoteSessionBuildFunction( logger.info(launchDescription); } - if (runtimePlatform === BuildRuntimePlatform.DARWIN) { - await selectXcodeDeveloperDirectoryAsync({ env, logger }); - } - const sessionHost = await startDeviceSessionHostAsync(ctx, { + ...iosStartup, runtimePlatform, env, logger, timeoutMs: STARTUP_TIMEOUT_MS, + signal, packageVersion, launchAppIdentifier: launch.launchAppIdentifier, launchArgs: launch.launchArgs, diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index b2da2b4df4..603bc6ea22 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -3,11 +3,17 @@ import type { bunyan } from '@expo/logger'; import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; import * as ngrok from '@ngrok/ngrok'; import { mkdirSync, writeFileSync } from 'node:fs'; -import { access, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { CustomBuildContext } from '../../../customBuildContext'; +import { disableIosSimulatorPushAsync } from '../../functions/startIosSimulator'; +import { verifyLocalEgressGuardAsync } from '../localEgressGuard'; +import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession'; + +import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; +import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts'; @@ -19,7 +25,12 @@ import { ensureMacosPreviewEncoderInstalledAsync, startDeviceRunSessionPreview, } from '../deviceRunSessionPreview'; -import { EXPO_DEVICE_HUB_STATE_DIR, startDeviceSessionHostAsync } from '../deviceSessionHost'; +import { + EXPO_DEVICE_HUB_STATE_DIR, + createServeSimArgs, + startDeviceSessionHostAsync, + waitForWebPreviewReadyAsync, +} from '../deviceSessionHost'; import { ensureFfmpegInstalledOnceAsync, fetchWebPreviewTurnArgsAsync, @@ -28,6 +39,9 @@ import { import * as screenshotCollector from '../deviceRunSessionScreenshots'; jest.mock('@ngrok/ngrok'); +jest.mock('../../functions/startIosSimulator', () => ({ disableIosSimulatorPushAsync: jest.fn() })); +jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() })); +jest.mock('../serveSimActions'); jest.mock('../deviceRunSessionArtifacts'); jest.mock('../../../sentry'); jest.mock('../serveSimMetricsRecorder', () => ({ @@ -111,6 +125,8 @@ const stopSessionPreview = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + jest.mocked(disableIosSimulatorPushAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined); stopSessionPreview.mockResolvedValue(undefined); jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview }); jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); @@ -120,6 +136,15 @@ beforeEach(() => { jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false); jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]); jest.mocked(spawnDetached).mockImplementation(options => { + const port = options.args[options.args.indexOf('--port') + 1]; + const device = options.args[options.args.indexOf('@expo/serve-sim@latest') + 1]; + jest.mocked(readServeSimServersAsync).mockResolvedValue([ + { + udid: device?.startsWith('--') ? 'emulator-5554' : (device ?? 'emulator-5554'), + token: 'preview-token', + url: `http://127.0.0.1:${port}`, + }, + ]); const flag = options.args.indexOf('--android-recording-directory'); if (flag >= 0) { directories.push(options.args[flag + 1]); @@ -156,6 +181,315 @@ afterEach(async () => { ); }); +it('targets the selected Simulator in the serve-sim invocation', () => { + expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: 'session-udid' }).slice(0, 4)).toEqual([ + '@expo/serve-sim@latest', + 'session-udid', + '--port', + '4321', + ]); +}); + +it('merges Simulator boot variables into the inherited serve-sim environment', async () => { + const inheritedEnv = { + ...env, + SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:1111', + HOST_SETTING: 'inherited', + }; + const bootEnv = { + SERVE_SIM_ADDITIONAL_DYLIBS: '/tmp/guard lib.dylib:/tmp/extra.dylib', + SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block', + SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard log', + SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899', + }; + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: inheritedEnv, + logger, + timeoutMs: 10_000, + iosSimulatorUdid: 'emulator-5554', + bootEnv, + }); + const invocation = jest.mocked(spawnDetached).mock.calls[0][0]; + expect(invocation.env).toEqual(expect.objectContaining({ ...inheritedEnv, ...bootEnv })); + expect(invocation.args).not.toEqual(expect.arrayContaining([expect.stringMatching(/^--boot-/)])); + expect(invocation.env).not.toHaveProperty('EAS_EGRESS_GUARD_MODE'); + expect(invocation.env).not.toHaveProperty('EAS_EGRESS_GUARD_LOG'); + expect(invocation.env).not.toHaveProperty('http_proxy'); + expect(invocation.env).not.toHaveProperty('HTTP_PROXY'); + expect(invocation.env).not.toHaveProperty('DYLD_INSERT_LIBRARIES'); + expect(invocation.env).not.toHaveProperty('DYLD_LIBRARY_PATH'); + expect(invocation.env).not.toHaveProperty('SIMCTL_CHILD_DYLD_INSERT_LIBRARIES'); + expect(inheritedEnv).toEqual({ + ...env, + SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:1111', + HOST_SETTING: 'inherited', + }); + await host.finishAsync(); +}); + +it('rejects Simulator boot options for Android before acquiring host resources', async () => { + await expect( + startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.LINUX, + env, + logger, + timeoutMs: 10_000, + iosSimulatorUdid: 'emulator-5554', + bootEnv: { SERVE_SIM_ADDITIONAL_DYLIBS: '/tmp/guard.dylib' }, + }) + ).rejects.toThrow('Simulator boot options require an explicit iOS Simulator'); + expect(fetchWebPreviewTurnArgsAsync).not.toHaveBeenCalled(); + expect(spawnDetached).not.toHaveBeenCalled(); +}); + +it('rejects boot options without an explicit Simulator before acquiring host resources', async () => { + await expect( + startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + bootEnv: { SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block' }, + }) + ).rejects.toThrow('Simulator boot options require an explicit iOS Simulator'); + expect(fetchWebPreviewTurnArgsAsync).not.toHaveBeenCalled(); + expect(spawnDetached).not.toHaveBeenCalled(); +}); + +it('installs and launches through the API for the selected Simulator', async () => { + jest + .mocked(stageServeSimAppAsync) + .mockResolvedValue({ directory: '/staged', path: '/staged/Example.app' }); + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + iosSimulatorUdid: 'emulator-5554', + installAppPath: '/tmp/Example.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--flag'], + openUrl: 'example://screen', + }); + expect(verifyLocalEgressGuardAsync).not.toHaveBeenCalled(); + const args = jest.mocked(spawnDetached).mock.calls[0][0].args; + expect(args).not.toContain('--install-app-path'); + expect(args).not.toContain('--launch-app-identifier'); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'app.install', + params: { udid: 'emulator-5554', path: '/staged/Example.app' }, + }) + ); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'app.launch', + params: { + udid: 'emulator-5554', + bundleId: 'dev.example.app', + launchArgs: ['--flag'], + openUrl: 'example://screen', + }, + }) + ); + expect(args[args.indexOf('@expo/serve-sim@latest') + 1]).toBe('emulator-5554'); + await host.finishAsync(); +}); + +it('preserves startup CLI flags for already-prepared workflows', async () => { + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 10_000, + installAppPath: '/tmp/Example.app', + launchAppIdentifier: 'dev.example.app', + launchArgs: ['--flag', 'literal value'], + openUrl: 'example://screen', + }); + const args = jest.mocked(spawnDetached).mock.calls[0][0].args; + expect(args).toEqual( + expect.arrayContaining([ + '--install-app-path', + '/tmp/Example.app', + '--launch-app-identifier', + 'dev.example.app', + '--launch-arg', + '--flag', + 'literal value', + '--open-url', + 'example://screen', + ]) + ); + expect(args.slice(args.indexOf('--launch-arg'), args.indexOf('--open-url'))).toEqual([ + '--launch-arg', + '--flag', + '--launch-arg', + 'literal value', + ]); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(disableIosSimulatorPushAsync).not.toHaveBeenCalled(); + expect(verifyLocalEgressGuardAsync).not.toHaveBeenCalled(); + await host.finishAsync(); +}); + +it('defaults owned Simulator startup to a long cold-boot budget', async () => { + jest.useFakeTimers(); + const startedAt = Date.now(); + const readyResponse = jest.mocked(turtleFetch).getMockImplementation()!; + jest.mocked(turtleFetch).mockImplementation(async (...args) => { + if (Date.now() - startedAt < 65_000) { + throw Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' }); + } + return await readyResponse(...args); + }); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 60_000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'dev.example.app', + }); + const resolved = expect(starting).resolves.toBeDefined(); + await jest.advanceTimersByTimeAsync(65_000); + await resolved; + const host = await starting; + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'app.launch', + timeoutMs: 30 * 60_000, + }) + ); + await host.finishAsync(); +}); + +it('allows a cold boot to take longer than the normal host startup deadline', async () => { + jest.useFakeTimers(); + const startedAt = Date.now(); + const readyResponse = jest.mocked(turtleFetch).getMockImplementation()!; + jest.mocked(turtleFetch).mockImplementation(async (...args) => { + if (Date.now() - startedAt < 65_000) { + throw Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' }); + } + return await readyResponse(...args); + }); + const waiting = waitForWebPreviewReadyAsync({ + previewServer: { pid: undefined, getOutput: () => '' }, + serverName: 'serve-sim', + port: 4321, + timeoutMs: 60_000, + startupTimeoutMs: 30 * 60_000, + }); + await jest.advanceTimersByTimeAsync(65_000); + await expect(waiting).resolves.toBe('emulator-5554'); +}); + +it('uses the shorter startup deadline after a cold host starts responding', async () => { + jest.useFakeTimers(); + jest.mocked(turtleFetch).mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ status: 'starting' }), + } as Awaited>); + const waiting = waitForWebPreviewReadyAsync({ + previewServer: { pid: undefined, getOutput: () => '' }, + serverName: 'serve-sim', + port: 4321, + timeoutMs: 60_000, + startupTimeoutMs: 30 * 60_000, + }); + const rejected = expect(waiting).rejects.toThrow('HTTP 503'); + await jest.advanceTimersByTimeAsync(60_000); + await rejected; +}); + +it('uses the shorter startup deadline when a listening host never answers readiness', async () => { + jest.useFakeTimers(); + jest + .mocked(turtleFetch) + .mockRejectedValue(Object.assign(new Error('network timeout'), { type: 'request-timeout' })); + const waiting = waitForWebPreviewReadyAsync({ + previewServer: { pid: undefined, getOutput: () => '' }, + serverName: 'serve-sim', + port: 4321, + timeoutMs: 60_000, + startupTimeoutMs: 30 * 60_000, + }); + const rejected = expect(waiting).rejects.toThrow('network timeout'); + await jest.advanceTimersByTimeAsync(60_000); + await rejected; +}); + +it('starts the readiness budget after refusals that exceed the normal startup deadline', async () => { + jest.useFakeTimers(); + const startedAt = Date.now(); + jest.mocked(turtleFetch).mockImplementation(async () => { + if (Date.now() - startedAt < 65_000) { + throw Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' }); + } + throw Object.assign(new Error('network timeout'), { type: 'request-timeout' }); + }); + const waiting = waitForWebPreviewReadyAsync({ + previewServer: { pid: undefined, getOutput: () => '' }, + serverName: 'serve-sim', + port: 4321, + timeoutMs: 60_000, + startupTimeoutMs: 30 * 60_000, + }); + let settled = false; + void waiting.catch(() => { + settled = true; + }); + const rejected = expect(waiting).rejects.toThrow('network timeout'); + await jest.advanceTimersByTimeAsync(65_000 + 59_000); + expect(settled).toBe(false); + await jest.advanceTimersByTimeAsync(1_000); + await rejected; +}); + +it('stops a host that became ready on a different Simulator', async () => { + await expect( + startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + iosSimulatorUdid: 'session-udid', + env, + logger, + timeoutMs: 10_000, + }) + ).rejects.toThrow('became ready on emulator-5554, but this session requested session-udid'); + expect(stopServer).toHaveBeenCalledTimes(1); + expect(ngrok.forward).not.toHaveBeenCalled(); +}); + +it('stops a host when a cold boot is cancelled during readiness polling', async () => { + const controller = new AbortController(); + const cancelled = new Error('session cancelled'); + jest.mocked(turtleFetch).mockImplementationOnce(async (_url, _method, options) => { + expect(options?.signal?.aborted).toBe(false); + controller.abort(cancelled); + expect(options?.signal?.reason).toBe(cancelled); + throw new Error('readiness request aborted'); + }); + await expect( + startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + iosSimulatorUdid: 'session-udid', + env, + logger, + timeoutMs: 30 * 60_000, + signal: controller.signal, + }) + ).rejects.toBe(cancelled); + expect(stopServer).toHaveBeenCalledTimes(1); + expect(ngrok.forward).not.toHaveBeenCalled(); + const directory = + jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY; + await expect(access(directory!)).rejects.toThrow(); +}); + it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => { const host = await startHostAsync(); expect(ngrok.forward).not.toHaveBeenCalled(); @@ -957,3 +1291,403 @@ it('uses the existing step logger for startup and shutdown output', async () => .mock.calls.some(([record]) => typeof record === 'object' && 'marker' in record) ).toBe(false); }); + +it.each([true, false])( + 'waits for setup before launch or boot-only readiness (app: %s)', + async hasApp => { + const setup = deferred(); + const prepared = deferred(); + const ready = deferred>>(); + jest.mocked(disableIosSimulatorPushAsync).mockReturnValue(setup.promise); + await mkdir('/tmp', { recursive: true }); + const directory = await import('node:fs/promises').then(fs => + fs.mkdtemp('/tmp/eas-staged-test-') + ); + directories.push(directory); + jest + .mocked(stageServeSimAppAsync) + .mockResolvedValue({ directory, path: `${directory}/App.app` }); + jest.mocked(spawnDetached).mockImplementation(options => { + expect(options.env.TMPDIR).toBe(env.TMPDIR ?? require('node:os').tmpdir()); + expect(options.env.SERVE_SIM_STATE_DIR).toBe( + env.SERVE_SIM_STATE_DIR ?? require('../serveSimMetricsRecorder').SERVE_SIM_STATE_DIR + ); + expect(options.args).not.toContain('--startup-handoff'); + expect(options.args).not.toContain('--install-app-path'); + expect(options.args).not.toContain('--launch-app-identifier'); + const port = options.args[options.args.indexOf('--port') + 1]; + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([ + { udid: 'emulator-5554', token: 'preview-token', url: `http://127.0.0.1:${port}` }, + ]); + return { + pid: undefined, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }; + }); + jest.mocked(turtleFetch).mockImplementation(() => ready.promise); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + startupTimeoutMs: 120_000, + iosSimulatorUdid: 'emulator-5554', + application: prepared.promise, + }); + while (!jest.mocked(disableIosSimulatorPushAsync).mock.calls.length) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(turtleFetch).toHaveBeenCalled(); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + prepared.resolve( + hasApp + ? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' } + : undefined + ); + if (!hasApp) { + let queried = false; + ready.resolve({ + ok: true, + json: async () => { + queried = true; + return { status: 'ready', device: 'emulator-5554' }; + }, + } as never); + let reported = false; + void starting.then(() => { + reported = true; + }); + while (!queried) { + await new Promise(resolve => setImmediate(resolve)); + } + await new Promise(resolve => setImmediate(resolve)); + expect(reported).toBe(false); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + setup.resolve(); + const host = await starting; + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + await host.finishAsync(); + return; + } + while (!jest.mocked(runServeSimActionAsync).mock.calls.length) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + await new Promise(resolve => setImmediate(resolve)); + expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); + setup.resolve(); + while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect( + jest + .mocked(runServeSimActionAsync) + .mock.calls.map(([options]) => [options.action, options.params]) + ).toEqual([ + ['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }], + [ + 'app.launch', + { + udid: 'emulator-5554', + bundleId: 'dev.example.other', + launchArgs: [], + openUrl: undefined, + }, + ], + ]); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 }) + ); + expect(stageServeSimAppAsync).toHaveBeenCalledWith( + '/tmp/App.app', + env.TMPDIR ?? require('node:os').tmpdir() + ); + ready.resolve({ + ok: true, + json: async () => ({ status: 'ready', device: 'emulator-5554' }), + } as never); + const host = await starting; + await host.finishAsync(); + await expect(access(directory)).rejects.toThrow(); + } +); + +it('stops the host without installing or launching when guard verification fails', async () => { + jest + .mocked(verifyLocalEgressGuardAsync) + .mockRejectedValueOnce(new Error('guard refused startup')); + jest.mocked(spawnDetached).mockImplementation(options => { + const port = options.args[options.args.indexOf('--port') + 1]; + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([ + { udid: 'emulator-5554', token: 'preview-token', url: `http://127.0.0.1:${port}` }, + ]); + return { + pid: undefined, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }; + }); + await expect( + startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + bootEnv: { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }, + }) + ).rejects.toThrow('guard refused startup'); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(stopServer).toHaveBeenCalledTimes(1); +}); + +it.each(['install', 'setup'])('does not launch after %s fails', async failure => { + const setup = deferred(); + if (failure === 'install') { + jest.mocked(runServeSimActionAsync).mockRejectedValueOnce(new Error('install failed')); + } + jest + .mocked(disableIosSimulatorPushAsync) + .mockImplementation(() => + failure === 'setup' ? Promise.reject(new Error('setup failed')) : setup.promise + ); + jest.mocked(spawnDetached).mockImplementation(options => { + const port = options.args[options.args.indexOf('--port') + 1]; + jest + .mocked(readServeSimServersAsync) + .mockResolvedValue([ + { udid: 'emulator-5554', token: 'preview-token', url: `http://127.0.0.1:${port}` }, + ]); + return { + pid: undefined, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }; + }); + await mkdir('/tmp', { recursive: true }); + const directory = await import('node:fs/promises').then(fs => + fs.mkdtemp('/tmp/eas-staged-test-') + ); + directories.push(directory); + jest.mocked(stageServeSimAppAsync).mockResolvedValue({ directory, path: `${directory}/App.app` }); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + }); + const rejected = expect(starting).rejects.toThrow(`${failure} failed`); + if (failure === 'install') { + while (!stopServer.mock.calls.length) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + await access(directory); + let settled = false; + void starting.catch(() => { + settled = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + setup.resolve(); + } + await rejected; + expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); + expect(stopServer).toHaveBeenCalledTimes(1); + await expect(access(directory)).rejects.toThrow(); +}); + +it('verifies the guard before starting setup or installing the app', async () => { + const guard = deferred(); + jest.mocked(verifyLocalEgressGuardAsync).mockReturnValue(guard.promise); + jest + .mocked(stageServeSimAppAsync) + .mockResolvedValue({ directory: '/staged', path: '/staged/App.app' }); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + bootEnv: { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }, + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + }); + while (!jest.mocked(verifyLocalEgressGuardAsync).mock.calls.length) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(verifyLocalEgressGuardAsync).toHaveBeenCalledWith({ + udid: 'emulator-5554', + env, + logger, + }); + expect(disableIosSimulatorPushAsync).not.toHaveBeenCalled(); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + guard.resolve(); + const host = await starting; + expect(disableIosSimulatorPushAsync).toHaveBeenCalledWith({ + udid: 'emulator-5554', + env, + logger: expect.anything(), + signal: expect.any(AbortSignal), + }); + expect(runServeSimActionAsync).toHaveBeenCalledTimes(2); + await host.finishAsync(); +}); + +it('cancels pending setup without launching and observes its late failure', async () => { + const controller = new AbortController(); + let rejectSetup!: (error: Error) => void; + jest.mocked(disableIosSimulatorPushAsync).mockReturnValue( + new Promise((_resolve, reject) => { + rejectSetup = reject; + }) + ); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'dev.example.app', + signal: controller.signal, + }); + const rejected = expect(starting).rejects.toThrow('session canceled'); + while (!jest.mocked(disableIosSimulatorPushAsync).mock.calls.length) { + await new Promise(resolve => setImmediate(resolve)); + } + const setupSignal = jest.mocked(disableIosSimulatorPushAsync).mock.calls[0][0].signal; + expect(setupSignal?.aborted).toBe(false); + controller.abort(new Error('session canceled')); + await rejected; + expect(setupSignal?.aborted).toBe(true); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(stopServer).toHaveBeenCalledTimes(1); + rejectSetup(new Error('late setup failure')); + await new Promise(resolve => setImmediate(resolve)); +}); + +it('cancels a pending guard check before setup or app startup', async () => { + const controller = new AbortController(); + let rejectGuard!: (error: Error) => void; + jest.mocked(verifyLocalEgressGuardAsync).mockReturnValue( + new Promise((_resolve, reject) => { + rejectGuard = reject; + }) + ); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + bootEnv: { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }, + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + signal: controller.signal, + }); + const rejected = expect(starting).rejects.toThrow('session canceled'); + while (!jest.mocked(verifyLocalEgressGuardAsync).mock.calls.length) { + await new Promise(resolve => setImmediate(resolve)); + } + controller.abort(new Error('session canceled')); + await rejected; + expect(disableIosSimulatorPushAsync).not.toHaveBeenCalled(); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(stopServer).toHaveBeenCalledTimes(1); + rejectGuard(new Error('late guard failure')); + await new Promise(resolve => setImmediate(resolve)); +}); + +it('stops startup when setup fails while the app download is still pending', async () => { + const application = deferred(); + jest.mocked(disableIosSimulatorPushAsync).mockRejectedValueOnce(new Error('setup failed')); + await expect( + startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + application: application.promise, + }) + ).rejects.toThrow('setup failed'); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + expect(stopServer).toHaveBeenCalledTimes(1); +}); + +it('reports startup failure before waiting for host cleanup', async () => { + const failure = new Error('guard refused startup'); + const stopping = deferred(); + const onStartupError = jest.fn(); + jest.mocked(verifyLocalEgressGuardAsync).mockRejectedValueOnce(failure); + stopServer.mockImplementationOnce(() => stopping.promise); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + bootEnv: { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }, + onStartupError, + }); + const rejected = expect(starting).rejects.toBe(failure); + while (!stopServer.mock.calls.length) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(onStartupError).toHaveBeenCalledTimes(1); + expect(onStartupError).toHaveBeenCalledWith(failure); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + stopping.resolve(); + await rejected; +}); + +it('cancels an in-flight install when parallel Simulator setup fails', async () => { + const failure = new Error('setup failed'); + let rejectSetup!: (error: Error) => void; + const setup = new Promise((_resolve, reject) => { + rejectSetup = reject; + }); + jest.mocked(disableIosSimulatorPushAsync).mockReturnValue(setup); + jest + .mocked(stageServeSimAppAsync) + .mockResolvedValue({ directory: '/staged', path: '/staged/App.app' }); + let installSignal: AbortSignal | undefined; + jest.mocked(runServeSimActionAsync).mockImplementationOnce(async options => { + installSignal = options.signal; + await new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true, + }); + }); + }); + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'dev.example.app', + }); + const rejected = expect(starting).rejects.toBe(failure); + while (!jest.mocked(runServeSimActionAsync).mock.calls.length) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(installSignal?.aborted).toBe(false); + rejectSetup(failure); + await rejected; + expect(installSignal?.aborted).toBe(true); + expect(installSignal?.reason).toBe(failure); + expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); + expect(stopServer).toHaveBeenCalledTimes(1); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 3981fc5a9e..50624e79e2 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -53,6 +53,10 @@ import { import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields'; jest.mock('@ngrok/ngrok'); +jest.mock('../localEgress', () => ({ + ...jest.requireActual('../localEgress'), + readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null), +})); jest.mock('node:timers'); jest.mock('node:timers/promises'); jest.mock('../../../utils/turtleFetch'); @@ -723,6 +727,8 @@ describe(waitForWebPreviewReadyAsync, () => { .mocked(turtleFetch) .mockRejectedValueOnce(new Error('not ready')) .mockResolvedValueOnce({ + ok: true, + status: 200, json: async () => ({ status: 'ready', device: 'DEVICE-A' }), } as unknown as Awaited>); @@ -949,6 +955,8 @@ describe(startDeviceSessionHostAsync, () => { } as unknown as Awaited>; } return { + ok: true, + status: 200, json: async () => ({ status: 'ready', device: 'device-id' }), } as unknown as Awaited>; }); diff --git a/packages/build-tools/src/steps/utils/__tests__/startupTasks.test.ts b/packages/build-tools/src/steps/utils/__tests__/startupTasks.test.ts index 70cde3a32c..3470bf0525 100644 --- a/packages/build-tools/src/steps/utils/__tests__/startupTasks.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/startupTasks.test.ts @@ -88,4 +88,23 @@ describe(createStartupTasks, () => { createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok')) ).resolves.toBe('ok'); }); + it.each([false, true])( + 'inherits parent cancellation, already aborted=%s', + async alreadyAborted => { + const parent = new AbortController(); + const reason = new Error('cancelled'); + if (alreadyAborted) { + parent.abort(reason); + } + const tasks = createStartupTasks(createLoggerMock(), parent.signal); + const waiting = tasks.untilAborted(new Promise(() => {})); + if (!alreadyAborted) { + parent.abort(reason); + } + await expect(waiting).rejects.toBe(reason); + expect(tasks.signal.reason).toBe(reason); + tasks.abort(new Error('later failure')); + expect(tasks.signal.reason).toBe(reason); + } + ); }); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index bfff1fa886..7246aed0aa 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -1,5 +1,6 @@ import { BuildPhaseResult, SystemError, UserError } from '@expo/eas-build-job'; import type { bunyan } from '@expo/logger'; +import { asyncResult } from '@expo/results'; import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; @@ -29,6 +30,7 @@ import { import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots'; import { type DetachedProcessHandle, + type ServeSimApplicationOptions, type ServeSimLaunchOptions, ensureFfmpegInstalledOnceAsync, fetchWebPreviewTurnArgsAsync, @@ -46,6 +48,11 @@ import { } from './IosSimulatorRecordingUtils'; import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; import { startLogPhase } from '../../utils/logPhase'; +import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions'; +import { disableIosSimulatorPushAsync } from '../functions/startIosSimulator'; +import { verifyLocalEgressGuardAsync } from './localEgressGuard'; +import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; +import { createStartupTasks } from './startupTasks'; const WEB_PREVIEW_HOST = '127.0.0.1'; const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim'; @@ -111,6 +118,8 @@ export function createServeSimArgs({ websiteArgs = [], shareUrl, packageVersion, + iosSimulatorUdid, + installAppPath, launchAppIdentifier, launchArgs = [], openUrl, @@ -122,11 +131,14 @@ export function createServeSimArgs({ websiteArgs?: string[]; shareUrl?: string; packageVersion?: string; + iosSimulatorUdid?: string; + installAppPath?: string; networkCapture?: boolean; networkCaptureFields?: string[]; } & ServeSimLaunchOptions): string[] { return [ createServeSimPackageSpec(packageVersion), + ...(iosSimulatorUdid ? [iosSimulatorUdid] : []), '--port', String(port), '--host', @@ -148,6 +160,7 @@ export function createServeSimArgs({ ...websiteArgs, ...(shareUrl ? ['--share-url', shareUrl] : []), ...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []), + ...(installAppPath ? ['--install-app-path', installAppPath] : []), ...launchArgs.flatMap(argument => ['--launch-arg', argument]), ...(openUrl ? ['--open-url', openUrl] : []), // `--network-capture` also covers an already booted simulator. Fields are repeated, not @@ -213,16 +226,23 @@ export async function waitForWebPreviewReadyAsync({ serverName, port, timeoutMs, + startupTimeoutMs, + signal, }: { previewServer: Pick; serverName: string; port: number; timeoutMs: number; + startupTimeoutMs?: number; + signal?: AbortSignal; }): Promise { const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`; - const deadline = Date.now() + timeoutMs; + let deadline = Date.now() + (startupTimeoutMs ?? timeoutMs); + // Preserve the boot budget until the host starts answering. + let waitingForHost = startupTimeoutMs !== undefined; let lastError: unknown; while (Date.now() < deadline) { + signal?.throwIfAborted(); if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) { throw new SystemError( `${serverName} exited before becoming ready. Last output:\n${ @@ -234,14 +254,31 @@ export async function waitForWebPreviewReadyAsync({ const response = await turtleFetch(readyUrl, 'GET', { retries: 0, timeout: 2_000, + shouldThrowOnNotOk: false, + signal, }); + if (!response.ok) { + throw new SystemError(`${serverName} readiness returned HTTP ${response.status}`); + } const ready = WebPreviewReadyResponseSchema.parse(await response.json()); + signal?.throwIfAborted(); return ready.device; } catch (error) { + const connectionRefused = + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ECONNREFUSED'; + if (waitingForHost && !connectionRefused) { + waitingForHost = false; + deadline = Math.min(deadline, Date.now() + timeoutMs); + } lastError = error; } + signal?.throwIfAborted(); await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS); } + signal?.throwIfAborted(); throw new SystemError( `Timed out waiting for ${serverName} readiness at ${readyUrl}${ lastError instanceof Error ? `: ${lastError.message}` : '' @@ -301,7 +338,14 @@ type DeviceSessionHostOptions = { logger: bunyan; timeoutMs: number; separateLogPhase?: boolean; + startupTimeoutMs?: number; + signal?: AbortSignal; packageVersion?: string; + iosSimulatorUdid?: string; + installAppPath?: string; + bootEnv?: Record; + application?: Promise; + onStartupError?: (error: unknown) => void; networkCapture?: boolean; networkCaptureFields?: string[]; } & ServeSimLaunchOptions; @@ -338,17 +382,34 @@ async function startDeviceSessionHostInternalAsync( env, logger, timeoutMs, + startupTimeoutMs, + signal, packageVersion, + iosSimulatorUdid, + installAppPath, + application, + onStartupError, launchAppIdentifier, launchArgs, openUrl, networkCapture = false, networkCaptureFields = [], + bootEnv = {}, }: DeviceSessionHostOptions, onFinished: (successful: boolean) => void, artifactLogger: bunyan ): Promise { + signal?.throwIfAborted(); const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX; + if ( + Object.keys(bootEnv).length > 0 && + (runtimePlatform !== BuildRuntimePlatform.DARWIN || !iosSimulatorUdid) + ) { + throw new UserError( + 'EAS_IOS_SIMULATOR_BOOT_INVALID_INPUT', + 'Simulator boot options require an explicit iOS Simulator.' + ); + } // Unreachable from the step functions, which reject a non-Darwin launch while parsing. // Kept because this function is exported and expo-device-hub cannot launch. if (isAndroid && networkCapture) { @@ -363,6 +424,9 @@ async function startDeviceSessionHostInternalAsync( `Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.` ); } + if (application && (isAndroid || !iosSimulatorUdid)) { + throw new SystemError('Deferred startup requires an explicit iOS Simulator.'); + } if (isAndroid) { await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }); } @@ -374,6 +438,10 @@ async function startDeviceSessionHostInternalAsync( env, } : null; + startupTimeoutMs ??= iosSimulatorUdid ? 30 * 60_000 : undefined; + const startup = createStartupTasks(logger, signal); + const serveSimTmpdir = env.TMPDIR ?? os.tmpdir(); + const serveSimStateDir = env.SERVE_SIM_STATE_DIR ?? SERVE_SIM_STATE_DIR; const subdomainId = randomBytes(16).toString('hex'); const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId); const port = await findAvailablePortAsync(); @@ -396,11 +464,13 @@ async function startDeviceSessionHostInternalAsync( port, turnArgs, packageVersion, + iosSimulatorUdid, websiteArgs: websiteOriginArgs(env), shareUrl: previewPageUrl, - launchAppIdentifier, - launchArgs, - openUrl, + installAppPath: iosSimulatorUdid ? undefined : installAppPath, + launchAppIdentifier: iosSimulatorUdid ? undefined : launchAppIdentifier, + launchArgs: iosSimulatorUdid ? [] : launchArgs, + openUrl: iosSimulatorUdid ? undefined : openUrl, networkCapture, networkCaptureFields, }) @@ -419,11 +489,14 @@ async function startDeviceSessionHostInternalAsync( }); let previewServer: DetachedProcessHandle; try { + signal?.throwIfAborted(); previewServer = spawnDetached({ command: previewExec.command, args: previewExec.args, env: { ...env, + ...bootEnv, + ...(isAndroid ? {} : { TMPDIR: serveSimTmpdir, SERVE_SIM_STATE_DIR: serveSimStateDir }), EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory, ...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}), // The Hub would resolve its state directory from the step's env, which may set its own @@ -443,6 +516,12 @@ async function startDeviceSessionHostInternalAsync( throw error; } + let stagedAppDirectory: string | undefined; + let beforeLaunch: ReturnType> | undefined; + const readinessController = new AbortController(); + const readinessSignal = signal + ? AbortSignal.any([signal, readinessController.signal]) + : readinessController.signal; let previewToken: string | undefined; let previewTask: Promise | null = null; let finishTask: Promise | null = null; @@ -508,6 +587,7 @@ async function startDeviceSessionHostInternalAsync( return opening; }, finishAsync() { + readinessController.abort(new Error('Simulator preview ended.')); return (finishTask ??= finishDeviceSessionHostAsync(ctx, { previewTask, stopSessionPreviewAsync: async () => { @@ -524,25 +604,105 @@ async function startDeviceSessionHostInternalAsync( logger, artifactLogger, onStopped: successful => onFinished(successful && !previewFailed), - }).catch(err => { - artifactLogger.warn({ err }, 'Could not finish the simulator preview.'); - onFinished(false); - })); + }) + .finally(async () => { + await beforeLaunch; + if (stagedAppDirectory) { + await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true }); + } + }) + .catch(err => { + artifactLogger.warn({ err }, 'Could not finish the simulator preview.'); + onFinished(false); + })); }, }; try { + const readiness = asyncResult( + waitForWebPreviewReadyAsync({ + previewServer, + serverName, + port, + timeoutMs, + startupTimeoutMs, + signal: readinessSignal, + }) + ); + if (iosSimulatorUdid) { + previewToken = await waitForServeSimControlAsync({ + device: iosSimulatorUdid, + port, + previewServer, + stateDir: serveSimStateDir, + timeoutMs: startupTimeoutMs ?? timeoutMs, + signal, + }); + secrets.push(previewToken); + if (Object.keys(bootEnv).length > 0) { + await startup.untilAborted( + verifyLocalEgressGuardAsync({ udid: iosSimulatorUdid as IosSimulatorUuid, env, logger }) + ); + } + signal?.throwIfAborted(); + beforeLaunch = asyncResult( + startup.untilAborted( + startup.run('Simulator setup', taskLogger => + disableIosSimulatorPushAsync({ + udid: iosSimulatorUdid as IosSimulatorUuid, + env, + logger: taskLogger, + signal: startup.signal, + }) + ) + ) + ); + const app = (application ? await startup.untilAborted(application) : undefined) ?? { + installAppPath, + launchAppIdentifier, + launchArgs, + openUrl, + }; + signal?.throwIfAborted(); + const action = (name: string, params: Record) => + runServeSimActionAsync({ + port, + token: previewToken!, + action: name, + params, + timeoutMs: startupTimeoutMs ?? timeoutMs, + signal: startup.signal, + }); + if (app.installAppPath) { + const staged = await stageServeSimAppAsync(app.installAppPath, serveSimTmpdir); + stagedAppDirectory = staged.directory; + signal?.throwIfAborted(); + await action('app.install', { udid: iosSimulatorUdid, path: staged.path }); + } + (await beforeLaunch)?.enforceValue(); + if (app.launchAppIdentifier) { + signal?.throwIfAborted(); + await action('app.launch', { + udid: iosSimulatorUdid, + bundleId: app.launchAppIdentifier, + launchArgs: app.launchArgs ?? [], + openUrl: app.openUrl, + }); + } + } logger.info(`Waiting for ${serverName} to become ready.`); - const device = await waitForWebPreviewReadyAsync({ - previewServer, - serverName, - port, - timeoutMs, - }); + const device = (await readiness).enforceValue(); + if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) { + throw new SystemError( + `serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.` + ); + } hostReady = true; // Both servers record their session token in a state file once they are ready. - previewToken = isAndroid - ? await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR) - : await readServeSimPreviewTokenAsync(device); + if (isAndroid) { + previewToken = await readExpoDeviceHubPreviewTokenAsync(port, EXPO_DEVICE_HUB_STATE_DIR); + } else { + previewToken ??= await readServeSimPreviewTokenAsync(device, serveSimStateDir); + } if (!previewToken) { throw new SystemError( `${serverName} became ready but wrote no session token for device ${device}. The ` + @@ -584,13 +744,56 @@ async function startDeviceSessionHostInternalAsync( logger.warn({ err }, 'Could not start refreshing the session preview.'); } }); + signal?.throwIfAborted(); return host; } catch (error) { + onStartupError?.(error); await host.finishAsync(); throw error; } } +async function waitForServeSimControlAsync({ + device, + port, + stateDir, + previewServer, + timeoutMs, + signal, +}: { + device: string; + port: number; + stateDir: string; + previewServer: DetachedProcessHandle; + timeoutMs: number; + signal?: AbortSignal; +}): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + signal?.throwIfAborted(); + if ( + previewServer.getExitError() !== undefined || + (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) + ) { + throw new SystemError( + `serve-sim exited before its control API became ready. ${previewServer.getOutput()}` + ); + } + const servers = await readServeSimServersAsync(stateDir); + const server = servers.find( + server => + server.udid.toLowerCase() === device.toLowerCase() && + server.url === `http://${WEB_PREVIEW_HOST}:${port}` + ); + if (server?.token) { + return server.token; + } + await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS); + } + signal?.throwIfAborted(); + throw new SystemError('Timed out waiting for serve-sim control readiness.'); +} + async function finishDeviceSessionHostAsync( ctx: CustomBuildContext, { diff --git a/packages/build-tools/src/steps/utils/startupTasks.ts b/packages/build-tools/src/steps/utils/startupTasks.ts index 10481a6d4f..7624231ad7 100644 --- a/packages/build-tools/src/steps/utils/startupTasks.ts +++ b/packages/build-tools/src/steps/utils/startupTasks.ts @@ -30,17 +30,20 @@ export type StartupTasks = { summary(): string; }; -export function createStartupTasks(logger: bunyan): StartupTasks { +export function createStartupTasks(logger: bunyan, parentSignal?: AbortSignal): StartupTasks { const startedAt = Date.now(); const finished: { name: string; ms: number }[] = []; const controller = new AbortController(); + const signal = parentSignal + ? AbortSignal.any([parentSignal, controller.signal]) + : controller.signal; const abort = (reason: unknown): void => { if (!controller.signal.aborted) { controller.abort(reason); } }; return { - signal: controller.signal, + signal, run(name: string, fn: (taskLogger: bunyan) => Promise): Promise { const taskStartedAt = Date.now(); logger.info(`Starting ${name}.`); @@ -61,7 +64,6 @@ export function createStartupTasks(logger: bunyan): StartupTasks { return promise; }, untilAborted(promise: Promise): Promise { - const { signal } = controller; const result = new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason); From fcc5bc169076efc49fd3660f74d429da37ec6039 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 11:16:27 -0700 Subject: [PATCH 2/4] [build-tools] Keep web-preview startup inside the session host --- .../startWebPreviewRemoteSession.test.ts | 3 +- .../functions/startWebPreviewRemoteSession.ts | 2 +- .../utils/__tests__/deviceSessionHost.test.ts | 109 ++++++++++-------- .../src/steps/utils/deviceSessionHost.ts | 23 ++-- 4 files changed, 80 insertions(+), 57 deletions(-) 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 210e0f8f47..f2c3cdc7c0 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -23,7 +23,8 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({ .mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]), }, })); -jest.mock('../installBuild', () => ({ +jest.mock('../../utils/serveSimActions', () => ({ + ...jest.requireActual('../../utils/serveSimActions'), readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), })); jest.mock('../../utils/localEgressGuard', () => ({ diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index fc26cd174e..af79b88e87 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -7,7 +7,7 @@ import { import { CustomBuildContext } from '../../customBuildContext'; import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; -import { resolveIosSessionStartupAsync } from './startIosSimulator'; +import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { createNetworkCaptureInputProviders, parseNetworkCaptureInputs, diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index 603bc6ea22..7ac4ffb929 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -8,7 +8,7 @@ import os from 'node:os'; import path from 'node:path'; import type { CustomBuildContext } from '../../../customBuildContext'; -import { disableIosSimulatorPushAsync } from '../../functions/startIosSimulator'; +import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils'; import { verifyLocalEgressGuardAsync } from '../localEgressGuard'; import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession'; @@ -39,7 +39,9 @@ import { import * as screenshotCollector from '../deviceRunSessionScreenshots'; jest.mock('@ngrok/ngrok'); -jest.mock('../../functions/startIosSimulator', () => ({ disableIosSimulatorPushAsync: jest.fn() })); +jest.mock('../../../utils/IosSimulatorUtils', () => ({ + IosSimulatorUtils: { disableApsdAsync: jest.fn(), waitForReadyAsync: jest.fn() }, +})); jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() })); jest.mock('../serveSimActions'); jest.mock('../deviceRunSessionArtifacts'); @@ -125,7 +127,7 @@ const stopSessionPreview = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - jest.mocked(disableIosSimulatorPushAsync).mockReset().mockResolvedValue(undefined); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined); jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined); stopSessionPreview.mockResolvedValue(undefined); jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview }); @@ -330,7 +332,7 @@ it('preserves startup CLI flags for already-prepared workflows', async () => { 'literal value', ]); expect(runServeSimActionAsync).not.toHaveBeenCalled(); - expect(disableIosSimulatorPushAsync).not.toHaveBeenCalled(); + expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled(); expect(verifyLocalEgressGuardAsync).not.toHaveBeenCalled(); await host.finishAsync(); }); @@ -1298,7 +1300,7 @@ it.each([true, false])( const setup = deferred(); const prepared = deferred(); const ready = deferred>>(); - jest.mocked(disableIosSimulatorPushAsync).mockReturnValue(setup.promise); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(setup.promise); await mkdir('/tmp', { recursive: true }); const directory = await import('node:fs/promises').then(fs => fs.mkdtemp('/tmp/eas-staged-test-') @@ -1338,7 +1340,7 @@ it.each([true, false])( iosSimulatorUdid: 'emulator-5554', application: prepared.promise, }); - while (!jest.mocked(disableIosSimulatorPushAsync).mock.calls.length) { + while (!jest.mocked(IosSimulatorUtils.disableApsdAsync).mock.calls.length) { await new Promise(resolve => setTimeout(resolve, 10)); } expect(turtleFetch).toHaveBeenCalled(); @@ -1447,16 +1449,10 @@ it('stops the host without installing or launching when guard verification fails expect(stopServer).toHaveBeenCalledTimes(1); }); -it.each(['install', 'setup'])('does not launch after %s fails', async failure => { +it('does not launch after installation fails', async () => { const setup = deferred(); - if (failure === 'install') { - jest.mocked(runServeSimActionAsync).mockRejectedValueOnce(new Error('install failed')); - } - jest - .mocked(disableIosSimulatorPushAsync) - .mockImplementation(() => - failure === 'setup' ? Promise.reject(new Error('setup failed')) : setup.promise - ); + jest.mocked(runServeSimActionAsync).mockRejectedValueOnce(new Error('install failed')); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(setup.promise); jest.mocked(spawnDetached).mockImplementation(options => { const port = options.args[options.args.indexOf('--port') + 1]; jest @@ -1486,20 +1482,18 @@ it.each(['install', 'setup'])('does not launch after %s fails', async failure => installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.app', }); - const rejected = expect(starting).rejects.toThrow(`${failure} failed`); - if (failure === 'install') { - while (!stopServer.mock.calls.length) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - await access(directory); - let settled = false; - void starting.catch(() => { - settled = true; - }); - await new Promise(resolve => setImmediate(resolve)); - expect(settled).toBe(false); - setup.resolve(); + const rejected = expect(starting).rejects.toThrow('install failed'); + while (!stopServer.mock.calls.length) { + await new Promise(resolve => setTimeout(resolve, 10)); } + await access(directory); + let settled = false; + void starting.catch(() => { + settled = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + setup.resolve(); await rejected; expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); expect(stopServer).toHaveBeenCalledTimes(1); @@ -1530,24 +1524,44 @@ it('verifies the guard before starting setup or installing the app', async () => env, logger, }); - expect(disableIosSimulatorPushAsync).not.toHaveBeenCalled(); + expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled(); expect(runServeSimActionAsync).not.toHaveBeenCalled(); guard.resolve(); const host = await starting; - expect(disableIosSimulatorPushAsync).toHaveBeenCalledWith({ + expect(IosSimulatorUtils.disableApsdAsync).toHaveBeenCalledWith({ udid: 'emulator-5554', env, - logger: expect.anything(), - signal: expect.any(AbortSignal), }); expect(runServeSimActionAsync).toHaveBeenCalledTimes(2); await host.finishAsync(); }); +it('warns and continues launching when disabling push fails', async () => { + const error = new Error('apsd disable failed'); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockRejectedValueOnce(error); + const host = await startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 2_000, + iosSimulatorUdid: 'emulator-5554', + launchAppIdentifier: 'dev.example.app', + }); + expect(logger.warn).toHaveBeenCalledWith( + { err: error }, + 'Failed to disable apsd in the Simulator.' + ); + expect(runServeSimActionAsync).toHaveBeenCalledWith( + expect.objectContaining({ action: 'app.launch' }) + ); + expect(IosSimulatorUtils.waitForReadyAsync).not.toHaveBeenCalled(); + await host.finishAsync(); +}); + it('cancels pending setup without launching and observes its late failure', async () => { const controller = new AbortController(); let rejectSetup!: (error: Error) => void; - jest.mocked(disableIosSimulatorPushAsync).mockReturnValue( + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue( new Promise((_resolve, reject) => { rejectSetup = reject; }) @@ -1562,14 +1576,11 @@ it('cancels pending setup without launching and observes its late failure', asyn signal: controller.signal, }); const rejected = expect(starting).rejects.toThrow('session canceled'); - while (!jest.mocked(disableIosSimulatorPushAsync).mock.calls.length) { + while (!jest.mocked(IosSimulatorUtils.disableApsdAsync).mock.calls.length) { await new Promise(resolve => setImmediate(resolve)); } - const setupSignal = jest.mocked(disableIosSimulatorPushAsync).mock.calls[0][0].signal; - expect(setupSignal?.aborted).toBe(false); controller.abort(new Error('session canceled')); await rejected; - expect(setupSignal?.aborted).toBe(true); expect(runServeSimActionAsync).not.toHaveBeenCalled(); expect(stopServer).toHaveBeenCalledTimes(1); rejectSetup(new Error('late setup failure')); @@ -1601,16 +1612,19 @@ it('cancels a pending guard check before setup or app startup', async () => { } controller.abort(new Error('session canceled')); await rejected; - expect(disableIosSimulatorPushAsync).not.toHaveBeenCalled(); + expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled(); expect(runServeSimActionAsync).not.toHaveBeenCalled(); expect(stopServer).toHaveBeenCalledTimes(1); rejectGuard(new Error('late guard failure')); await new Promise(resolve => setImmediate(resolve)); }); -it('stops startup when setup fails while the app download is still pending', async () => { +it('cancels setup while the app download is still pending', async () => { + const controller = new AbortController(); const application = deferred(); - jest.mocked(disableIosSimulatorPushAsync).mockRejectedValueOnce(new Error('setup failed')); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockImplementationOnce(async () => { + controller.abort(new Error('session canceled')); + }); await expect( startDeviceSessionHostAsync(ctx, { runtimePlatform: BuildRuntimePlatform.DARWIN, @@ -1619,8 +1633,9 @@ it('stops startup when setup fails while the app download is still pending', asy timeoutMs: 2_000, iosSimulatorUdid: 'emulator-5554', application: application.promise, + signal: controller.signal, }) - ).rejects.toThrow('setup failed'); + ).rejects.toThrow('session canceled'); expect(runServeSimActionAsync).not.toHaveBeenCalled(); expect(stopServer).toHaveBeenCalledTimes(1); }); @@ -1651,13 +1666,14 @@ it('reports startup failure before waiting for host cleanup', async () => { await rejected; }); -it('cancels an in-flight install when parallel Simulator setup fails', async () => { - const failure = new Error('setup failed'); +it('cancels an in-flight install while Simulator setup is pending', async () => { + const controller = new AbortController(); + const failure = new Error('session canceled'); let rejectSetup!: (error: Error) => void; const setup = new Promise((_resolve, reject) => { rejectSetup = reject; }); - jest.mocked(disableIosSimulatorPushAsync).mockReturnValue(setup); + jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(setup); jest .mocked(stageServeSimAppAsync) .mockResolvedValue({ directory: '/staged', path: '/staged/App.app' }); @@ -1678,14 +1694,17 @@ it('cancels an in-flight install when parallel Simulator setup fails', async () iosSimulatorUdid: 'emulator-5554', installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.app', + signal: controller.signal, }); const rejected = expect(starting).rejects.toBe(failure); while (!jest.mocked(runServeSimActionAsync).mock.calls.length) { await new Promise(resolve => setImmediate(resolve)); } expect(installSignal?.aborted).toBe(false); - rejectSetup(failure); + controller.abort(failure); await rejected; + rejectSetup(new Error('late setup failure')); + await new Promise(resolve => setImmediate(resolve)); expect(installSignal?.aborted).toBe(true); expect(installSignal?.reason).toBe(failure); expect(runServeSimActionAsync).toHaveBeenCalledTimes(1); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 7246aed0aa..4688489434 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -49,9 +49,8 @@ import { import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder'; import { startLogPhase } from '../../utils/logPhase'; import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions'; -import { disableIosSimulatorPushAsync } from '../functions/startIosSimulator'; import { verifyLocalEgressGuardAsync } from './localEgressGuard'; -import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; +import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils'; import { createStartupTasks } from './startupTasks'; const WEB_PREVIEW_HOST = '127.0.0.1'; @@ -646,14 +645,18 @@ async function startDeviceSessionHostInternalAsync( signal?.throwIfAborted(); beforeLaunch = asyncResult( startup.untilAborted( - startup.run('Simulator setup', taskLogger => - disableIosSimulatorPushAsync({ - udid: iosSimulatorUdid as IosSimulatorUuid, - env, - logger: taskLogger, - signal: startup.signal, - }) - ) + startup.run('Simulator setup', async taskLogger => { + try { + await IosSimulatorUtils.disableApsdAsync({ + udid: iosSimulatorUdid as IosSimulatorUuid, + env, + }); + } catch (err) { + startup.signal.throwIfAborted(); + taskLogger.warn({ err }, 'Failed to disable apsd in the Simulator.'); + } + startup.signal.throwIfAborted(); + }) ) ); const app = (application ? await startup.untilAborted(application) : undefined) ?? { From e93e33acc9fa1e8e3b0068f3a3acee4619c14e0d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 11:26:14 -0700 Subject: [PATCH 3/4] [build-tools] Mock app metadata at its own boundary --- .../functions/__tests__/startWebPreviewRemoteSession.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f2c3cdc7c0..ba68417dc3 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -23,8 +23,8 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({ .mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]), }, })); -jest.mock('../../utils/serveSimActions', () => ({ - ...jest.requireActual('../../utils/serveSimActions'), +jest.mock('../../utils/iosAppArtifact', () => ({ + ...jest.requireActual('../../utils/iosAppArtifact'), readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'), })); jest.mock('../../utils/localEgressGuard', () => ({ From c1588a3354f9a6a6e16657d757701d6bff3a29b6 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sat, 10 Oct 2026 11:49:18 -0700 Subject: [PATCH 4/4] [build-tools] Stop startup when preview readiness fails --- .changeset/serve-sim-pr-4601.md | 5 ++ .../utils/__tests__/deviceSessionHost.test.ts | 48 +++++++++++++++++++ .../src/steps/utils/deviceSessionHost.ts | 9 ++-- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 .changeset/serve-sim-pr-4601.md diff --git a/.changeset/serve-sim-pr-4601.md b/.changeset/serve-sim-pr-4601.md new file mode 100644 index 0000000000..5463f4a377 --- /dev/null +++ b/.changeset/serve-sim-pr-4601.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': minor +--- + +Let serve-sim start the iOS Simulator for web-preview sessions. diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts index 7ac4ffb929..fa0b8e90ac 100644 --- a/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/deviceSessionHost.test.ts @@ -1666,6 +1666,54 @@ it('reports startup failure before waiting for host cleanup', async () => { await rejected; }); +it.each(['control state', 'app download'])( + 'stops owned startup when readiness fails while waiting for %s', + async waitingFor => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + const controller = new AbortController(); + const application = deferred(); + const onStartupError = jest.fn(); + jest.mocked(turtleFetch).mockResolvedValue({ ok: false, status: 503 } as never); + if (waitingFor === 'control state') { + jest.mocked(spawnDetached).mockReturnValueOnce({ + pid: undefined, + getOutput: () => '', + getExitError: () => undefined, + stopAsync: stopServer, + }); + jest.mocked(readServeSimServersAsync).mockResolvedValue([]); + } + const starting = startDeviceSessionHostAsync(ctx, { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env, + logger, + timeoutMs: 1_000, + iosSimulatorUdid: 'emulator-5554', + application: waitingFor === 'app download' ? application.promise : undefined, + signal: controller.signal, + onStartupError, + }); + const outcome = starting.catch(error => error); + try { + while (!jest.mocked(turtleFetch).mock.calls.length) { + await new Promise(resolve => setImmediate(resolve)); + } + await jest.advanceTimersByTimeAsync(2_000); + expect(onStartupError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('readiness returned HTTP 503'), + }) + ); + expect(stopServer).toHaveBeenCalledTimes(1); + expect(runServeSimActionAsync).not.toHaveBeenCalled(); + } finally { + controller.abort(new Error('test cleanup')); + await jest.advanceTimersByTimeAsync(1_000); + await outcome; + } + } +); + it('cancels an in-flight install while Simulator setup is pending', async () => { const controller = new AbortController(); const failure = new Error('session canceled'); diff --git a/packages/build-tools/src/steps/utils/deviceSessionHost.ts b/packages/build-tools/src/steps/utils/deviceSessionHost.ts index 4688489434..078589d05d 100644 --- a/packages/build-tools/src/steps/utils/deviceSessionHost.ts +++ b/packages/build-tools/src/steps/utils/deviceSessionHost.ts @@ -518,9 +518,7 @@ async function startDeviceSessionHostInternalAsync( let stagedAppDirectory: string | undefined; let beforeLaunch: ReturnType> | undefined; const readinessController = new AbortController(); - const readinessSignal = signal - ? AbortSignal.any([signal, readinessController.signal]) - : readinessController.signal; + const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]); let previewToken: string | undefined; let previewTask: Promise | null = null; let finishTask: Promise | null = null; @@ -625,6 +623,9 @@ async function startDeviceSessionHostInternalAsync( timeoutMs, startupTimeoutMs, signal: readinessSignal, + }).catch(error => { + startup.abort(error); + throw error; }) ); if (iosSimulatorUdid) { @@ -634,7 +635,7 @@ async function startDeviceSessionHostInternalAsync( previewServer, stateDir: serveSimStateDir, timeoutMs: startupTimeoutMs ?? timeoutMs, - signal, + signal: readinessSignal, }); secrets.push(previewToken); if (Object.keys(bootEnv).length > 0) {