diff --git a/.changeset/web-preview-stop-during-startup.md b/.changeset/web-preview-stop-during-startup.md new file mode 100644 index 0000000000..60fb2b16b4 --- /dev/null +++ b/.changeset/web-preview-stop-during-startup.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Stop a web-preview session that is still starting up when it is stopped. 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 3894b05f03..8668d189bf 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startWebPreviewRemoteSession.test.ts @@ -12,6 +12,8 @@ import { uploadRemoteSessionConfigAsync, waitForDeviceRunSessionStoppedAsync, } from '../../utils/remoteDeviceRunSession'; +import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup'; +import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch'; import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession'; jest.mock('../../utils/deviceSessionHost'); @@ -39,6 +41,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({ uploadRemoteSessionConfigAsync: jest.fn(), waitForDeviceRunSessionStoppedAsync: jest.fn(), })); +jest.mock('../../utils/deviceRunSessionStartup', () => ({ + watchDeviceRunSessionStartup: jest.fn(), +})); const ctx = {} as CustomBuildContext; const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv; @@ -48,7 +53,8 @@ const stopAsync = jest.fn(); async function runAsync( runtimePlatform: BuildRuntimePlatform, - launchInputs: Record = {} + launchInputs: Record = {}, + signal?: AbortSignal ): Promise { const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx); await buildFunction.fn!( @@ -63,6 +69,7 @@ async function runAsync( ...launchInputs, }, outputs: {}, + signal, env, } as never ); @@ -86,9 +93,80 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { }); jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined); jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined); + jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock); stopAsync.mockResolvedValue(undefined); }); + it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])( + 'handles Stop while the %s host starts', + async runtimePlatform => { + let hostEntered!: () => void; + const entered = new Promise(resolve => { + hostEntered = resolve; + }); + jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => { + hostEntered(); + return new Promise((_resolve, reject) => + signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true }) + ); + }); + const running = runAsync(runtimePlatform); + running.catch(() => {}); + await entered; + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + watch.stop(); + await expect(running).resolves.toBeUndefined(); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled(); + expect(watch.finishAsync).toHaveBeenCalled(); + } + ); + + it('keeps a teardown failure visible after Stop during preview setup', async () => { + let previewEntered!: () => void; + let finishPreview!: (value: { + previewPageUrl: string; + apiUrl: string; + closeAsync: () => Promise; + }) => void; + const entered = new Promise(resolve => { + previewEntered = resolve; + }); + jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({ + openPreviewAsync: () => { + previewEntered(); + return new Promise(resolve => { + finishPreview = resolve; + }); + }, + finishAsync: stopAsync, + getLastPreviewActivityAt: jest.fn(), + }); + const failed = new Error('host teardown failed'); + stopAsync.mockRejectedValueOnce(failed); + const running = runAsync(BuildRuntimePlatform.LINUX); + running.catch(() => {}); + await entered; + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + watch.stop(); + finishPreview({ + previewPageUrl: 'https://preview.test', + apiUrl: 'https://server.test', + closeAsync: async () => {}, + }); + await expect(running).rejects.toBe(failed); + expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled(); + expect(stopAsync).toHaveBeenCalledTimes(1); + }); + + it('retires the startup watcher before lifetime waiting', async () => { + await runAsync(BuildRuntimePlatform.LINUX); + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0] + ); + }); + it('reports the session token when serve-sim minted one', async () => { jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({ openPreviewAsync: jest.fn().mockResolvedValue({ @@ -205,7 +283,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { env: childEnv, logger, timeoutMs: 60_000, - signal: undefined, + signal: expect.any(AbortSignal), ...(selectsXcode ? { iosSimulatorUdid: 'SELECTED-IOS-UDID', @@ -237,6 +315,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => { previewApiUrl: 'https://web-preview.example.test', }, logger, + signal: expect.any(AbortSignal), }); expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({ ctx, diff --git a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts index c7df033ee9..191091c92d 100644 --- a/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startWebPreviewRemoteSession.ts @@ -6,7 +6,7 @@ import { } from '@expo/steps'; import { CustomBuildContext } from '../../customBuildContext'; -import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; +import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost'; import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession'; import { createNetworkCaptureInputProviders, @@ -33,6 +33,8 @@ import { selectXcodeDeveloperDirectoryAsync, waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; +import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup'; +import { createStartupTasks } from '../utils/startupTasks'; const STARTUP_TIMEOUT_MS = 60_000; @@ -71,55 +73,66 @@ 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; - 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, + const startup = createStartupTasks(logger, signal); + const startupWatch = watchDeviceRunSessionStartup({ + ctx, + deviceRunSessionId, logger, - signal, + startup, }); - const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( - { - networkCapture: inputs.network_capture?.value, - networkCaptureFields: inputs.network_capture_fields?.value, - }, - { runtimePlatform } - ); + let sessionHost: DeviceSessionHost | undefined; + try { + startup.signal.throwIfAborted(); + 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: startup.signal, + }); + startup.signal.throwIfAborted(); + const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( + { + networkCapture: inputs.network_capture?.value, + networkCaptureFields: inputs.network_capture_fields?.value, + }, + { runtimePlatform } + ); - logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`); - const launchDescription = describeServeSimLaunch(launch); - if (launchDescription) { - logger.info(launchDescription); - } + logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`); + const launchDescription = describeServeSimLaunch(launch); + if (launchDescription) { + logger.info(launchDescription); + } - const sessionHost = await startDeviceSessionHostAsync(ctx, { - ...iosStartup, - runtimePlatform, - env, - logger, - timeoutMs: STARTUP_TIMEOUT_MS, - signal, - packageVersion, - disableShare: inputs.public_preview?.value === false, - privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, - frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), - corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), - launchAppIdentifier: launch.launchAppIdentifier, - launchArgs: launch.launchArgs, - openUrl: launch.openUrl, - networkCapture, - networkCaptureFields, - }); + sessionHost = await startDeviceSessionHostAsync(ctx, { + ...iosStartup, + runtimePlatform, + env, + logger, + timeoutMs: STARTUP_TIMEOUT_MS, + signal: startup.signal, + packageVersion, + disableShare: inputs.public_preview?.value === false, + privatePreviewUrl: inputs.private_preview_url?.value as string | undefined, + frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value), + corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value), + launchAppIdentifier: launch.launchAppIdentifier, + launchArgs: launch.launchArgs, + openUrl: launch.openUrl, + networkCapture, + networkCaptureFields, + }); + startup.signal.throwIfAborted(); - try { const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain, authtoken: ngrokAuthtoken, @@ -131,7 +144,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction( ); await uploadRemoteSessionConfigWithLocalEgressAsync({ env, - signal, + signal: startup.signal, ctx, deviceRunSessionId, // WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM @@ -148,6 +161,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction( logger, }); + await startupWatch.completeAsync(); await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, @@ -155,8 +169,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction( maxDurationSeconds, signal, }); + } catch (error) { + if (startupWatch.isStopped()) { + return; + } + throw startup.signal.aborted ? startup.signal.reason : error; } finally { - await sessionHost.finishAsync(); + startup.abort(new Error('Web preview session ended.')); + await startupWatch.finishAsync(); + await sessionHost?.finishAsync(); } }), });