diff --git a/.nx/version-plans/version-plan-1785770217523.md b/.nx/version-plans/version-plan-1785770217523.md deleted file mode 100644 index c1cc60e4..00000000 --- a/.nx/version-plans/version-plan-1785770217523.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -__default__: patch ---- - -Published packages exclude test outputs and local build artifacts. diff --git a/.nx/version-plans/version-plan-1785777985382.md b/.nx/version-plans/version-plan-1785777985382.md deleted file mode 100644 index 63ab081a..00000000 --- a/.nx/version-plans/version-plan-1785777985382.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -__default__: patch ---- - -Harness tracks pending work while preserving native Promise instances so native modules return their settled values on Hermes. diff --git a/.nx/version-plans/version-plan-1785922736004.md b/.nx/version-plans/version-plan-1785922736004.md new file mode 100644 index 00000000..01fd8569 --- /dev/null +++ b/.nx/version-plans/version-plan-1785922736004.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness now bounds owned CLI processes so cancelled runs do not leave console, logcat, or XCTest children behind. diff --git a/CHANGELOG.md b/CHANGELOG.md index 935e3730..fc5c19c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.4.1 (2026-08-04) + +### 🩹 Fixes + +- Published packages exclude test outputs and local build artifacts. ([#179](https://github.com/callstackincubator/react-native-harness/pull/179)) +- Harness tracks pending work while preserving native Promise instances so native modules return their settled values on Hermes. ([#180](https://github.com/callstackincubator/react-native-harness/pull/180)) + +### ❤️ Thank You + +- Szymon Chmal @V3RON + ## 1.4.0 (2026-08-03) ### 🚀 Features diff --git a/packages/babel-preset/package.json b/packages/babel-preset/package.json index 533ed1f8..7badcd57 100644 --- a/packages/babel-preset/package.json +++ b/packages/babel-preset/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/babel-preset", - "version": "1.4.0", + "version": "1.4.1", "type": "commonjs", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 3d837ae1..93263ba8 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/bridge", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index 52e505bc..18d10bcf 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/bundler-metro", "description": "Metro bundler for React Native Harness", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 2d6f9bc8..2203bb40 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -167,9 +167,22 @@ export const getMetroInstance = async ( socket.on('close', () => sockets.delete(socket)); }); - abortSignal.throwIfAborted(); + const closeServer = () => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + for (const socket of sockets) { + socket.destroy(); + } + }); - await ready; + try { + abortSignal.throwIfAborted(); + await ready; + } catch (error) { + await closeServer(); + throw error; + } metroLogger.debug('Metro server is running'); @@ -265,15 +278,10 @@ export const getMetroInstance = async ( }, getPrewarmState: () => prewarmState, isBuildInFlight: () => buildsInFlight > 0, - dispose: () => - new Promise((resolve) => { - reporter.removeListener(onBundleRequestObserved); - reporter.removeListener(onBuildEvent); - server.close(() => resolve()); - server.closeAllConnections(); - for (const socket of sockets) { - socket.destroy(); - } - }), + dispose: () => { + reporter.removeListener(onBundleRequestObserved); + reporter.removeListener(onBuildEvent); + return closeServer(); + }, }; }; diff --git a/packages/cache/package.json b/packages/cache/package.json index 4dcaa550..d46f539b 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/cache", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index ee70b857..bca6a108 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/cli", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/config/package.json b/packages/config/package.json index 19398faa..707d5e1e 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/config", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/coverage-ios/package.json b/packages/coverage-ios/package.json index 53d50840..8c1a95b4 100644 --- a/packages/coverage-ios/package.json +++ b/packages/coverage-ios/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/coverage-ios", "description": "Native iOS code coverage support for React Native Harness.", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "exports": { "./package.json": "./package.json", diff --git a/packages/github-action/package.json b/packages/github-action/package.json index 8b9ccd96..b5e46110 100644 --- a/packages/github-action/package.json +++ b/packages/github-action/package.json @@ -2,7 +2,7 @@ "name": "@react-native-harness/github-action", "private": true, "description": "GitHub Actions for React Native Harness", - "version": "1.4.0", + "version": "1.4.1", "type": "commonjs", "exports": { "./package.json": "./package.json" diff --git a/packages/jest/package.json b/packages/jest/package.json index 6db0745c..f27d1847 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/jest", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index 8a740e93..aab2a6ef 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -38,6 +38,7 @@ import { import { createCrashArtifactWriter, createDiagnostics, + delay, logger, getTimeoutSignal, ensureSignalsDeliverable, @@ -94,6 +95,25 @@ const ignorePromiseRejection = () => undefined; const isBridgeDisconnectError = (error: unknown) => error instanceof Error && error.message === 'App bridge disconnected'; const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500; +const DISPOSE_HOOK_TIMEOUT_MS = 5_000; + +const waitForBoundedSettlement = async ( + promise: Promise, + timeoutMs: number +): Promise | null> => { + const timeout = delay(timeoutMs); + try { + return await Promise.race([ + promise.then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ), + timeout.promise.then(() => null), + ]); + } finally { + timeout.cancel(); + } +}; type DisconnectObservableBridge = { readonly connection: AppConnection | null; @@ -487,7 +507,11 @@ export const createHarnessSession = async ( // child the runner threads it into. ensureSignalsDeliverable(); const sessionController = new AbortController(); - const onEarlySignal = () => sessionController.abort(); + let receivedEarlySignal = false; + const onEarlySignal = () => { + receivedEarlySignal = true; + sessionController.abort(); + }; process.once('SIGTERM', onEarlySignal); process.once('SIGINT', onEarlySignal); @@ -584,6 +608,11 @@ export const createHarnessSession = async ( let metroInstance: MetroInstance; let platformInstance: HarnessPlatformRunner; + const initialized = { + metro: null as MetroInstance | null, + platform: null as HarnessPlatformRunner | null, + }; + let initializationCancelled = false; // Noop until the Metro instance resolves and the deriver is attached. let disposeMetroDiagnostics: () => void = () => undefined; @@ -601,6 +630,10 @@ export const createHarnessSession = async ( }, sessionController.signal, ).then((instance) => { + if (initializationCancelled) { + return instance.dispose().then(() => instance); + } + initialized.metro = instance; sessionLogger.debug('Metro initialized'); // Attach the Metro diagnostics deriver before the eager prewarm // fires so its bundle build/request events land in the trace. @@ -638,6 +671,10 @@ export const createHarnessSession = async ( diagnostics, } satisfies HarnessPlatformInitOptions), ).then((instance) => { + if (initializationCancelled) { + return instance.dispose().then(() => instance); + } + initialized.platform = instance; sessionLogger.debug('platform runner initialized'); return instance; }); @@ -647,10 +684,16 @@ export const createHarnessSession = async ( ), ]); } catch (error) { - // Only bridge and the Metro diagnostics listener need cleanup here; - // leases are released by the outer catch. + // Roll back every resource whose initialization won a concurrent race. + // Leases are released by the outer catch. + initializationCancelled = true; + sessionController.abort(); disposeMetroDiagnostics(); - await bridge.dispose(); + await Promise.allSettled([ + bridge.dispose(), + initialized.metro?.dispose(), + initialized.platform?.dispose(), + ]); throw error; } @@ -759,7 +802,11 @@ export const createHarnessSession = async ( const disposeSpan = diagnostics.start('dispose.total', { reason }); let hookError: unknown; - try { + // Abort only cancels finite/in-flight work. Ownership remains explicit + // below, so this cannot race a platform or app-session disposal path. + sessionController.abort(); + + const hookWork = (async () => { await hooks.drain(); await pluginManager.callHook('harness:after-run', { runId: currentRun?.runId, @@ -777,8 +824,17 @@ export const createHarnessSession = async ( error: currentRun?.error, }); await hooks.drain(); - } catch (error) { - hookError = error; + })(); + const hookResult = await waitForBoundedSettlement( + hookWork, + DISPOSE_HOOK_TIMEOUT_MS + ); + if (hookResult === null) { + hookError = new Error( + `Timed out after ${DISPOSE_HOOK_TIMEOUT_MS}ms while running disposal hooks.` + ); + } else if (hookResult.status === 'rejected') { + hookError = hookResult.reason; } if (runtimeConfig.forwardClientLogs) { @@ -809,26 +865,23 @@ export const createHarnessSession = async ( } } - let cleanupError: unknown; - try { - // The app session (and its logcat/device log stream) must be fully - // torn down before the platform is disposed: platform disposal can - // shut down the device/emulator, and doing that concurrently with - // the app session's own cleanup races the device's log stream - // against the device disappearing out from under it. - await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]); - await Promise.all([ - bridge.dispose(), - platformInstance.dispose(), - metroInstance.dispose(), - metroPortLease?.release(), - ]); - } catch (error) { - cleanupError = error; - } finally { - await resourceLease.release(); - sessionController.abort(); - } + // The app session (and its log streams) must finish before platform + // disposal can remove the underlying device. Run independent cleanup + // all-settled so one failure cannot strand another owned resource. + const appCleanup = await Promise.allSettled([ + crashMonitor.dispose(), + disposeCurrentAppSession(), + ]); + const infrastructureCleanup = await Promise.allSettled([ + bridge.dispose(), + platformInstance.dispose(), + metroInstance.dispose(), + metroPortLease?.release(), + ]); + const leaseCleanup = await Promise.allSettled([resourceLease.release()]); + const cleanupError = [...appCleanup, ...infrastructureCleanup, ...leaseCleanup] + .find((result): result is PromiseRejectedResult => result.status === 'rejected') + ?.reason; sessionLogger.debug('session resources disposed'); @@ -1102,6 +1155,9 @@ export const createHarnessSession = async ( // (e.g. a platform init call abandoned after a readiness timeout race). sessionController.abort(); await Promise.allSettled([resourceLease.release(), metroPortLease?.release()]); + if (receivedEarlySignal) { + process.exit(1); + } throw error; } }; diff --git a/packages/metro/package.json b/packages/metro/package.json index 49d0a689..3221868e 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/metro", - "version": "1.4.0", + "version": "1.4.1", "type": "commonjs", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/platform-android/package.json b/packages/platform-android/package.json index 3567a9c2..490c52a3 100644 --- a/packages/platform-android/package.json +++ b/packages/platform-android/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/platform-android", "description": "Android platform for React Native Harness", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/platform-android/src/__tests__/app-session.test.ts b/packages/platform-android/src/__tests__/app-session.test.ts index b52cf2fb..e8df1a05 100644 --- a/packages/platform-android/src/__tests__/app-session.test.ts +++ b/packages/platform-android/src/__tests__/app-session.test.ts @@ -1,19 +1,20 @@ import { describe, expect, it } from 'vitest'; -import type { Subprocess } from '@react-native-harness/tools'; +import type { OwnedProcess, Subprocess } from '@react-native-harness/tools'; import { createAndroidAppSession } from '../app-session.js'; // Mimics nano-spawn: the async iterator only ends once its abort signal // fires, matching how startLogcat's real subprocess reacts to abort. -const createAbortableLogcatProcess = (signal: AbortSignal): Subprocess => - ({ +const createAbortableLogcatProcess = (): OwnedProcess => { + const controller = new AbortController(); + const subprocess = { [Symbol.asyncIterator]: () => ({ next: () => new Promise<{ done: true; value: undefined }>((resolve) => { - if (signal.aborted) { + if (controller.signal.aborted) { resolve({ done: true, value: undefined }); return; } - signal.addEventListener( + controller.signal.addEventListener( 'abort', () => resolve({ done: true, value: undefined }), { once: true } @@ -21,12 +22,13 @@ const createAbortableLogcatProcess = (signal: AbortSignal): Subprocess => }), }), catch: () => undefined, - }) as unknown as Subprocess; + } as unknown as Subprocess; + return { subprocess, dispose: async () => controller.abort() }; +}; describe('createAndroidAppSession', () => { - it('combines the session-lifetime signal with its own logcat abort controller', async () => { - let capturedSignal: AbortSignal | undefined; - const controller = new AbortController(); + it('disposes logcat only through the explicit disposal path', async () => { + let disposed = false; const session = await createAndroidAppSession({ appUid: 1, @@ -35,24 +37,24 @@ describe('createAndroidAppSession', () => { stopApp: async () => undefined, getAppPid: async () => null, getLogcatTimestamp: async () => '00:00:00.000', - startLogcat: (_args, options) => { - capturedSignal = options.signal; - return createAbortableLogcatProcess(options.signal); + startLogcat: () => { + const process = createAbortableLogcatProcess(); + return { + ...process, + dispose: async () => { + disposed = true; + await process.dispose(); + }, + }; }, - signal: controller.signal, }); - expect(capturedSignal?.aborted).toBe(false); - - controller.abort(); - - expect(capturedSignal?.aborted).toBe(true); - await session.dispose(); + expect(disposed).toBe(true); }); - it('still aborts the logcat signal via dispose() with no external signal provided', async () => { - let capturedSignal: AbortSignal | undefined; + it('disposes logcat with no external signal provided', async () => { + let disposed = false; const session = await createAndroidAppSession({ appUid: 1, @@ -61,16 +63,19 @@ describe('createAndroidAppSession', () => { stopApp: async () => undefined, getAppPid: async () => null, getLogcatTimestamp: async () => '00:00:00.000', - startLogcat: (_args, options) => { - capturedSignal = options.signal; - return createAbortableLogcatProcess(options.signal); + startLogcat: () => { + const process = createAbortableLogcatProcess(); + return { + ...process, + dispose: async () => { + disposed = true; + await process.dispose(); + }, + }; }, }); - expect(capturedSignal?.aborted).toBe(false); - await session.dispose(); - - expect(capturedSignal?.aborted).toBe(true); + expect(disposed).toBe(true); }); }); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index f5544d14..8fc17c70 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_METRO_PORT, type Config as HarnessConfig, } from '@react-native-harness/config'; -import type { Subprocess } from '@react-native-harness/tools'; +import type { OwnedProcess, Subprocess } from '@react-native-harness/tools'; import { getAndroidEmulatorPlatformInstance, getAndroidPhysicalDevicePlatformInstance, @@ -15,7 +15,7 @@ import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; import * as emulatorStartup from '../emulator-startup.js'; const { getEmulatorCpuCores } = emulatorStartup; -const createLogcatProcess = (lines: string[] = []): Subprocess => { +const createLogcatProcess = (lines: string[] = []): OwnedProcess => { // Mirrors nano-spawn's real Subprocess, which is both a Promise and an // async iterable, since app-session.ts relies on it being thenable. const process = Object.assign(Promise.resolve(undefined), { @@ -29,7 +29,7 @@ const createLogcatProcess = (lines: string[] = []): Subprocess => { }, }); - return process as unknown as Subprocess; + return { subprocess: process as unknown as Subprocess, dispose: async () => undefined }; }; const harnessConfig = { @@ -558,8 +558,7 @@ describe('Android platform instance', () => { '--uid=10234', '-T', '01-01 00:00:00.000', - ], - { signal: expect.any(AbortSignal) } + ] ); expect(startLogcat.mock.invocationCallOrder[0]).toBeLessThan( startApp.mock.invocationCallOrder[0] @@ -696,8 +695,7 @@ describe('Android platform instance', () => { '--uid=10234', '-T', '01-01 00:00:00.000', - ], - { signal: expect.any(AbortSignal) } + ] ); await expect(appSession.getState()).resolves.toEqual({ status: 'running', diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 10bb4f56..98929824 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -3,9 +3,10 @@ import { delay, logger, spawn, + spawnOwnedProcess, SubprocessError, waitForAbort, - type Subprocess, + type OwnedProcess, } from '@react-native-harness/tools'; import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcessByStdio } from 'node:child_process'; @@ -789,13 +790,11 @@ export const getLogcatTimestamp = async (adbId: string): Promise => { export const startLogcat = ( adbId: string, - args: readonly string[], - options?: { signal?: AbortSignal } -): Subprocess => - spawn(getAdbBinaryPath(), ['-s', adbId, ...args], { + args: readonly string[] +): OwnedProcess => + spawnOwnedProcess(getAdbBinaryPath(), ['-s', adbId, ...args], { stdout: 'pipe', stderr: 'pipe', - signal: options?.signal, }); export const DROPBOX_CRASH_TAGS = [ diff --git a/packages/platform-android/src/app-session.ts b/packages/platform-android/src/app-session.ts index 330a4561..f2105b52 100644 --- a/packages/platform-android/src/app-session.ts +++ b/packages/platform-android/src/app-session.ts @@ -8,7 +8,7 @@ import { import { escapeRegExp, logger, - type Subprocess, + type OwnedProcess, } from '@react-native-harness/tools'; import { createAndroidCrashReporter } from './crash-reporter.js'; @@ -75,19 +75,11 @@ type CreateAndroidAppSessionOptions = { getAppPid: () => Promise; getLogcatTimestamp: () => Promise; startLogcat: ( - args: readonly string[], - options: { signal: AbortSignal } - ) => Subprocess; + args: readonly string[] + ) => OwnedProcess; getDropboxOutput?: () => Promise; getExitInfo?: () => Promise; crashArtifactWriter?: CrashArtifactWriter; - /** - * Session-lifetime abort signal (see HarnessPlatformInitOptions.signal). - * Combined with the app session's own logcatAbortController so that - * aborting the session stops the logcat stream even if dispose() hasn't - * been called yet (e.g. SIGTERM during teardown of other resources). - */ - signal?: AbortSignal; }; export const createAndroidAppSession = async ({ @@ -101,7 +93,6 @@ export const createAndroidAppSession = async ({ getDropboxOutput, getExitInfo, crashArtifactWriter, - signal, }: CreateAndroidAppSessionOptions): Promise => { const emitter = createAppSessionEmitter(); const logBuffer = createBoundedLogBuffer(); @@ -196,20 +187,8 @@ export const createAndroidAppSession = async ({ const logcatTimestamp = await getLogcatTimestamp(); const sessionStartedAt = Date.now(); - const logcatAbortController = new AbortController(); - const logcatSignal = signal - ? AbortSignal.any([signal, logcatAbortController.signal]) - : logcatAbortController.signal; - const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp), { - signal: logcatSignal, - }); - // Aborting is nano-spawn's own cancellation path, so the resulting - // SubprocessError is settled the same way regardless of which of the - // merged stdout/stderr iterators observes it first. Without this, killing - // the underlying process directly can make both iterators reject at once, - // and nano-spawn's internal `Promise.race` only reports one of them, - // leaving the other an unhandled rejection. - logcatProcess.catch(() => undefined); + const ownedLogcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp)); + const logcatProcess = ownedLogcatProcess.subprocess; const crashReporter = createAndroidCrashReporter({ bundleId, crashArtifactWriter, @@ -255,7 +234,7 @@ export const createAndroidAppSession = async ({ disposed = true; stopPolling = true; emitter.clear(); - logcatAbortController.abort(); + await ownedLogcatProcess.dispose(); await Promise.allSettled([logTask]); throw error; } @@ -285,8 +264,9 @@ export const createAndroidAppSession = async ({ } })(); - return { - dispose: async () => { + let disposePromise: Promise | undefined; + const dispose = () => + (disposePromise ??= (async () => { if (disposed) { return; } @@ -303,10 +283,13 @@ export const createAndroidAppSession = async ({ cancelPendingPollDelay(); emitter.clear(); - logcatAbortController.abort(); + await ownedLogcatProcess.dispose(); await stopApp(); await Promise.allSettled([logTask, pollTask]); - }, + })()); + + return { + dispose, getState: async () => state, getLogs: () => logBuffer.getLogs(), getCrashDetails: crashReporter.getCrashDetails, diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index b9cc06f8..9e2fa362 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -297,11 +297,10 @@ export const getAndroidEmulatorPlatformInstance = async ( stopApp: () => adb.stopApp(adbId, config.bundleId), getAppPid: () => adb.getAppPid(adbId, config.bundleId), getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId), - startLogcat: (args, options) => adb.startLogcat(adbId, args, options), + startLogcat: (args) => adb.startLogcat(adbId, args), getDropboxOutput: () => adb.getDropboxPrint(adbId), getExitInfo: () => adb.getActivityExitInfo(adbId, config.bundleId), crashArtifactWriter: init.crashArtifactWriter, - signal: init.signal, }); }, dispose: async () => { @@ -371,11 +370,10 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( stopApp: () => adb.stopApp(adbId, config.bundleId), getAppPid: () => adb.getAppPid(adbId, config.bundleId), getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId), - startLogcat: (args, options) => adb.startLogcat(adbId, args, options), + startLogcat: (args) => adb.startLogcat(adbId, args), getDropboxOutput: () => adb.getDropboxPrint(adbId), getExitInfo: () => adb.getActivityExitInfo(adbId, config.bundleId), crashArtifactWriter: init?.crashArtifactWriter, - signal: init?.signal, }); }, dispose: async () => { diff --git a/packages/platform-ios/package.json b/packages/platform-ios/package.json index 60ed0a3f..d9d49833 100644 --- a/packages/platform-ios/package.json +++ b/packages/platform-ios/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/platform-apple", "description": "Apple platform for React Native Harness", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/platform-ios/src/__tests__/app-session.test.ts b/packages/platform-ios/src/__tests__/app-session.test.ts index b91c26fc..754f0093 100644 --- a/packages/platform-ios/src/__tests__/app-session.test.ts +++ b/packages/platform-ios/src/__tests__/app-session.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Subprocess } from '@react-native-harness/tools'; +import type { OwnedProcess, Subprocess } from '@react-native-harness/tools'; import { createIosAppSession } from '../app-session.js'; -const createPendingLaunchProcess = (): Subprocess => { +const createPendingLaunchProcess = (): OwnedProcess => { let resolveLaunch!: () => void; const pending = new Promise((resolve) => { resolveLaunch = resolve; @@ -13,7 +13,7 @@ const createPendingLaunchProcess = (): Subprocess => { return true; }), }; - return Object.assign(pending, { + const subprocess = Object.assign(pending, { [Symbol.asyncIterator]: () => ({ next: async () => { await pending; @@ -22,6 +22,7 @@ const createPendingLaunchProcess = (): Subprocess => { }), nodeChildProcess: Promise.resolve(child), }) as unknown as Subprocess; + return { subprocess, dispose: async () => { child.kill(); } }; }; describe('createIosAppSession', () => { @@ -94,52 +95,47 @@ describe('createIosAppSession', () => { } }); - it('disposes the session when the session-lifetime signal aborts', async () => { + it('does not dispose the session before explicit disposal', async () => { vi.useFakeTimers(); try { const launchProcess = createPendingLaunchProcess(); const isAppRunning = vi.fn<() => Promise>().mockResolvedValue(true); const stopApp = vi.fn(async () => undefined); - const controller = new AbortController(); const sessionPromise = createIosAppSession({ launch: () => launchProcess, stopApp, isAppRunning, - signal: controller.signal, }); await vi.advanceTimersByTimeAsync(100); const session = await sessionPromise; - controller.abort(); await vi.advanceTimersByTimeAsync(1000); await expect(session.getState()).resolves.toMatchObject({ - status: 'disposed', + status: 'running', }); + expect(stopApp).not.toHaveBeenCalled(); + await session.dispose(); expect(stopApp).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); - it('disposes immediately when created with an already-aborted signal', async () => { + it('remains running until explicitly disposed', async () => { vi.useFakeTimers(); try { const launchProcess = createPendingLaunchProcess(); const isAppRunning = vi.fn<() => Promise>().mockResolvedValue(true); const stopApp = vi.fn(async () => undefined); - const controller = new AbortController(); - controller.abort(); - const sessionPromise = createIosAppSession({ launch: () => launchProcess, stopApp, isAppRunning, - signal: controller.signal, }); await vi.advanceTimersByTimeAsync(100); @@ -147,8 +143,10 @@ describe('createIosAppSession', () => { await vi.advanceTimersByTimeAsync(1000); await expect(session.getState()).resolves.toMatchObject({ - status: 'disposed', + status: 'running', }); + expect(stopApp).not.toHaveBeenCalled(); + await session.dispose(); expect(stopApp).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts index a3c53b55..bd80c9d5 100644 --- a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts @@ -95,10 +95,9 @@ describe('iOS XCTest agent runner integration', () => { kind: 'simulator', id: 'sim-udid', }, - signal: initSignal, }); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); + expect(mocks.prepare).toHaveBeenCalledWith(initSignal); + expect(mocks.ensureStarted).toHaveBeenCalledWith(initSignal); expect(mocks.dispose).toHaveBeenCalledTimes(1); }); diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts index e26ca82c..0b6605dc 100644 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -30,6 +30,22 @@ vi.mock('@react-native-harness/tools', async () => { return { ...actual, spawn: mocks.spawn, + spawnOwnedProcess: ( + file: string, + args: string[], + options?: Record + ) => { + const subprocess = mocks.spawn(file, args, options); + let disposePromise: Promise | undefined; + + return { + subprocess, + dispose: () => + (disposePromise ??= actual.terminate(subprocess, { + forceAfterMs: 1_000, + })), + }; + }, }; }); @@ -89,13 +105,14 @@ const createLongRunningSubprocess = (options?: { const stop = () => { stopped = true; + childProcess.exitCode = 0; for (const listener of listeners) { listener(); } }; const childProcess = { - exitCode: null, + exitCode: null as number | null, kill: vi.fn((signal?: NodeJS.Signals) => { mocks.kill(signal); @@ -142,6 +159,17 @@ const createLongRunningSubprocess = (options?: { describe('xctest-agent orchestration', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.health.mockResolvedValue({ + permissions: { + autoAcceptPermissions: false, + }, + status: 'ok', + }); + mocks.configurePermissions.mockResolvedValue({ + autoAcceptPermissions: true, + }); + mocks.disposeClient.mockResolvedValue(undefined); + mocks.disposeTransport.mockResolvedValue(undefined); tempProjectRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'rn-harness-xctest-agent-') ); @@ -240,6 +268,10 @@ describe('xctest-agent orchestration', () => { }); afterEach(() => { + for (const stop of mocks.activeAgentStops) { + stop(); + } + mocks.activeAgentStops.length = 0; restoreEnvVar( 'HARNESS_IOS_XCTESTRUN_FILE', originalExternalXCTestRunFile @@ -271,7 +303,8 @@ describe('xctest-agent orchestration', () => { 'build-for-testing', '-destination', 'generic/platform=iOS Simulator', - ]) + ]), + { cwd: projectRoot, signal: undefined } ); const cacheDirectories = fs.readdirSync(simulatorCacheRoot); expect(cacheDirectories).toHaveLength(1); @@ -302,7 +335,8 @@ describe('xctest-agent orchestration', () => { 'build-for-testing', '-destination', 'generic/platform=iOS Simulator', - ]) + ]), + { cwd: projectRoot, signal: undefined } ); }); @@ -478,6 +512,59 @@ describe('xctest-agent orchestration', () => { expect(mocks.disposeClient).toHaveBeenCalledTimes(1); }); + it('aborts an in-flight readiness request when the session is cancelled', async () => { + const signal = new AbortController(); + mocks.health.mockImplementation(() => new Promise(() => undefined)); + const controller = createXCTestAgentController({ + port: 49152, + target: { + kind: 'simulator', + id: 'sim-999', + }, + }); + + const starting = controller.ensureStarted(signal.signal); + await vi.waitFor(() => expect(mocks.health).toHaveBeenCalledOnce()); + signal.abort(); + + await expect(starting).rejects.toMatchObject({ name: 'AbortError' }); + expect(mocks.disposeClient).toHaveBeenCalledOnce(); + }); + + it('aborts the finite XCTest build when preparation is cancelled', async () => { + const controller = new AbortController(); + let buildSignal: AbortSignal | undefined; + mocks.spawn.mockImplementation( + (file: string, args?: string[], options?: { signal?: AbortSignal }) => { + if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { + buildSignal = options?.signal; + return new Promise((_, reject) => { + buildSignal?.addEventListener( + 'abort', + () => reject(buildSignal?.reason), + { once: true } + ); + }); + } + + return createLongRunningSubprocess().subprocess; + } + ); + const xctest = createXCTestAgentController({ + target: { + kind: 'device', + id: 'device-555', + }, + }); + + const preparing = xctest.prepare(controller.signal); + await vi.waitFor(() => expect(buildSignal).toBeDefined()); + controller.abort(); + + await expect(preparing).rejects.toMatchObject({ name: 'AbortError' }); + expect(buildSignal?.aborted).toBe(true); + }); + it('selects the device transport for physical devices', async () => { const controller = createXCTestAgentController({ port: 49153, @@ -494,6 +581,7 @@ describe('xctest-agent orchestration', () => { deviceId: 'device-555', port: 49153, }); + await controller.dispose(); }); it('requests graceful shutdown during disposal', async () => { @@ -682,7 +770,8 @@ describe('xctest-agent orchestration', () => { expect(mocks.spawn).toHaveBeenNthCalledWith( 3, 'xcodebuild', - expect.arrayContaining(['build-for-testing']) + expect.arrayContaining(['build-for-testing']), + { cwd: projectRoot, signal: undefined } ); }); diff --git a/packages/platform-ios/src/app-session.ts b/packages/platform-ios/src/app-session.ts index 94576b0b..795f12db 100644 --- a/packages/platform-ios/src/app-session.ts +++ b/packages/platform-ios/src/app-session.ts @@ -8,27 +8,19 @@ import { import { delay, logger, - terminate, - type Subprocess, + type OwnedProcess, } from '@react-native-harness/tools'; import type { IosCrashReporter } from './crash-reporter.js'; const iosAppSessionLogger = logger.child('ios-app-session'); const APP_EXIT_POLL_INTERVAL_MS = 1000; const LAUNCH_FAILURE_SETTLE_MS = 100; -const LAUNCH_PROCESS_FORCE_KILL_AFTER_MS = 2000; type CreateIosAppSessionOptions = { - launch: () => Subprocess; + launch: () => OwnedProcess; stopApp: () => Promise; isAppRunning: () => Promise; crashReporter?: IosCrashReporter; - /** - * Session-lifetime abort signal (see HarnessPlatformInitOptions.signal). - * Terminates the `--console` launch process on session teardown, in - * addition to the normal dispose() path. - */ - signal?: AbortSignal; }; export const createIosAppSession = async ({ @@ -36,11 +28,11 @@ export const createIosAppSession = async ({ stopApp, isAppRunning, crashReporter, - signal, }: CreateIosAppSessionOptions): Promise => { const emitter = createAppSessionEmitter(); const logBuffer = createBoundedLogBuffer(); - const launchProcess = launch(); + const ownedLaunchProcess = launch(); + const launchProcess = ownedLaunchProcess.subprocess; let state: AppSessionState = { status: 'running' }; let disposed = false; let stopPolling = false; @@ -157,29 +149,21 @@ export const createIosAppSession = async ({ throw new Error('The iOS app launch finished before the app was running.'); } - const dispose = async () => { - if (disposed) { - return; - } - - disposed = true; - stopPolling = true; - cancelPendingPollDelay(); - state = { status: 'disposed', occurredAt: Date.now() }; - emitter.clear(); - - await terminate(launchProcess, { - forceAfterMs: LAUNCH_PROCESS_FORCE_KILL_AFTER_MS, - }); - await stopApp(); - await Promise.allSettled([logTask, exitTask, pollTask]); - }; - - if (signal?.aborted) { - void dispose(); - } else { - signal?.addEventListener('abort', () => void dispose(), { once: true }); - } + let disposePromise: Promise | undefined; + const dispose = () => + (disposePromise ??= (async () => { + if (disposed) return; + + disposed = true; + stopPolling = true; + cancelPendingPollDelay(); + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + + await ownedLaunchProcess.dispose(); + await stopApp(); + await Promise.allSettled([logTask, exitTask, pollTask]); + })()); return { dispose, diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 9b51929f..429e9cf0 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -86,7 +86,6 @@ export const getAppleSimulatorPlatformInstance = async ( id: udid, }, capabilities: [createPermissionPromptAutoAcceptCapability()], - signal: init.signal, }) : null; @@ -153,7 +152,7 @@ export const getAppleSimulatorPlatformInstance = async ( ); const xctestPreparation = overlapStartup - ? xctestAgent.prepare() + ? xctestAgent.prepare(init.signal) : undefined; // Observe an early build rejection while preparing the simulator; the // original promise is still awaited below so its error is propagated. @@ -162,12 +161,12 @@ export const getAppleSimulatorPlatformInstance = async ( let agentStarted = false; try { if (!overlapStartup) { - await xctestAgent.prepare(); + await xctestAgent.prepare(init.signal); } await prepareSimulator(); await xctestPreparation; - await xctestAgent.ensureStarted(); + await xctestAgent.ensureStarted(init.signal); agentStarted = true; } catch (error) { await xctestPreparation?.catch(() => undefined); @@ -215,7 +214,6 @@ export const getAppleSimulatorPlatformInstance = async ( stopApp: () => simctl.stopApp(udid, config.bundleId), isAppRunning: () => simctl.isAppRunning(udid, config.bundleId), crashReporter, - signal: init.signal, }); }, dispose: async () => { @@ -258,7 +256,7 @@ export const getApplePhysicalDevicePlatformInstance = async ( ); } - const device = await devicectl.getDevice(config.device.name); + const device = await devicectl.getDevice(config.device.name, init?.signal); if (!device) { throw new DeviceNotFoundError(getDeviceName(config.device)); @@ -285,14 +283,13 @@ export const getApplePhysicalDevicePlatformInstance = async ( codeSign: config.device.codeSign, }, capabilities: [createPermissionPromptAutoAcceptCapability()], - signal: init?.signal, }) : null; if (xctestAgent) { let agentStarted = false; try { - await xctestAgent.ensureStarted(); + await xctestAgent.ensureStarted(init?.signal); agentStarted = true; } finally { if (!agentStarted) { @@ -328,7 +325,6 @@ export const getApplePhysicalDevicePlatformInstance = async ( stopApp: () => devicectl.stopApp(deviceId, config.bundleId), isAppRunning: () => devicectl.isAppRunning(deviceId, config.bundleId), crashReporter, - signal: init?.signal, }); }, dispose: async () => { diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index 78c67893..905540a7 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -1,5 +1,9 @@ import { type AppleAppLaunchOptions } from '@react-native-harness/platforms'; -import { spawn, type Subprocess } from '@react-native-harness/tools'; +import { + runCommand, + spawnOwnedProcess, + type OwnedProcess, +} from '@react-native-harness/tools'; import fs from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,32 +13,45 @@ import { DeviceNotFoundError, } from './devicectl-errors.js'; +const DEVICECTL_NATIVE_TIMEOUT_SECONDS = 60; +const DEVICECTL_TIMEOUT_MS = 65_000; + +const commandSignal = (signal?: AbortSignal) => + signal ?? new AbortController().signal; + export const devicectl = async ( command: string, - args: string[] + args: string[], + options?: { signal?: AbortSignal } ): Promise => { const tempFile = join(tmpdir(), `devicectl-${randomUUID()}.json`); const separatorIndex = args.indexOf('--'); const argsWithJsonOutput = separatorIndex === -1 - ? [...args, '--json-output', tempFile] + ? [...args, '--timeout', String(DEVICECTL_NATIVE_TIMEOUT_SECONDS), '--json-output', tempFile] : [ ...args.slice(0, separatorIndex), + '--timeout', + String(DEVICECTL_NATIVE_TIMEOUT_SECONDS), '--json-output', tempFile, ...args.slice(separatorIndex), ]; - await spawn('xcrun', ['devicectl', command, ...argsWithJsonOutput]); - - if (!fs.existsSync(tempFile)) { - throw new Error(`devicectl did not produce JSON output at ${tempFile}`); - } + try { + await runCommand('xcrun', ['devicectl', command, ...argsWithJsonOutput], { + signal: commandSignal(options?.signal), + timeoutMs: DEVICECTL_TIMEOUT_MS, + }); - const output = fs.readFileSync(tempFile, 'utf8'); - fs.unlinkSync(tempFile); + if (!fs.existsSync(tempFile)) { + throw new Error(`devicectl did not produce JSON output at ${tempFile}`); + } - return JSON.parse(output).result; + return JSON.parse(fs.readFileSync(tempFile, 'utf8')).result; + } finally { + fs.rmSync(tempFile, { force: true }); + } }; export type AppleDeviceInfo = { @@ -71,10 +88,10 @@ type AppleDeviceNetworkProperties = { ipAddress?: string; }; -export const listDevices = async (): Promise => { +export const listDevices = async (signal?: AbortSignal): Promise => { const result = await devicectl<{ devices: AppleDeviceInfo[] }>('list', [ 'devices', - ]); + ], { signal }); return result.devices; }; @@ -85,14 +102,15 @@ type AppleDeviceDetailsResult = }; export const getDeviceDetails = async ( - identifier: string + identifier: string, + signal?: AbortSignal ): Promise => { const result = await devicectl('device', [ 'info', 'details', '--device', identifier, - ]); + ], { signal }); return 'device' in result ? result.device : result; }; @@ -213,8 +231,8 @@ export const launchAppProcess = ( identifier: string, bundleId: string, options?: AppleAppLaunchOptions -): Subprocess => - spawn( +): OwnedProcess => + spawnOwnedProcess( 'xcrun', [ 'devicectl', @@ -377,9 +395,10 @@ export const isMatchingDevice = ( }; export const getDevice = async ( - identifier: string + identifier: string, + signal?: AbortSignal ): Promise => { - const devices = await listDevices(); + const devices = await listDevices(signal); const matchingDevice = devices.find((device) => { return isMatchingDevice(device, identifier); }); diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index 5ad7d059..720fd3b0 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -6,8 +6,9 @@ import { logger, spawn, spawnAndForget, + spawnOwnedProcess, SubprocessError, - type Subprocess, + type OwnedProcess, } from '@react-native-harness/tools'; import fs from 'node:fs'; import { homedir } from 'node:os'; @@ -305,11 +306,11 @@ export const launchAppProcess = ( udid: string, bundleId: string, options?: AppleAppLaunchOptions, -): Subprocess => { +): OwnedProcess => { const environment = getSimctlChildEnvironment(options); const argumentsList = options?.arguments ?? []; - return spawn( + return spawnOwnedProcess( 'xcrun', [ 'simctl', @@ -371,8 +372,8 @@ export const diagnose = async ( export const streamLogs = ( udid: string, predicate: string, -): Subprocess => - spawn( +): OwnedProcess => + spawnOwnedProcess( 'xcrun', [ 'simctl', diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts index 3da13449..dd437620 100644 --- a/packages/platform-ios/src/xctest-agent.ts +++ b/packages/platform-ios/src/xctest-agent.ts @@ -5,7 +5,9 @@ import { delay as cancellableDelay, logger, spawn, - terminate, + spawnOwnedProcess, + waitForAbort, + type OwnedProcess, type Subprocess, } from '@react-native-harness/tools'; import fs from 'node:fs'; @@ -35,7 +37,6 @@ const XCTEST_AGENT_XCTESTRUN_FILE_ENV = 'HARNESS_IOS_XCTESTRUN_FILE'; const XCTEST_AGENT_DERIVED_DATA_PATH_ENV = 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH'; const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 120_000; -const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 30_000; const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250; const HARNESS_DIRNAME = '.harness'; const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; @@ -55,6 +56,7 @@ export type BuildXCTestAgentOptions = { destination: XCTestAgentBuildDestination; signing?: XCTestAgentBuildSigning; projectRoot?: string; + signal?: AbortSignal; }; export type BuildXCTestAgentResult = { @@ -114,8 +116,8 @@ type ExternalXCTestConfiguration = { }; export type XCTestAgentController = { - prepare: () => Promise; - ensureStarted: () => Promise; + prepare: (signal?: AbortSignal) => Promise; + ensureStarted: (signal?: AbortSignal) => Promise; stop: () => Promise; dispose: () => Promise; }; @@ -478,27 +480,31 @@ const findReusableSimulatorBuildArtifacts = ( return null; }; -const getCurrentXcodeVersion = async (): Promise => { - const { stdout } = await spawn('xcodebuild', ['-version']); +const getCurrentXcodeVersion = async ( + signal?: AbortSignal +): Promise => { + const { stdout } = await spawn('xcodebuild', ['-version'], { signal }); return stdout.trim(); }; -const getCurrentSimulatorSdkVersion = async (): Promise => { - const { stdout } = await spawn('xcodebuild', [ - '-version', - '-sdk', - 'iphonesimulator', - 'SDKVersion', - ]); +const getCurrentSimulatorSdkVersion = async ( + signal?: AbortSignal +): Promise => { + const { stdout } = await spawn( + 'xcodebuild', + ['-version', '-sdk', 'iphonesimulator', 'SDKVersion'], + { signal } + ); return stdout.trim(); }; const getSimulatorCacheContext = async ( - buildInputsHash: string + buildInputsHash: string, + signal?: AbortSignal ): Promise => { const [xcodeVersion, simulatorSdkVersion] = await Promise.all([ - getCurrentXcodeVersion(), - getCurrentSimulatorSdkVersion(), + getCurrentXcodeVersion(signal), + getCurrentSimulatorSdkVersion(signal), ]); return { @@ -585,7 +591,10 @@ export const buildXCTestAgent = async ( assertXCTestAgentProjectExists(); if (options.destination === 'simulator') { - simulatorCacheContext = await getSimulatorCacheContext(buildInputsHash); + simulatorCacheContext = await getSimulatorCacheContext( + buildInputsHash, + options.signal + ); const reusableDerivedDataPath = findReusableSimulatorBuildArtifacts( simulatorCacheContext, projectRoot @@ -672,7 +681,10 @@ export const buildXCTestAgent = async ( ...signingArgs, ]; - await spawn('xcodebuild', buildArgs); + await spawn('xcodebuild', buildArgs, { + cwd: getXCTestAgentProjectRoot(), + signal: options.signal, + }); const xctestrunRelativePath = getXCTestRunRelativePath(derivedDataPath); @@ -735,26 +747,54 @@ const getRuntimeConfiguration = ( }, getDefaultRuntimeConfiguration()); }; -// Sequential wait between readiness polls; not raced against anything, so -// the plain (uncancelled) promise is fine here. -const delay = async (ms: number) => { - await cancellableDelay(ms).promise; +const delay = async (ms: number, signal?: AbortSignal) => { + if (!signal) { + await cancellableDelay(ms).promise; + return; + } + + signal.throwIfAborted(); + const abortWait = waitForAbort(signal); + const timeout = cancellableDelay(ms); + try { + await Promise.race([timeout.promise, abortWait.promise]); + } finally { + abortWait.cancel(); + timeout.cancel(); + } +}; + +const waitForAbortable = async (promise: Promise, signal?: AbortSignal): Promise => { + if (!signal) { + return await promise; + } + + signal.throwIfAborted(); + const abortWait = waitForAbort(signal); + try { + return await Promise.race([promise, abortWait.promise]); + } finally { + abortWait.cancel(); + } }; const waitForAgentReady = async (options: { client: ReturnType; startupTimeoutMs: number; + signal?: AbortSignal; }) => { const deadline = Date.now() + options.startupTimeoutMs; let lastError: unknown = null; while (Date.now() < deadline) { + options.signal?.throwIfAborted(); try { - await options.client.health(); + await waitForAbortable(options.client.health(), options.signal); + options.signal?.throwIfAborted(); return; } catch (error) { lastError = error; - await delay(XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS); + await delay(XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS, options.signal); } } @@ -765,63 +805,6 @@ const waitForAgentReady = async (options: { ); }; -const waitForShutdown = async (options: { - processTask: Promise | null; - shutdownTimeoutMs: number; -}): Promise => { - if (!options.processTask) { - return true; - } - - const timedOut = Symbol('timedOut'); - const shutdownTimer = cancellableDelay(options.shutdownTimeoutMs); - try { - const result = await Promise.race([ - options.processTask.then(() => undefined), - shutdownTimer.promise.then(() => timedOut), - ]); - - return result !== timedOut; - } finally { - shutdownTimer.cancel(); - } -}; - -const waitForGracefulShutdown = async (options: { - client: ReturnType; - processTask: Promise | null; - shutdownTimeoutMs: number; -}): Promise<{ didStop: boolean; requestError: unknown | null }> => { - let requestError: unknown | null = null; - const timedOut = Symbol('timedOut'); - const shutdownTimer = cancellableDelay(options.shutdownTimeoutMs); - - try { - const result = await Promise.race([ - (async () => { - try { - await options.client.shutdown(); - } catch (error) { - requestError = error; - } - - return await waitForShutdown({ - processTask: options.processTask, - shutdownTimeoutMs: options.shutdownTimeoutMs, - }); - })(), - shutdownTimer.promise.then(() => timedOut), - ]); - - return { - didStop: result !== timedOut && result === true, - requestError, - }; - } finally { - shutdownTimer.cancel(); - } -}; - const waitForChildProcessExit = async (subprocess: Subprocess) => { const childProcess = await subprocess.nodeChildProcess; @@ -846,14 +829,13 @@ const waitForChildProcessExit = async (subprocess: Subprocess) => { }; const stopProcess = async (options: { - process: Subprocess | null; - shutdownTimeoutMs: number; + process: OwnedProcess | null; }) => { if (!options.process) { return; } - await terminate(options.process, { forceAfterMs: options.shutdownTimeoutMs }); + await options.process.dispose(); }; const toTestRunnerEnv = (env: Record): Record => @@ -925,17 +907,14 @@ export const createXCTestAgentController = (options: { target: XCTestAgentTarget; capabilities?: XCTestAgentCapability[]; port?: number; - shutdownTimeoutMs?: number; startupTimeoutMs?: number; - /** Session-lifetime abort signal (see HarnessPlatformInitOptions.signal). */ - signal?: AbortSignal; + /** Maximum time for the XCTest client to close gracefully. */ + shutdownTimeoutMs?: number; }): XCTestAgentController => { const { target } = options; const capabilities = options.capabilities ?? []; const startupTimeoutMs = options.startupTimeoutMs ?? XCTEST_AGENT_STARTUP_TIMEOUT_MS; - const shutdownTimeoutMs = - options.shutdownTimeoutMs ?? XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS; const logArtifacts = createHarnessArtifactDirectory({ artifactType: 'logs', bundleId: options.appBundleId, @@ -949,9 +928,8 @@ export const createXCTestAgentController = (options: { let preparedDerivedDataPath = getXCTestAgentDerivedDataPath(target.kind); let preparedXCTestRunFilePath: string | null = null; let prepared = false; - let agentProcess: Subprocess | null = null; + let agentProcess: OwnedProcess | null = null; let agentClient: ReturnType | null = null; - let processTask: Promise | null = null; const getLaunchEnvironment = (): Record => { const tickIntervalMs = getEnvironmentPath(XCTEST_AGENT_TICK_INTERVAL_MS_ENV); @@ -985,7 +963,7 @@ export const createXCTestAgentController = (options: { }); }; - const prepare = async () => { + const prepare = async (signal?: AbortSignal) => { if (prepared) { return; } @@ -1017,6 +995,7 @@ export const createXCTestAgentController = (options: { const buildResult = await buildXCTestAgent({ destination: target.kind, signing, + signal, }); preparedDerivedDataPath = buildResult.derivedDataPath; @@ -1024,8 +1003,8 @@ export const createXCTestAgentController = (options: { prepared = true; }; - const ensureStarted = async () => { - await prepare(); + const ensureStarted = async (signal?: AbortSignal) => { + await prepare(signal); if (agentProcess && agentClient) { return; @@ -1061,7 +1040,7 @@ export const createXCTestAgentController = (options: { '-derivedDataPath', preparedDerivedDataPath, ]; - agentProcess = spawn('xcodebuild', xcodebuildArgs, { + agentProcess = spawnOwnedProcess('xcodebuild', xcodebuildArgs, { cwd: getXCTestAgentProjectRoot(), env: { ...process.env, @@ -1074,7 +1053,7 @@ export const createXCTestAgentController = (options: { void attachProcessOutputLog({ command: ['xcodebuild', ...xcodebuildArgs].join(' '), logFilePath: xcodebuildLogPath, - process: agentProcess, + process: agentProcess.subprocess, }); xctestAgentLogger.info( 'Saving XCTest agent xcodebuild logs to %s', @@ -1082,8 +1061,8 @@ export const createXCTestAgentController = (options: { ); const currentProcess = agentProcess; - if (typeof currentProcess.catch === 'function') { - void currentProcess.catch((error) => { + if (typeof currentProcess.subprocess.catch === 'function') { + void currentProcess.subprocess.catch((error) => { xctestAgentLogger.debug('XCTest agent process stopped', error); }); } @@ -1091,11 +1070,10 @@ export const createXCTestAgentController = (options: { const client = createXCTestAgentClient(transport); agentClient = client; - processTask = waitForChildProcessExit(currentProcess).finally(() => { + void waitForChildProcessExit(currentProcess.subprocess).finally(() => { if (agentProcess === currentProcess) { agentProcess = null; agentClient = null; - processTask = null; } }); @@ -1103,6 +1081,7 @@ export const createXCTestAgentController = (options: { await waitForAgentReady({ client, startupTimeoutMs, + signal, }); await client.configurePermissions(runtimeConfiguration.permissions); } catch (error) { @@ -1112,9 +1091,9 @@ export const createXCTestAgentController = (options: { getErrorMessage(error), xcodebuildLogPath ); - await transport.dispose(); + await client.dispose(); agentClient = null; - await stopProcess({ process: currentProcess, shutdownTimeoutMs }); + await stopProcess({ process: currentProcess }); throw error; } }; @@ -1122,83 +1101,44 @@ export const createXCTestAgentController = (options: { const stop = async () => { const currentProcess = agentProcess; const currentClient = agentClient; - const currentProcessTask = processTask; agentProcess = null; agentClient = null; - processTask = null; xctestAgentLogger.info( 'Stopping XCTest agent session for %s target', target.kind ); - if (currentClient) { + const shutdownTimeout = cancellableDelay(options.shutdownTimeoutMs ?? 5_000); + let gracefulShutdownCompleted = false; + try { + if (currentClient) { + const gracefulExit = currentProcess + ? waitForChildProcessExit(currentProcess.subprocess) + : Promise.resolve(); + await Promise.race([ + (async () => { + await currentClient.shutdown(); + await gracefulExit; + gracefulShutdownCompleted = true; + })(), + shutdownTimeout.promise, + ]); + } + } finally { + shutdownTimeout.cancel(); try { - xctestAgentLogger.info( - 'Requesting XCTest agent graceful shutdown for %s target', - target.kind, - ); - - const gracefulShutdown = await waitForGracefulShutdown({ - client: currentClient, - processTask: currentProcessTask, - shutdownTimeoutMs, - }); - - if (gracefulShutdown.didStop) { - xctestAgentLogger.info( - 'XCTest agent session for %s target stopped gracefully', - target.kind, - ); - await currentClient.dispose(); - return; - } - - if (gracefulShutdown.requestError) { - xctestAgentLogger.warn( - 'XCTest agent graceful shutdown request failed for %s: %s', - target.kind, - getErrorMessage(gracefulShutdown.requestError), - ); + await currentClient?.dispose(); + } finally { + if (!gracefulShutdownCompleted) { + await stopProcess({ process: currentProcess }); } - - xctestAgentLogger.warn( - 'XCTest agent session for %s target did not stop gracefully after %dms; terminating xcodebuild', - target.kind, - shutdownTimeoutMs, - ); - } catch (error) { - xctestAgentLogger.warn( - 'XCTest agent graceful shutdown failed for %s: %s', - target.kind, - getErrorMessage(error), - ); } } - - await currentClient?.dispose(); - await stopProcess({ process: currentProcess, shutdownTimeoutMs }); }; - // Guards against the abort listener below redundantly re-running stop() - // when the caller has already disposed the controller explicitly (the - // normal teardown path always aborts the session signal afterwards too). - let disposed = false; - const dispose = async () => { - if (disposed) { - return; - } - disposed = true; - await stop(); - }; - - if (options.signal?.aborted) { - void dispose(); - } else { - options.signal?.addEventListener('abort', () => void dispose(), { - once: true, - }); - } + let disposePromise: Promise | undefined; + const dispose = () => (disposePromise ??= stop()); return { prepare, diff --git a/packages/platform-vega/package.json b/packages/platform-vega/package.json index f05feb67..93c369c2 100644 --- a/packages/platform-vega/package.json +++ b/packages/platform-vega/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/platform-vega", "description": "Vega platform for React Native Harness", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/platform-vega/src/__tests__/runner.test.ts b/packages/platform-vega/src/__tests__/runner.test.ts index ddf09100..465c64d0 100644 --- a/packages/platform-vega/src/__tests__/runner.test.ts +++ b/packages/platform-vega/src/__tests__/runner.test.ts @@ -40,7 +40,7 @@ describe('getVegaRunner', () => { expect(runner.dispose).toBeTypeOf('function'); }); - it('stops the app when the init signal aborts after session creation', async () => { + it('does not stop the app when the init signal aborts after session creation', async () => { const { default: getVegaRunner } = await import('../runner.js'); const controller = new AbortController(); @@ -52,6 +52,8 @@ describe('getVegaRunner', () => { controller.abort(); - expect(stopAppMock).toHaveBeenCalled(); + expect(stopAppMock).not.toHaveBeenCalled(); + await runner.dispose(); + expect(stopAppMock).toHaveBeenCalledOnce(); }); }); diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index a6272692..6c519631 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -15,7 +15,9 @@ const APP_EXIT_POLL_INTERVAL_MS = 1000; const getVegaRunner: HarnessPlatformRunnerFactory< VegaPlatformConfig, HarnessConfig -> = async (config, _harnessConfig, init) => { +> = async (config, _harnessConfig, _init) => { + void _harnessConfig; + void _init; const parsedConfig = VegaPlatformConfigSchema.parse(config); const deviceId = parsedConfig.device.deviceId; const bundleId = parsedConfig.bundleId; @@ -31,20 +33,6 @@ const getVegaRunner: HarnessPlatformRunnerFactory< throw new AppNotInstalledError(bundleId, deviceId); } - let currentAppSession: AppSession | null = null; - - // Session-lifetime signal (see HarnessPlatformInitOptions.signal): stop the - // poll loop and the app on session teardown, in addition to the normal - // dispose() path. - const disposeCurrentAppSessionOnAbort = () => void currentAppSession?.dispose(); - if (init.signal.aborted) { - disposeCurrentAppSessionOnAbort(); - } else { - init.signal.addEventListener('abort', disposeCurrentAppSessionOnAbort, { - once: true, - }); - } - return { createAppSession: async (): Promise => { await kepler.stopApp(deviceId, bundleId); @@ -122,7 +110,6 @@ const getVegaRunner: HarnessPlatformRunnerFactory< removeListener: emitter.removeListener, }; - currentAppSession = session; return session; }, dispose: async () => { diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 7a327f90..5a7d3a04 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/platform-web", "description": "Web platform for React Native Harness using Playwright", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/platform-web/src/__tests__/runner.test.ts b/packages/platform-web/src/__tests__/runner.test.ts index 8d4657ea..01b621a7 100644 --- a/packages/platform-web/src/__tests__/runner.test.ts +++ b/packages/platform-web/src/__tests__/runner.test.ts @@ -72,7 +72,7 @@ describe('getWebRunner', () => { expect(session.dispose).toBeTypeOf('function'); }); - it('closes the browser when the init signal aborts after session creation', async () => { + it('does not close the browser when the init signal aborts after session creation', async () => { const { default: getWebRunner } = await import('../runner.js'); const controller = new AbortController(); @@ -84,6 +84,8 @@ describe('getWebRunner', () => { controller.abort(); - expect(closeMock).toHaveBeenCalled(); + expect(closeMock).not.toHaveBeenCalled(); + await runner.dispose(); + expect(closeMock).toHaveBeenCalledOnce(); }); }); diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index c851b697..baa35709 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -11,25 +11,14 @@ import { WebPlatformConfigSchema, type WebPlatformConfig } from './config.js'; const getWebRunner: HarnessPlatformRunnerFactory< WebPlatformConfig, HarnessConfig -> = async (config, _harnessConfig, init) => { +> = async (config, _harnessConfig, _init) => { + void _harnessConfig; + void _init; const parsedConfig = WebPlatformConfigSchema.parse(config); let browser: Browser | null = null; let page: Page | null = null; - // Session-lifetime signal (see HarnessPlatformInitOptions.signal): close the - // browser on session teardown, in addition to the normal dispose() path. - const closeBrowserOnAbort = () => { - void browser?.close(); - browser = null; - page = null; - }; - if (init.signal.aborted) { - closeBrowserOnAbort(); - } else { - init.signal.addEventListener('abort', closeBrowserOnAbort, { once: true }); - } - const launchBrowser = async () => { const browserType = { chromium, diff --git a/packages/platforms/package.json b/packages/platforms/package.json index 108c859a..e35c2f93 100644 --- a/packages/platforms/package.json +++ b/packages/platforms/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/platforms", "description": "Core platform abstractions for React Native Harness", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 80c30f5c..85006436 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -126,12 +126,8 @@ export type HarnessPlatformRunner = { export type HarnessPlatformInitOptions = { /** - * Session-lifetime abort signal: it aborts when the harness session - * disposes (normal shutdown, error, or SIGINT/SIGTERM), not on a - * readiness/init timeout. Runners should hold onto it for as long as the - * session lives — e.g. to abort long-lived child processes and streams - * (device logs, launch processes, agent connections) on session teardown — - * rather than treating it as scoped to the init() call itself. + * Cancels finite initialization and readiness work. It is not an ownership + * or disposal signal: runners must explicitly dispose resources they own. */ signal: AbortSignal; crashArtifactWriter?: CrashArtifactWriter; diff --git a/packages/plugins/package.json b/packages/plugins/package.json index cfd5a2be..ec99b77f 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/plugins", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/react-native-harness/package.json b/packages/react-native-harness/package.json index 4c880366..89b0739f 100644 --- a/packages/react-native-harness/package.json +++ b/packages/react-native-harness/package.json @@ -1,6 +1,6 @@ { "name": "react-native-harness", - "version": "1.4.0", + "version": "1.4.1", "bin": { "react-native-harness": "./bin.js", "harness": "./bin.js" diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 841b9012..7a6d4539 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/runtime", "description": "The core test runtime that executes on React Native devices, providing Jest-compatible APIs (describe, it, expect) and managing test collection, execution, and result reporting in native environments.", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/tools/package.json b/packages/tools/package.json index ee0047b8..81694a23 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@react-native-harness/tools", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/tools/src/__tests__/owned-process.test.ts b/packages/tools/src/__tests__/owned-process.test.ts new file mode 100644 index 00000000..0c15f01a --- /dev/null +++ b/packages/tools/src/__tests__/owned-process.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { spawnOwnedProcess } from '../owned-process.js'; + +describe('spawnOwnedProcess', () => { + it('kills an owned child exactly once and shares concurrent disposal', async () => { + const ownedProcess = spawnOwnedProcess(globalThis.process.execPath, [ + '-e', + "process.stdout.write('ready\\n'); process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)", + ]); + await ownedProcess.subprocess.nodeChildProcess; + await new Promise((resolve) => setTimeout(resolve, 100)); + + const firstDispose = ownedProcess.dispose(); + const secondDispose = ownedProcess.dispose(); + + expect(secondDispose).toBe(firstDispose); + await firstDispose; + }); +}); diff --git a/packages/tools/src/__tests__/spawn.test.ts b/packages/tools/src/__tests__/spawn.test.ts new file mode 100644 index 00000000..181ed222 --- /dev/null +++ b/packages/tools/src/__tests__/spawn.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { runCommand } from '../spawn.js'; + +describe('runCommand', () => { + it('uses SIGKILL when a deadline expires', async () => { + const command = runCommand(globalThis.process.execPath, [ + '-e', + "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)", + ], { + signal: new AbortController().signal, + timeoutMs: 50, + }); + + await expect(command).rejects.toBeDefined(); + }); +}); diff --git a/packages/tools/src/__tests__/terminate.test.ts b/packages/tools/src/__tests__/terminate.test.ts index 8e663956..b8007857 100644 --- a/packages/tools/src/__tests__/terminate.test.ts +++ b/packages/tools/src/__tests__/terminate.test.ts @@ -69,6 +69,21 @@ describe('terminate', () => { expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); }); + it('returns after the post-SIGKILL bound when close is never observed', async () => { + vi.useFakeTimers(); + const childProcess = createMockChildProcess(); + const done = terminate(createMockSubprocess(childProcess), { + forceAfterMs: 1_000, + settleAfterForceMs: 500, + }); + + await vi.advanceTimersByTimeAsync(1_500); + await done; + + expect(childProcess.kill).toHaveBeenNthCalledWith(1, 'SIGTERM'); + expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); + }); + it('clears the force-kill timer once the process exits gracefully', async () => { vi.useFakeTimers(); const childProcess = createMockChildProcess({ exitOnKill: 'SIGTERM' }); diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 337a0f91..a00382a6 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -5,6 +5,7 @@ export * from './color.js'; export * from './logger.js'; export * from './prompts.js'; export * from './spawn.js'; +export * from './owned-process.js'; export * from './terminate.js'; export * from './react-native.js'; export * from './error.js'; diff --git a/packages/tools/src/owned-process.ts b/packages/tools/src/owned-process.ts new file mode 100644 index 00000000..66177caa --- /dev/null +++ b/packages/tools/src/owned-process.ts @@ -0,0 +1,37 @@ +import type { Subprocess } from 'nano-spawn'; +import { spawn, type SpawnOptions } from './spawn.js'; +import { terminate } from './terminate.js'; + +export type OwnedProcess = { + subprocess: Subprocess; + dispose: () => Promise; +}; + +export type OwnedProcessOptions = Omit< + SpawnOptions, + 'signal' | 'killSignal' | 'timeout' +>; + +const OWNED_PROCESS_FORCE_AFTER_MS = 1_000; + +/** Starts a process whose lifetime is explicitly owned by its caller. */ +export const spawnOwnedProcess = ( + file: string, + args: readonly string[], + options?: OwnedProcessOptions +): OwnedProcess => { + const subprocess = spawn(file, args, { + ...options, + }); + // The caller still receives the original subprocess. This observer only + // prevents expected cancellation from being an unhandled rejection. + void subprocess.catch(() => undefined); + let disposePromise: Promise | undefined; + + const dispose = () => + (disposePromise ??= (async () => { + await terminate(subprocess, { forceAfterMs: OWNED_PROCESS_FORCE_AFTER_MS }); + })()); + + return { subprocess, dispose }; +}; diff --git a/packages/tools/src/spawn.ts b/packages/tools/src/spawn.ts index e6642c1b..2013ea5c 100644 --- a/packages/tools/src/spawn.ts +++ b/packages/tools/src/spawn.ts @@ -1,8 +1,15 @@ -import type { Options, Subprocess } from 'nano-spawn'; +import type { Options, Result, Subprocess } from 'nano-spawn'; import nanoSpawn, { SubprocessError } from 'nano-spawn'; import { logger } from './logger.js'; export type SpawnOptions = Options; +export type RunCommandOptions = Omit< + SpawnOptions, + 'signal' | 'timeout' | 'killSignal' +> & { + signal: AbortSignal; + timeoutMs: number; +}; const spawnLogger = logger.child('spawn'); let signalsDeliverableEnsured = false; @@ -41,6 +48,23 @@ export const spawn = ( return nanoSpawn(file, args, { ...defaultOptions, ...options }); }; +/** + * Runs a command with the two bounds every finite Harness command needs. + * Keep this separate from `spawn`: streams and other owned processes have a + * different lifecycle and must be disposed by their domain owner. + */ +export const runCommand = ( + file: string, + args: readonly string[], + { signal, timeoutMs, ...options }: RunCommandOptions +): Promise => + spawn(file, args, { + ...options, + signal, + timeout: timeoutMs, + killSignal: 'SIGKILL', + }); + export const spawnAndForget = async ( file: string, args?: readonly string[], diff --git a/packages/tools/src/terminate.ts b/packages/tools/src/terminate.ts index 7f12549d..6c456595 100644 --- a/packages/tools/src/terminate.ts +++ b/packages/tools/src/terminate.ts @@ -25,6 +25,8 @@ const waitForExit = ( export type TerminateOptions = { /** How long to wait after SIGTERM before escalating to SIGKILL. */ forceAfterMs: number; + /** Maximum wait after SIGKILL before disposal returns. */ + settleAfterForceMs?: number; }; /** @@ -34,7 +36,7 @@ export type TerminateOptions = { */ export const terminate = async ( subprocess: Subprocess, - { forceAfterMs }: TerminateOptions + { forceAfterMs, settleAfterForceMs = forceAfterMs }: TerminateOptions ): Promise => { let childProcess: Awaited; try { @@ -65,6 +67,11 @@ export const terminate = async ( } catch { // Ignore termination failures for already-ended processes. } - await exited; + const settleTimeout = delay(settleAfterForceMs); + try { + await Promise.race([exited, settleTimeout.promise]); + } finally { + settleTimeout.cancel(); + } } }; diff --git a/packages/ui/package.json b/packages/ui/package.json index 0e4ba8b3..dc6d6068 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-harness/ui", "description": "Native UI testing module for React Native Harness.", - "version": "1.4.0", + "version": "1.4.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js",