From d9ce8c908fd0f4d20c90854c2e872ff7751f56d7 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 16 Oct 2026 17:15:14 -0700 Subject: [PATCH 1/3] [build-tools] Observe agent-device Stop requests during startup --- .../agent-device-stop-during-startup.md | 5 + .../deviceSessionHost-orchestration.test.ts | 27 +++- .../remoteSessionLocalEgress.test.ts | 56 +++++--- ...tDeviceRemoteSession-orchestration.test.ts | 78 +++++++++- .../__tests__/startAgentDeviceSession.test.ts | 133 +++++++++++++++++- .../startAgentDeviceRemoteSession.ts | 9 +- .../functions/startAgentDeviceSession.ts | 42 +++++- .../__tests__/remoteDeviceRunSession.test.ts | 80 +++++++++++ .../src/steps/utils/localEgressSession.ts | 1 + .../src/steps/utils/remoteDeviceRunSession.ts | 15 +- 10 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 .changeset/agent-device-stop-during-startup.md diff --git a/.changeset/agent-device-stop-during-startup.md b/.changeset/agent-device-stop-during-startup.md new file mode 100644 index 0000000000..29c96437a7 --- /dev/null +++ b/.changeset/agent-device-stop-during-startup.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Stop an agent-device session that is still starting up when it is stopped. diff --git a/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts index 5d091a6426..ccab9b131d 100644 --- a/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/deviceSessionHost-orchestration.test.ts @@ -44,6 +44,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({ waitForFileAsync: jest.fn(), })); +// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor +// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock. +const lifetimeWait = jest.fn< + ReturnType, + Parameters +>(); +function routeStartupWatch(): void { + lifetimeWait.mockReset(); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => { + if ('idleTimeout' in args || 'maxDurationSeconds' in args) { + return await lifetimeWait(args); + } + if (!args.signal?.aborted) { + await new Promise(resolve => + args.signal?.addEventListener('abort', () => resolve(), { once: true }) + ); + } + }); +} + const finishHost = jest.fn(); const closePreview = jest.fn(); const stopTool = jest.fn(); @@ -54,6 +74,7 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh beforeEach(() => { jest.clearAllMocks(); + routeStartupWatch(); // The daemon package is external. No installation or daemon process runs in these tests. jest.spyOn(fs, 'existsSync').mockReturnValue(true); // Only the emulator logcat folder: the Appium step writes into its own temp folder. @@ -99,7 +120,7 @@ beforeEach(() => { jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events); jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events); jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined); - jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); + lifetimeWait.mockResolvedValue(undefined); }); afterEach(() => { @@ -157,7 +178,7 @@ describe.each([ expect(finishHost).toHaveBeenCalledTimes(1); expect(closePreview).not.toHaveBeenCalled(); expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan( - jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0] + lifetimeWait.mock.invocationCallOrder[0] ); expect(stopTool).toHaveBeenCalledTimes(1); expect(stopEvents).toHaveBeenCalledTimes(1); @@ -302,7 +323,7 @@ describe.each([ } else if (phase === 'config') { jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error); } else { - jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error); + lifetimeWait.mockRejectedValueOnce(error); } await expect(runAsync()).rejects.toBe(error); expect(finishHost).toHaveBeenCalledTimes(1); 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 62fb88fc23..583bc46ddc 100644 --- a/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/remoteSessionLocalEgress.test.ts @@ -82,6 +82,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({ waitForDeviceRunSessionStoppedAsync: jest.fn(), })); +// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor +// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock. +const lifetimeWait = jest.fn< + ReturnType, + Parameters +>(); +function routeStartupWatch(): void { + lifetimeWait.mockReset(); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => { + if ('idleTimeout' in args || 'maxDurationSeconds' in args) { + return await lifetimeWait(args); + } + if (!args.signal?.aborted) { + await new Promise(resolve => + args.signal?.addEventListener('abort', () => resolve(), { once: true }) + ); + } + }); +} + const logger = { info: jest.fn(), warn: jest.fn(), @@ -124,6 +144,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller } beforeEach(() => { jest.clearAllMocks(); + routeStartupWatch(); lifetime = registerLocalEgressResources(stopEgress); stopEgress.mockResolvedValue(undefined); stopPreview.mockResolvedValue(undefined); @@ -166,7 +187,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller finishAsync: stopPreview, }); jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined); - jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => { + lifetimeWait.mockImplementation(async () => { expect(lifetime.aborted).toBe(false); expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1); }); @@ -178,16 +199,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller it('reports credentials and starts monitoring before waiting, then releases resources', async () => { await run(); - expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({ - ctx, - deviceRunSessionId: 'session-id', - logger, - remoteConfig: expect.objectContaining({ - ...fields, - [controllerField]: expect.any(String), - [controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret', - }), - }); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ + ctx, + deviceRunSessionId: 'session-id', + logger, + remoteConfig: expect.objectContaining({ + ...fields, + [controllerField]: expect.any(String), + [controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret', + }), + }) + ); expect(monitorLocalEgressAsync).toHaveBeenCalledWith({ port: handoff.port, env, @@ -201,7 +224,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller await stopLocalEgressResourcesAsync(logger); stopEgress.mockClear(); jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null); - jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); + lifetimeWait.mockResolvedValue(undefined); await run(); const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0]; expect(remoteConfig).toHaveProperty(controllerField); @@ -220,7 +243,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller } else if (phase === 'report') { jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error); } else if (phase === 'wait') { - jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error); + lifetimeWait.mockRejectedValue(error); } else { stopPreview.mockRejectedValue(error); } @@ -234,11 +257,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller ); it('passes cancellation to the monitor and releases the lifetime', async () => { const abort = new AbortController(); - jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort()); + lifetimeWait.mockImplementation(async () => abort.abort()); await run(abort.signal); - expect(monitorLocalEgressAsync).toHaveBeenCalledWith( - expect.objectContaining({ signal: abort.signal }) - ); + // Agent-device hands the monitor its startup signal, which follows the step signal. + expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true); expect(lifetime.aborted).toBe(true); expect(stopEgress).toHaveBeenCalledTimes(1); }); 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 318b17ca6a..23868affcc 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 @@ -9,6 +9,7 @@ import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceA import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents'; import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost'; import { + DeviceRunSessionStoppedError, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, getNgrokTunnelDomainOrThrow, @@ -337,7 +338,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { type Device = Parameters[1]['device']; function startSession( device: Device | ((tasks: StartupTasks) => Device), - signal?: AbortSignal + signal?: AbortSignal, + onStarted?: () => Promise ) { const logger = { info: jest.fn(), @@ -365,6 +367,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { capture: NO_CAPTURE, tasks, device: sessionDevice, + onStarted, }); } @@ -640,6 +643,79 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => { expect(startDeviceSessionHostAsync).not.toHaveBeenCalled(); }); + it('drains resources and preserves the explicit startup Stop reason', async () => { + const stopped = new DeviceRunSessionStoppedError('stopped during startup'); + let tasks!: StartupTasks; + const session = startSession(startup => { + tasks = startup; + return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) }; + }); + session.catch(() => {}); + await flushAsync(); + tasks.abort(stopped); + await expect(session).rejects.toBe(stopped); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1); + expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1); + }); + + it('does not hide a teardown failure after an explicit startup Stop', async () => { + const stopped = new DeviceRunSessionStoppedError('stopped during startup'); + const failed = new Error('daemon teardown failed'); + let tasks!: StartupTasks; + const session = startSession(startup => { + tasks = startup; + return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) }; + }); + session.catch(() => {}); + await flushAsync(); + mockDaemonStopAsync.mockRejectedValueOnce(failed); + tasks.abort(stopped); + await expect(session).rejects.toBe(failed); + }); + + it('retires startup monitoring after reporting and before lifetime waiting', async () => { + const retired = deferred(); + const retiring = jest.fn(() => retired.promise); + const session = startSession( + { booted: Promise.resolve(), ready: Promise.resolve() }, + undefined, + retiring + ); + await flushAsync(); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1); + expect(retiring).toHaveBeenCalledTimes(1); + expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled(); + retired.resolve(); + await session; + expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1); + }); + + it('aborts a pending ready report when startup is stopped', async () => { + const stopped = new DeviceRunSessionStoppedError('stopped during startup'); + let tasks!: StartupTasks; + jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation( + async ({ signal }) => + new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(new Error('request aborted')), { + once: true, + }); + }) + ); + const session = startSession(startup => { + tasks = startup; + return { booted: Promise.resolve(), ready: Promise.resolve() }; + }); + session.catch(() => {}); + await flushAsync(); + expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith( + expect.objectContaining({ signal: tasks.signal }) + ); + tasks.abort(stopped); + await expect(session).rejects.toBe(stopped); + expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled(); + }); + 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 }); 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 15d01dd6c8..6fde18df79 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts @@ -11,7 +11,10 @@ import { resolveLocalEgressServeSimBootEnvironmentAsync, startLocalEgressGuardRelayAsync, } from '../../utils/localEgressGuard'; -import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession'; +import { + selectXcodeDeveloperDirectoryAsync, + waitForDeviceRunSessionStoppedAsync, +} from '../../utils/remoteDeviceRunSession'; import { downloadBuildAsync } from '../downloadBuild'; import { installBuildAsync } from '../installBuild'; import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact'; @@ -36,6 +39,7 @@ jest.mock('../../../utils/IosSimulatorUtils'); jest.mock('../../utils/remoteDeviceRunSession', () => ({ ...jest.requireActual('../../utils/remoteDeviceRunSession'), selectXcodeDeveloperDirectoryAsync: jest.fn(), + waitForDeviceRunSessionStoppedAsync: jest.fn(), })); jest.mock('../startIosSimulator', () => ({ ...jest.requireActual('../startIosSimulator'), @@ -157,6 +161,13 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { .mocked(IosSimulatorUtils.getAvailableDevicesAsync) .mockResolvedValue([{ name: 'iPhone 17', udid: 'SELECTED-UDID' }] as never); jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async ({ signal }) => { + if (!signal?.aborted) { + await new Promise(resolve => + signal?.addEventListener('abort', () => resolve(), { once: true }) + ); + } + }); jest .mocked(runAgentDeviceRemoteSessionAsync) .mockImplementation(async (_ctx, { device, tasks }) => { @@ -309,6 +320,33 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expect(stopLocalEgressResourcesAsync).toHaveBeenCalledTimes(1); }); + it('treats Stop during guarded device shutdown as normal cancellation', async () => { + const stopped = deferred(); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise); + 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).mockImplementation( + (_command, _args, options) => + new Promise((_resolve, reject) => { + options!.signal!.addEventListener('abort', () => reject(new Error('shutdown aborted')), { + once: true, + }); + }) as ReturnType + ); + const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }); + step.catch(() => {}); + await flushAsync(); + expect(spawn).toHaveBeenCalled(); + stopped.resolve(); + await expect(step).resolves.toBeUndefined(); + expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + expect(startLocalEgressGuardRelayAsync).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({ @@ -539,6 +577,99 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); + it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])( + 'observes Stop during a held download on %s and drains startup normally', + async runtimePlatform => { + const download = mockStalledDownload(); + const stopped = deferred(); + const controller = new AbortController(); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise); + const step = runStep(runtimePlatform, { build_id: 'build-id' }, controller.signal); + step.catch(() => {}); + try { + await flushAsync(); + expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + expect.objectContaining({ + deviceRunSessionId: sessionEnv.deviceRunSessionId, + signal: expect.any(AbortSignal), + }) + ); + stopped.resolve(); + await expect(step).resolves.toBeUndefined(); + expect(download.aborted()).toBe(true); + expect(download.settled()).toBe(true); + expect(installBuildAsync).not.toHaveBeenCalled(); + expect(launchApplicationAsync).not.toHaveBeenCalled(); + expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled(); + } finally { + controller.abort(new Error('test cleanup')); + stopped.resolve(); + await step.catch(() => {}); + } + } + ); + + it('waits for an in-flight Android boot after Stop without installing or launching', async () => { + const boot = deferred>>(); + const stopped = deferred(); + jest.mocked(startAndroidEmulatorAsync).mockReturnValue(boot.promise); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise); + let settled = false; + const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id' }); + void step + .finally(() => { + settled = true; + }) + .catch(() => {}); + try { + await flushAsync(); + expect(startAndroidEmulatorAsync).toHaveBeenCalled(); + stopped.resolve(); + await flushAsync(); + expect(settled).toBe(false); + } finally { + boot.resolve({} as Awaited>); + stopped.resolve(); + await step; + } + expect(installBuildAsync).not.toHaveBeenCalled(); + expect(launchApplicationAsync).not.toHaveBeenCalled(); + }); + + it('retires the startup watch without treating its abort as user Stop', async () => { + jest + .mocked(runAgentDeviceRemoteSessionAsync) + .mockImplementation(async (_ctx, { onStarted, tasks }) => { + await onStarted!(); + expect(tasks.signal.aborted).toBe(false); + }); + await expect(runStep(BuildRuntimePlatform.DARWIN)).resolves.toBeUndefined(); + const options = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0]; + expect(options.maxDurationSeconds).toBeUndefined(); + expect(options.idleTimeout).toBeUndefined(); + expect(options.signal!.aborted).toBe(true); + }); + + it('preserves a download failure that wins before Stop', async () => { + const failed = new Error('download failed first'); + const stopped = deferred(); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockReturnValue(stopped.promise); + jest.mocked(downloadBuildAsync).mockRejectedValue(failed); + const step = runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' }); + step.catch(() => {}); + await flushAsync(); + stopped.resolve(); + await expect(step).rejects.toBe(failed); + }); + + it('fails when the startup status watch rejects', async () => { + const failed = new Error('session errored'); + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(failed); + await expect(runStep(BuildRuntimePlatform.DARWIN)).rejects.toBe(failed); + 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(); diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index f56585cf3d..62b50f5e24 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -23,6 +23,7 @@ import { type ServeSimApplicationOptions } from '../utils/remoteDeviceRunSession import { type StartupTasks } from '../utils/startupTasks'; import { type DetachedProcessHandle, + DeviceRunSessionStoppedError, finishRemoteSessionAsync, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, @@ -98,6 +99,7 @@ export async function runAgentDeviceRemoteSessionAsync( frameAncestors, corsOrigins, tasks, + onStarted, device, }: { env: BuildStepEnv; @@ -114,6 +116,7 @@ export async function runAgentDeviceRemoteSessionAsync( frameAncestors?: string[]; corsOrigins?: string[]; tasks: StartupTasks; + onStarted?: () => Promise; device: | { booted: Promise; @@ -228,7 +231,7 @@ export async function runAgentDeviceRemoteSessionAsync( await uploadRemoteSessionConfigWithLocalEgressAsync({ env, - signal, + signal: tasks.signal, ctx, deviceRunSessionId, remoteConfig: { @@ -240,6 +243,7 @@ export async function runAgentDeviceRemoteSessionAsync( }, logger, }); + await onStarted?.(); tasks.signal.throwIfAborted(); void pollAgentDeviceArtifactsForUploadAsync(ctx, { deviceRunSessionId, @@ -271,6 +275,9 @@ export async function runAgentDeviceRemoteSessionAsync( : undefined, }); } catch (error) { + if (tasks.signal.reason instanceof DeviceRunSessionStoppedError) { + throw tasks.signal.reason; + } sessionFailed = true; throw error; } finally { diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index a2cc2535de..fc95f1fdc4 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -18,6 +18,7 @@ import { import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi'; import { withLocalEgressSession } from '../utils/localEgressSession'; import { + DeviceRunSessionStoppedError, createCorsOriginsInputProvider, createFrameAncestorsInputProvider, createPrivatePreviewUrlInputProvider, @@ -26,6 +27,7 @@ import { parseCorsOriginsInput, parseFrameAncestorsInput, selectXcodeDeveloperDirectoryAsync, + waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; import { createNetworkCaptureInputProviders, @@ -175,8 +177,29 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte ); const tasks = createStartupTasks(logger, signal); + tasks.signal.throwIfAborted(); + const stoppedDuringStartup = new DeviceRunSessionStoppedError( + `Device run session ${sessionEnv.deviceRunSessionId} was stopped during startup.` + ); + const startupWatchDone = new AbortController(); + const startupWatchSignal = AbortSignal.any([tasks.signal, startupWatchDone.signal]); + const startupWatch = waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId: sessionEnv.deviceRunSessionId, + logger, + signal: startupWatchSignal, + }).then( + () => { + if (!startupWatchSignal.aborted) { + tasks.abort(stoppedDuringStartup); + } + }, + error => tasks.abort(error) + ); let downloaded: Promise | undefined; + let booted: Promise | undefined; let applicationReady: Promise | undefined; + let sessionOwnsTeardown = false; try { if (isIos) { @@ -199,7 +222,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte logger.info(`Selected iOS Simulator: ${iosSimulatorUdid}.`); } - const booted = iosSimulatorUdid + booted = iosSimulatorUdid ? undefined : tasks.run('Android Emulator boot', async taskLogger => { const logcatDirectory = await fs.promises.mkdtemp( @@ -274,6 +297,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte }) : undefined; + sessionOwnsTeardown = true; await runAgentDeviceRemoteSessionAsync(ctx, { env, logger, @@ -290,6 +314,11 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), tasks, + onStarted: async () => { + startupWatchDone.abort(); + await startupWatch; + tasks.signal.throwIfAborted(); + }, device: iosSimulatorUdid ? { ...iosStartup, @@ -301,9 +330,18 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte ready: applicationReady ?? tasks.untilAborted(booted!), }, }); + } catch (error) { + if ( + error !== stoppedDuringStartup && + (sessionOwnsTeardown || tasks.signal.reason !== stoppedDuringStartup) + ) { + throw error; + } + logger.info(stoppedDuringStartup.message); } finally { + startupWatchDone.abort(); tasks.abort(new Error('Agent-device session ended.')); - await Promise.allSettled([downloaded, applicationReady]); + await Promise.allSettled([startupWatch, booted, downloaded, applicationReady]); } }), }); 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 cec59e9ec7..298325925a 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1827,6 +1827,33 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout); }); + it('does not warn when retiring a watch aborts its pending status request', async () => { + const controller = new AbortController(); + const logger = createLoggerMock(); + const ctx = createCtxMock(); + ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token'; + jest.mocked(turtleFetch).mockImplementation( + async (_url, _method, options) => + new Promise((_resolve, reject) => { + options!.signal!.addEventListener('abort', () => reject(new Error('request aborted')), { + once: true, + }); + }) + ); + const waiting = waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId: 'drs-id', + logger, + signal: controller.signal, + }); + + controller.abort(); + await waiting; + + expect(logger.warn).not.toHaveBeenCalled(); + expect(Sentry.capture).not.toHaveBeenCalled(); + }); + it('logs and retries transient polling errors', async () => { const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]); const logger = createLoggerMock(); @@ -2008,6 +2035,59 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { }); }); +describe('remote configuration cancellation', () => { + beforeEach(() => jest.mocked(turtleFetch).mockReset()); + + it('does not report readiness for an already stopped startup', async () => { + const controller = new AbortController(); + const stopped = new Error('startup stopped'); + controller.abort(stopped); + + await expect( + remoteDeviceRunSession.uploadRemoteSessionConfigAsync({ + ctx: createCtxMock(), + deviceRunSessionId: 'drs-id', + remoteConfig: {}, + logger: createLoggerMock(), + signal: controller.signal, + }) + ).rejects.toBe(stopped); + expect(turtleFetch).not.toHaveBeenCalled(); + }); + + it('forwards cancellation to the in-flight readiness HTTP request', async () => { + const controller = new AbortController(); + const ctx = createCtxMock(); + ctx.env.EAS_DEVICE_RUN_SESSION_TOKEN = 'worker-token'; + const stopped = new Error('session cancelled'); + let requestAborted = false; + jest.mocked(turtleFetch).mockImplementation( + async (_url, _method, options) => + new Promise((_resolve, reject) => { + options!.signal!.addEventListener( + 'abort', + () => { + requestAborted = true; + reject(new Error('ready request aborted')); + }, + { once: true } + ); + }) + ); + const reporting = remoteDeviceRunSession.uploadRemoteSessionConfigAsync({ + ctx, + deviceRunSessionId: 'drs-id', + remoteConfig: {}, + logger: createLoggerMock(), + signal: controller.signal, + }); + controller.abort(stopped); + + await expect(reporting).rejects.toBe(stopped); + expect(requestAborted).toBe(true); + }); +}); + describe(ensureFfmpegInstalledOnceAsync, () => { const spawnMock = jest.mocked(spawn); let binDirectory: string; diff --git a/packages/build-tools/src/steps/utils/localEgressSession.ts b/packages/build-tools/src/steps/utils/localEgressSession.ts index 9e77357dc1..f91760e03c 100644 --- a/packages/build-tools/src/steps/utils/localEgressSession.ts +++ b/packages/build-tools/src/steps/utils/localEgressSession.ts @@ -37,6 +37,7 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({ signal?.throwIfAborted(); await uploadRemoteSessionConfigAsync({ ...options, + signal, remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) }, }); if (localEgress && !signal?.aborted) { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index f39f9336c3..bebeaac1c1 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -128,6 +128,8 @@ export type DeviceRunSessionIdleTimeout = { getLastPreviewActivityAt?: () => Date | undefined; }; +export class DeviceRunSessionStoppedError extends Error {} + export async function waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, @@ -161,9 +163,7 @@ export async function waitForDeviceRunSessionStoppedAsync({ }, maxDurationSeconds * 1_000); try { - logger.info( - `Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.` - ); + logger.info(`Polling device run session ${deviceRunSessionId} until it is stopped.`); if (durationTimeout !== undefined) { logger.info( `The device run session will stop automatically after ${maxDurationSeconds} seconds.` @@ -207,6 +207,9 @@ export async function waitForDeviceRunSessionStoppedAsync({ throw new SystemError(`Device run session ${deviceRunSessionId} errored.`); } } catch (err) { + if (signal.aborted) { + return; + } if (err instanceof SystemError) { throw err; } @@ -547,19 +550,23 @@ export async function uploadRemoteSessionConfigAsync({ deviceRunSessionId, remoteConfig, logger, + signal, }: { ctx: CustomBuildContext; deviceRunSessionId: string; remoteConfig: Record; logger: bunyan; + signal?: AbortSignal; }): Promise { + signal?.throwIfAborted(); logger.info( `Reporting remote config to the API server (device run session: ${deviceRunSessionId}).` ); const workerApi = requireDeviceRunSessionWorkerApi(ctx); try { - await workerApi.startAsync({ remoteConfig }); + await workerApi.startAsync({ remoteConfig }, { signal }); } catch (err) { + signal?.throwIfAborted(); throw new SystemError( `Failed to start device run session ${deviceRunSessionId}: ${ err instanceof Error ? err.message : String(err) From 7a43ac73777c6c5daa679ca9a5678cb8e55d6476 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 16 Oct 2026 18:24:21 -0700 Subject: [PATCH 2/3] [build-tools] Preserve errored session status at the duration deadline --- .../__tests__/remoteDeviceRunSession.test.ts | 17 +++++++++++++++++ .../src/steps/utils/remoteDeviceRunSession.ts | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) 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 298325925a..1a96916871 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1809,6 +1809,23 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout); }); + it('preserves an errored status when the duration timer fires during the response', async () => { + const ctx = createStatusCtxMock([]); + jest.mocked(turtleFetch).mockImplementationOnce(async () => { + (jest.mocked(setTimeoutCallback).mock.calls[0][0] as () => void)(); + return workerApiResponse({ id: 'drs-id', status: 'errored' }); + }); + await expect( + waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId: 'drs-id', + logger: createLoggerMock(), + maxDurationSeconds: 30, + }) + ).rejects.toThrow('Device run session drs-id errored.'); + expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout); + }); + it('clears the duration timeout when the build step is aborted', async () => { const ctx = createStatusCtxMock([{ status: 'in-progress' }]); const abortController = new AbortController(); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index bebeaac1c1..1d9edb72dd 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -207,12 +207,12 @@ export async function waitForDeviceRunSessionStoppedAsync({ throw new SystemError(`Device run session ${deviceRunSessionId} errored.`); } } catch (err) { - if (signal.aborted) { - return; - } if (err instanceof SystemError) { throw err; } + if (signal.aborted) { + return; + } const error = err instanceof Error ? err : new Error(String(err)); pollErrorCount += 1; From bf60bcadfd8e8c3371ddd813926c581a5730a682 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 20 Oct 2026 13:08:52 -0700 Subject: [PATCH 3/3] [build-tools] Share the startup Stop watch Co-Authored-By: Claude Opus 5.5 --- .../functions/startAgentDeviceSession.ts | 41 +++---- .../__tests__/deviceRunSessionStartup.test.ts | 102 ++++++++++++++++++ .../steps/utils/deviceRunSessionStartup.ts | 69 ++++++++++++ 3 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 packages/build-tools/src/steps/utils/__tests__/deviceRunSessionStartup.test.ts create mode 100644 packages/build-tools/src/steps/utils/deviceRunSessionStartup.ts diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index fc95f1fdc4..67a012f216 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -15,10 +15,10 @@ import { AndroidEmulatorUtils, AndroidVirtualDeviceName, } from '../../utils/AndroidEmulatorUtils'; +import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup'; import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi'; import { withLocalEgressSession } from '../utils/localEgressSession'; import { - DeviceRunSessionStoppedError, createCorsOriginsInputProvider, createFrameAncestorsInputProvider, createPrivatePreviewUrlInputProvider, @@ -27,7 +27,6 @@ import { parseCorsOriginsInput, parseFrameAncestorsInput, selectXcodeDeveloperDirectoryAsync, - waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; import { createNetworkCaptureInputProviders, @@ -178,24 +177,12 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte const tasks = createStartupTasks(logger, signal); tasks.signal.throwIfAborted(); - const stoppedDuringStartup = new DeviceRunSessionStoppedError( - `Device run session ${sessionEnv.deviceRunSessionId} was stopped during startup.` - ); - const startupWatchDone = new AbortController(); - const startupWatchSignal = AbortSignal.any([tasks.signal, startupWatchDone.signal]); - const startupWatch = waitForDeviceRunSessionStoppedAsync({ + const startupWatch = watchDeviceRunSessionStartup({ ctx, deviceRunSessionId: sessionEnv.deviceRunSessionId, logger, - signal: startupWatchSignal, - }).then( - () => { - if (!startupWatchSignal.aborted) { - tasks.abort(stoppedDuringStartup); - } - }, - error => tasks.abort(error) - ); + startup: tasks, + }); let downloaded: Promise | undefined; let booted: Promise | undefined; let applicationReady: Promise | undefined; @@ -314,11 +301,7 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), tasks, - onStarted: async () => { - startupWatchDone.abort(); - await startupWatch; - tasks.signal.throwIfAborted(); - }, + onStarted: () => startupWatch.completeAsync(), device: iosSimulatorUdid ? { ...iosStartup, @@ -331,17 +314,17 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte }, }); } catch (error) { - if ( - error !== stoppedDuringStartup && - (sessionOwnsTeardown || tasks.signal.reason !== stoppedDuringStartup) - ) { + if (error !== startupWatch.stopped && (sessionOwnsTeardown || !startupWatch.isStopped())) { throw error; } - logger.info(stoppedDuringStartup.message); } finally { - startupWatchDone.abort(); tasks.abort(new Error('Agent-device session ended.')); - await Promise.allSettled([startupWatch, booted, downloaded, applicationReady]); + await Promise.allSettled([ + startupWatch.finishAsync(), + booted, + downloaded, + applicationReady, + ]); } }), }); diff --git a/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionStartup.test.ts b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionStartup.test.ts new file mode 100644 index 0000000000..eea9d32408 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/deviceRunSessionStartup.test.ts @@ -0,0 +1,102 @@ +import { bunyan } from '@expo/logger'; + +import { CustomBuildContext } from '../../../customBuildContext'; +import { watchDeviceRunSessionStartup } from '../deviceRunSessionStartup'; +import { + DeviceRunSessionStoppedError, + waitForDeviceRunSessionStoppedAsync, +} from '../remoteDeviceRunSession'; +import { createStartupTasks } from '../startupTasks'; + +jest.mock('../remoteDeviceRunSession', () => ({ + ...jest.requireActual('../remoteDeviceRunSession'), + waitForDeviceRunSessionStoppedAsync: jest.fn(), +})); + +const ctx = {} as CustomBuildContext; +const logger = { + info: jest.fn(), + warn: jest.fn(), + child: jest.fn().mockReturnThis(), +} as unknown as bunyan; + +/** Makes the status poll settle only when the test says so, or end when its signal aborts. */ +function holdStatusPoll(): { stop: () => void; fail: (error: Error) => void } { + let stop!: () => void; + let fail!: (error: Error) => void; + jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation( + ({ signal }) => + new Promise((resolve, reject) => { + stop = resolve; + fail = reject; + signal?.addEventListener('abort', () => resolve(), { once: true }); + }) + ); + return { stop: () => stop(), fail: error => fail(error) }; +} + +function watch(startup = createStartupTasks(logger)) { + return { + startup, + watch: watchDeviceRunSessionStartup({ ctx, deviceRunSessionId: 'drs-id', logger, startup }), + }; +} + +describe(watchDeviceRunSessionStartup, () => { + beforeEach(() => jest.clearAllMocks()); + + it('aborts startup when the session is stopped', async () => { + const poll = holdStatusPoll(); + const { startup, watch: startupWatch } = watch(); + poll.stop(); + await new Promise(resolve => setImmediate(resolve)); + expect(startupWatch.stopped).toBeInstanceOf(DeviceRunSessionStoppedError); + expect(startup.signal.reason).toBe(startupWatch.stopped); + expect(startupWatch.isStopped()).toBe(true); + expect(logger.info).toHaveBeenCalledWith( + 'Device run session drs-id was stopped during startup.' + ); + await expect(startupWatch.completeAsync()).rejects.toBe(startupWatch.stopped); + }); + + it('ends the poll when startup completes, without aborting startup', async () => { + holdStatusPoll(); + const { startup, watch: startupWatch } = watch(); + await expect(startupWatch.completeAsync()).resolves.toBeUndefined(); + const [{ signal }] = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0]; + expect(signal?.aborted).toBe(true); + expect(startup.signal.aborted).toBe(false); + expect(startupWatch.isStopped()).toBe(false); + }); + + it('aborts startup with a status poll failure', async () => { + const poll = holdStatusPoll(); + const { startup, watch: startupWatch } = watch(); + const failed = new Error('status poll failed'); + poll.fail(failed); + await new Promise(resolve => setImmediate(resolve)); + expect(startup.signal.reason).toBe(failed); + expect(startupWatch.isStopped()).toBe(false); + }); + + it('keeps the first abort reason when startup ends for another reason', async () => { + holdStatusPoll(); + const { startup, watch: startupWatch } = watch(); + const failed = new Error('host startup failed'); + startup.abort(failed); + await startupWatch.finishAsync(); + expect(startup.signal.reason).toBe(failed); + expect(startupWatch.isStopped()).toBe(false); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('follows the step signal', async () => { + holdStatusPoll(); + const step = new AbortController(); + const { startup, watch: startupWatch } = watch(createStartupTasks(logger, step.signal)); + step.abort(new Error('step cancelled')); + await startupWatch.finishAsync(); + expect(startup.signal.aborted).toBe(true); + expect(startupWatch.isStopped()).toBe(false); + }); +}); diff --git a/packages/build-tools/src/steps/utils/deviceRunSessionStartup.ts b/packages/build-tools/src/steps/utils/deviceRunSessionStartup.ts new file mode 100644 index 0000000000..cd1b8ea86a --- /dev/null +++ b/packages/build-tools/src/steps/utils/deviceRunSessionStartup.ts @@ -0,0 +1,69 @@ +import { type bunyan } from '@expo/logger'; + +import { type CustomBuildContext } from '../../customBuildContext'; +import { + DeviceRunSessionStoppedError, + waitForDeviceRunSessionStoppedAsync, +} from './remoteDeviceRunSession'; +import { type StartupTasks } from './startupTasks'; + +export type DeviceRunSessionStartupWatch = { + /** The startup abort reason when a Stop request arrives during startup. */ + readonly stopped: DeviceRunSessionStoppedError; + /** Whether a Stop request aborted startup. */ + isStopped(): boolean; + /** Ends the watch after the session reports ready. Throws if startup was aborted meanwhile. */ + completeAsync(): Promise; + /** Ends the watch without throwing, for cleanup. Safe to call more than once. */ + finishAsync(): Promise; +}; + +/** + * Polls for a Stop request while the session starts up, and aborts startup with `stopped` when + * one arrives. A poll failure aborts startup with that error. Call `completeAsync` before the + * lifetime wait, so only one poll runs at a time, and `finishAsync` in cleanup. + */ +export function watchDeviceRunSessionStartup({ + ctx, + deviceRunSessionId, + logger, + startup, +}: { + ctx: CustomBuildContext; + deviceRunSessionId: string; + logger: bunyan; + startup: Pick; +}): DeviceRunSessionStartupWatch { + const stopped = new DeviceRunSessionStoppedError( + `Device run session ${deviceRunSessionId} was stopped during startup.` + ); + const done = new AbortController(); + const watchSignal = AbortSignal.any([startup.signal, done.signal]); + const watching = waitForDeviceRunSessionStoppedAsync({ + ctx, + deviceRunSessionId, + logger, + signal: watchSignal, + }).then( + () => { + if (!watchSignal.aborted) { + logger.info(stopped.message); + startup.abort(stopped); + } + }, + error => startup.abort(error) + ); + const finishAsync = async (): Promise => { + done.abort(); + await watching; + }; + return { + stopped, + isStopped: () => startup.signal.reason === stopped, + completeAsync: async () => { + await finishAsync(); + startup.signal.throwIfAborted(); + }, + finishAsync, + }; +}