diff --git a/.changeset/appium-stop-during-startup.md b/.changeset/appium-stop-during-startup.md new file mode 100644 index 0000000000..29c944ef9a --- /dev/null +++ b/.changeset/appium-stop-during-startup.md @@ -0,0 +1,6 @@ +--- +'@expo/build-tools': patch +--- + +Stop an Appium session that is still starting up when it is stopped. +Report temporary-directory cleanup failures when Stop interrupts Appium installation. diff --git a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts index 3c70590799..2b96cb7c9f 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAppiumRemoteSession.test.ts @@ -1,6 +1,7 @@ import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import fs from 'node:fs'; +import * as timersPromises from 'node:timers/promises'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { type CustomBuildContext } from '../../../customBuildContext'; @@ -19,6 +20,8 @@ import { startNgrokTunnelAsync, waitForDeviceRunSessionStoppedAsync, } from '../../utils/remoteDeviceRunSession'; +import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup'; +import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch'; import { createStartAppiumRemoteSessionBuildFunction, @@ -27,6 +30,10 @@ import { resolveAppiumDeviceAsync, } from '../startAppiumRemoteSession'; +jest.mock('node:timers/promises', () => ({ + ...jest.requireActual('node:timers/promises'), + setTimeout: jest.fn(jest.requireActual('node:timers/promises').setTimeout), +})); jest.mock('../../../utils/AndroidEmulatorUtils', () => ({ AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() }, })); @@ -45,6 +52,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({ startNgrokTunnelAsync: jest.fn(), waitForDeviceRunSessionStoppedAsync: jest.fn(), })); +jest.mock('../../utils/deviceRunSessionStartup', () => ({ + watchDeviceRunSessionStartup: jest.fn(), +})); jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); jest.mock('../../../sentry'); jest.mock('../../../utils/retry', () => ({ @@ -227,6 +237,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => const stopEventCollection = jest.fn(); beforeEach(() => { + jest + .mocked(timersPromises.setTimeout) + .mockImplementation(jest.requireActual('node:timers/promises').setTimeout); + jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock); jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined); jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({}); jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({ @@ -337,6 +351,154 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => } ); + it.each([ + [BuildRuntimePlatform.DARWIN, false], + [BuildRuntimePlatform.LINUX, false], + [BuildRuntimePlatform.DARWIN, true], + ])( + 'handles Stop during %s setup, preserving teardown failure=%s', + async (runtimePlatform, teardownFails) => { + let setupEntered!: () => void; + const entered = new Promise(resolve => { + setupEntered = resolve; + }); + jest.mocked(ensureFfmpegInstalledOnceAsync).mockImplementationOnce(({ signal }) => { + setupEntered(); + return new Promise(resolve => + signal!.addEventListener('abort', () => resolve(), { once: true }) + ); + }); + const teardownError = new Error('host teardown failed'); + const finishHost = jest.fn().mockImplementation(async () => { + if (teardownFails) { + throw teardownError; + } + }); + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { + jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({ + openPreviewAsync: jest.fn(), + finishAsync: finishHost, + getLastPreviewActivityAt: () => undefined, + }); + } + const running = runSessionAsync( + { info: jest.fn(), warn: jest.fn() }, + undefined, + runtimePlatform, + runtimePlatform === BuildRuntimePlatform.DARWIN + ? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } } + : {} + ); + running.catch(() => {}); + await entered; + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + watch.stop(); + if (teardownFails) { + await expect(running).rejects.toBe(teardownError); + } else { + await expect(running).resolves.toBeUndefined(); + } + expect(spawnDetached).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + expect(watch.finishAsync).toHaveBeenCalled(); + if (runtimePlatform === BuildRuntimePlatform.DARWIN) { + expect(finishHost).toHaveBeenCalledTimes(1); + } + } + ); + + it.each([ + [BuildRuntimePlatform.DARWIN, false], + [BuildRuntimePlatform.DARWIN, true], + [BuildRuntimePlatform.LINUX, false], + ])( + 'preserves failed installer cleanup after Stop on %s with Simulator startup=%s', + async (runtimePlatform, bootSimulator) => { + const cleanupError = new Error('Appium home removal failed: EACCES'); + const remove = fs.promises.rm.bind(fs.promises); + jest + .mocked(IosSimulatorUtils.getAvailableDevicesAsync) + .mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]); + let enteredInstall!: () => void; + const installEntered = new Promise(resolve => { + enteredInstall = resolve; + }); + let closeInstaller!: (error: Error) => void; + jest.mocked(spawn).mockImplementationOnce(() => { + enteredInstall(); + return Object.assign( + new Promise((_resolve, reject) => { + closeInstaller = reject; + }), + { child: { pid: 12345 } } + ) as never; + }); + jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {}); + const rm = jest.spyOn(fs.promises, 'rm').mockRejectedValueOnce(cleanupError); + const running = runSessionAsync( + { info: jest.fn(), warn: jest.fn() }, + undefined, + runtimePlatform, + bootSimulator + ? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-ios-udid' } } + : {} + ); + const observed = running.catch(() => {}); + let home: string | undefined; + try { + await installEntered; + home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string; + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + watch.stop(); + closeInstaller(new Error('installer interrupted')); + await expect(running).rejects.toBe(cleanupError); + expect(rm).toHaveBeenCalledWith(home, { recursive: true, force: true }); + expect(fs.existsSync(home)).toBe(true); + expect(spawnDetached).not.toHaveBeenCalled(); + expect(startNgrokTunnelAsync).not.toHaveBeenCalled(); + expect(watch.finishAsync).toHaveBeenCalled(); + } finally { + closeInstaller?.(new Error('test cleanup')); + await observed; + if (home) { + await remove(home, { recursive: true, force: true }); + } + } + } + ); + + it('preserves an Android installer failure observed before a later Stop', async () => { + const failed = new Error('Appium install failed first'); + let observedFailure!: () => void; + const installFailed = new Promise(resolve => { + observedFailure = resolve; + }); + jest.mocked(spawn).mockImplementationOnce((() => { + observedFailure(); + return Promise.reject(failed); + }) as never); + jest + .mocked(ensureFfmpegInstalledOnceAsync) + .mockImplementationOnce( + ({ signal }) => + new Promise(resolve => + signal!.addEventListener('abort', () => resolve(), { once: true }) + ) + ); + const running = runSessionAsync( + { info: jest.fn(), warn: jest.fn() }, + undefined, + BuildRuntimePlatform.LINUX + ); + running.catch(() => {}); + await installFailed; + await new Promise(resolve => setImmediate(resolve)); + const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value; + watch.stop(); + await expect(running).rejects.toBe(failed); + expect(spawnDetached).not.toHaveBeenCalled(); + }); + it('selects Xcode only once for an already-prepared iOS session', async () => { stopAppium.mockResolvedValue(undefined); jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear(); @@ -651,9 +813,9 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () => it('keeps the readiness error when Appium cannot be stopped', async () => { let now = Date.now(); jest.spyOn(Date, 'now').mockImplementation(() => now); - jest.mocked(sleepAsync).mockImplementation(async ms => { - now += ms; - }); + jest.mocked(timersPromises.setTimeout).mockImplementation((async ms => { + now += Number(ms ?? 0); + }) as typeof timersPromises.setTimeout); jest.mocked(turtleFetch).mockRejectedValue(new Error('connect ECONNREFUSED')); const logger = { info: jest.fn(), warn: jest.fn() }; diff --git a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts index d12bce5fbc..87e5654d85 100644 --- a/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAppiumRemoteSession.ts @@ -35,7 +35,6 @@ import { resolvePackageAdd, } from '../../utils/packageManager'; import { killProcessGroup } from '../../utils/processes'; -import { sleepAsync } from '../../utils/retry'; import { turtleFetch } from '../../utils/turtleFetch'; import { startAppiumEventCollectionAsync } from '../utils/appiumEvents'; import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda'; @@ -60,6 +59,8 @@ import { startNgrokTunnelAsync, waitForDeviceRunSessionStoppedAsync, } from '../utils/remoteDeviceRunSession'; +import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup'; +import { createStartupTasks } from '../utils/startupTasks'; const APPIUM_HOST = '127.0.0.1'; const APPIUM_PORT = 4723; @@ -106,46 +107,13 @@ export function createStartAppiumRemoteSessionBuildFunction( const packageVersion = inputs.package_version.value as string | undefined; const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | 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 } - ); - const versionSpec = resolveAppium3VersionSpec(packageVersion); - - logger.info( - `Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` - ); - const device: AppiumDevice = iosStartup - ? { - platformName: 'iOS', - automationName: 'XCUITest', - driverName: 'xcuitest', - udid: iosStartup.iosSimulatorUdid, - } - : await resolveAppiumDeviceAsync({ runtimePlatform, env }); - const startupAbortController = new AbortController(); - const startupSignal = signal - ? AbortSignal.any([signal, startupAbortController.signal]) - : startupAbortController.signal; - const setupSignal = iosStartup ? startupSignal : undefined; let hostStartup: Promise> | undefined; let sessionHost: DeviceSessionHost | undefined; let appiumProcess: ReturnType | undefined; @@ -153,30 +121,68 @@ export function createStartAppiumRemoteSessionBuildFunction( let eventCollection: Awaited> | undefined; let appiumTunnel: Awaited> | undefined; let sessionFailed = false; - const startHost = () => - startDeviceSessionHostAsync(ctx, { - ...iosStartup, - separateLogPhase: true, + 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, - timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, - 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), - signal: startupSignal, - onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined, - ...launch, - networkCapture, - networkCaptureFields, - }).catch(error => { - if (iosStartup) { - startupAbortController.abort(error); - } - throw error; + signal: startup.signal, }); - try { + startup.signal.throwIfAborted(); + const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs( + { + networkCapture: inputs.network_capture?.value, + networkCaptureFields: inputs.network_capture_fields?.value, + }, + { runtimePlatform } + ); + const versionSpec = resolveAppium3VersionSpec(packageVersion); + + logger.info( + `Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).` + ); + const device: AppiumDevice = iosStartup + ? { + platformName: 'iOS', + automationName: 'XCUITest', + driverName: 'xcuitest', + udid: iosStartup.iosSimulatorUdid, + } + : await resolveAppiumDeviceAsync({ runtimePlatform, env }); + startup.signal.throwIfAborted(); + const startHost = () => + startDeviceSessionHostAsync(ctx, { + ...iosStartup, + separateLogPhase: true, + runtimePlatform, + env, + logger, + timeoutMs: APPIUM_STARTUP_TIMEOUT_MS, + 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), + signal: startup.signal, + onStartupError: iosStartup ? error => startup.abort(error) : undefined, + ...launch, + networkCapture, + networkCaptureFields, + }).catch(error => { + if (iosStartup) { + startup.abort(error); + } + throw error; + }); if (iosStartup) { hostStartup = asyncResult(startHost()); } @@ -191,22 +197,20 @@ export function createStartAppiumRemoteSessionBuildFunction( driverName: device.driverName, env, logger, - signal: setupSignal, - onError: iosStartup ? error => startupAbortController.abort(error) : undefined, + signal: startup.signal, + onError: error => startup.abort(error), }).catch(error => { - if (iosStartup) { - startupAbortController.abort(error); - } + startup.abort(error); throw error; }) ), asyncResult( - ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal }) + ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: startup.signal }) ), ]); const installation = appiumInstall.enforceValue(); appiumHome = installation.appiumHome; - setupSignal?.throwIfAborted(); + startup.signal.throwIfAborted(); const { appiumBinPath, appiumEnv } = installation; const wdaCapabilities = await preparePreinstalledWdaAsync({ runtimeIdentifier: iosStartup @@ -221,10 +225,10 @@ export function createStartAppiumRemoteSessionBuildFunction( appiumBinPath, appiumEnv, logger, - signal: setupSignal, + signal: startup.signal, }); - setupSignal?.throwIfAborted(); + startup.signal.throwIfAborted(); appiumProcess = spawnDetached({ command: appiumBinPath, args: [ @@ -247,11 +251,11 @@ export function createStartAppiumRemoteSessionBuildFunction( env: appiumEnv, logger, }); - await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal }); + await waitForAppiumReadyAsync({ appiumProcess, logger, signal: startup.signal }); if (hostStartup) { sessionHost = (await hostStartup).enforceValue(); } - setupSignal?.throwIfAborted(); + startup.signal.throwIfAborted(); eventCollection = await startAppiumEventCollectionAsync({ ctx, deviceRunSessionId, @@ -281,7 +285,7 @@ export function createStartAppiumRemoteSessionBuildFunction( await uploadRemoteSessionConfigWithLocalEgressAsync({ env, - signal, + signal: startup.signal, ctx, deviceRunSessionId, remoteConfig: { @@ -298,6 +302,7 @@ export function createStartAppiumRemoteSessionBuildFunction( logger, }); + await startupWatch.completeAsync(); await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, @@ -313,10 +318,15 @@ export function createStartAppiumRemoteSessionBuildFunction( : undefined, }); } catch (error) { + const stopped = startupWatch.isStopped(); + if (stopped && error === startupWatch.stopped) { + return; + } sessionFailed = true; - throw setupSignal?.aborted ? setupSignal.reason : error; + throw !stopped && startup.signal.aborted ? startup.signal.reason : error; } finally { - startupAbortController.abort(new Error('Appium session ended.')); + startup.abort(new Error('Appium session ended.')); + await startupWatch.finishAsync(); await finishRemoteSessionAsync({ logger, sessionFailed, @@ -516,11 +526,11 @@ async function waitForAppiumReadyAsync({ }: { appiumProcess: { getOutput: () => string }; logger: bunyan; - signal?: AbortSignal; + signal: AbortSignal; }): Promise { const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS; while (Date.now() < deadline) { - signal?.throwIfAborted(); + signal.throwIfAborted(); try { const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', { timeout: 2_000, @@ -528,17 +538,13 @@ async function waitForAppiumReadyAsync({ logger, signal, }); - signal?.throwIfAborted(); + signal.throwIfAborted(); if (response.ok) { return; } } catch {} - signal?.throwIfAborted(); - if (signal) { - await setTimeoutAsync(1_000, undefined, { signal }); - } else { - await sleepAsync(1_000); - } + signal.throwIfAborted(); + await setTimeoutAsync(1_000, undefined, { signal }); } const output = appiumProcess.getOutput(); throw new SystemError(