From e340b05ddc5544be4f367399e1370be84562bd20 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 14:07:37 +0200 Subject: [PATCH 1/3] feat: queue harness runs on shared targets --- .../jest/src/__tests__/harness-cache.test.ts | 3 +- packages/jest/src/__tests__/harness.test.ts | 78 ++ .../jest/src/__tests__/resource-lock.test.ts | 118 +++ packages/jest/src/harness.ts | 966 ++++++++++-------- packages/jest/src/logs.ts | 16 +- packages/jest/src/resource-lock.ts | 449 ++++++++ packages/platform-android/src/factory.ts | 4 + packages/platform-ios/src/factory.ts | 4 + packages/platform-vega/src/factory.ts | 1 + packages/platform-web/src/factory.ts | 2 + packages/platforms/src/types.ts | 5 +- 11 files changed, 1202 insertions(+), 444 deletions(-) create mode 100644 packages/jest/src/__tests__/resource-lock.test.ts create mode 100644 packages/jest/src/resource-lock.ts diff --git a/packages/jest/src/__tests__/harness-cache.test.ts b/packages/jest/src/__tests__/harness-cache.test.ts index b81200e0..9139066a 100644 --- a/packages/jest/src/__tests__/harness-cache.test.ts +++ b/packages/jest/src/__tests__/harness-cache.test.ts @@ -24,6 +24,7 @@ const platform: HarnessPlatform = { platformId: 'ios', runner: '/virtual/platform-runner.js', config: {}, + getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; const createHarnessConfig = ( @@ -37,7 +38,7 @@ const createHarnessConfig = ( unstable__enableMetroCache: true, forwardClientLogs: false, ...overrides, - }) as HarnessConfig; + } as HarnessConfig); describe('maybeLogMetroCacheReuse', () => { beforeEach(() => { diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 4c545e40..ee326852 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -25,6 +25,9 @@ const mocks = vi.hoisted(() => ({ getMetroInstance: vi.fn(), isMetroCacheReusable: vi.fn(() => false), logMetroCacheReused: vi.fn(), + logRunnerStarting: vi.fn(), + logRunnerStillWaitingInQueue: vi.fn(), + logRunnerWaitingInQueue: vi.fn(), waitForMetroBackedAppReady: vi.fn(), })); @@ -48,6 +51,9 @@ vi.mock('@react-native-harness/bridge/server', () => ({ vi.mock('../logs.js', () => ({ logMetroCacheReused: mocks.logMetroCacheReused, + logRunnerStarting: mocks.logRunnerStarting, + logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue, + logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue, })); vi.mock('@react-native-harness/tools', async () => { @@ -337,6 +343,7 @@ describe('getHarness', () => { runner: `data:text/javascript,${encodeURIComponent( 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' )}`, + getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; const harness = await getHarness( @@ -402,6 +409,7 @@ describe('getHarness', () => { runner: `data:text/javascript,${encodeURIComponent( 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' )}`, + getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; const harness = await getHarness( @@ -511,6 +519,7 @@ describe('plugins', () => { runner: `data:text/javascript,${encodeURIComponent( 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' )}`, + getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; const harness = await getHarness( @@ -553,6 +562,75 @@ describe('plugins', () => { 'beforeDispose:1:normal', ]); }); + + it('waits in queue before starting Metro and releases the lock on dispose', async () => { + const resourceKey = 'ios:simulator:iPhone 17 Pro:26.2'; + const firstPlatformRunner = createPlatformRunner(); + const secondPlatformRunner = createPlatformRunner(); + const secondAppMonitor = createAppMonitor(); + const firstMetroInstance = createMetroInstance(); + const secondMetroInstance = createMetroInstance(); + const firstBridge = createBridgeServer(); + const secondBridge = createBridgeServer(); + + mocks.getBridgeServer + .mockResolvedValueOnce(firstBridge.serverBridge) + .mockResolvedValueOnce(secondBridge.serverBridge); + mocks.getMetroInstance + .mockResolvedValueOnce(firstMetroInstance) + .mockResolvedValueOnce(secondMetroInstance); + + let invocationCount = 0; + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => { + invocationCount += 1; + return invocationCount === 1 + ? firstPlatformRunner + : createPlatformRunner({ + createAppMonitor: () => secondAppMonitor.appMonitor, + dispose: secondPlatformRunner.dispose, + }); + }); + + const platform: HarnessPlatform = { + config: {}, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + getResourceLockKey: () => resourceKey, + }; + + const firstHarness = await getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + const secondHarnessPromise = getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + expect(mocks.logRunnerWaitingInQueue).toHaveBeenCalledWith(platform); + expect(mocks.logRunnerStarting).not.toHaveBeenCalled(); + expect(mocks.getMetroInstance).toHaveBeenCalledTimes(1); + + await firstHarness.dispose(); + const secondHarness = await secondHarnessPromise; + + expect(mocks.logRunnerStarting).toHaveBeenCalledWith(platform); + expect(mocks.getMetroInstance).toHaveBeenCalledTimes(2); + + await secondHarness.dispose(); + }); }); describe('StartupStallError', () => { diff --git a/packages/jest/src/__tests__/resource-lock.test.ts b/packages/jest/src/__tests__/resource-lock.test.ts new file mode 100644 index 00000000..c2e5e864 --- /dev/null +++ b/packages/jest/src/__tests__/resource-lock.test.ts @@ -0,0 +1,118 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + createResourceLockManager, + hashResourceLockKey, +} from '../resource-lock.js'; + +describe('resource lock manager', () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-') + ); + }); + + afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it('queues access in FIFO order', async () => { + const manager = createResourceLockManager({ + rootDir, + pollIntervalMs: 5, + heartbeatIntervalMs: 20, + staleLockTimeoutMs: 200, + }); + const order: string[] = []; + + const firstLease = await manager.acquire( + 'ios:simulator:iPhone 17 Pro:26.2' + ); + const secondAcquire = manager + .acquire('ios:simulator:iPhone 17 Pro:26.2', { + onWait: () => { + order.push('waiting'); + }, + }) + .then(async (lease) => { + order.push('acquired'); + await lease.release(); + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(order).toEqual(['waiting']); + + await firstLease.release(); + await secondAcquire; + + expect(order).toEqual(['waiting', 'acquired']); + }); + + it('removes the queued ticket when waiting is aborted', async () => { + const manager = createResourceLockManager({ + rootDir, + pollIntervalMs: 5, + heartbeatIntervalMs: 20, + staleLockTimeoutMs: 200, + }); + const key = 'android:emulator:Pixel_8_API_35'; + const firstLease = await manager.acquire(key); + const controller = new AbortController(); + + const acquirePromise = manager.acquire(key, { + signal: controller.signal, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + controller.abort(); + + await expect(acquirePromise).rejects.toMatchObject({ + name: 'AbortError', + }); + + const queueDir = path.join(rootDir, hashResourceLockKey(key), 'queue'); + const queuedEntries = await fs.readdir(queueDir); + expect(queuedEntries).toHaveLength(0); + + await firstLease.release(); + }); + + it('reclaims a stale owner before granting the lock', async () => { + const manager = createResourceLockManager({ + rootDir, + pollIntervalMs: 5, + heartbeatIntervalMs: 20, + staleLockTimeoutMs: 50, + isProcessActive: () => false, + }); + const key = 'web:browser:chromium'; + const keyDir = path.join(rootDir, hashResourceLockKey(key)); + const queueDir = path.join(keyDir, 'queue'); + const ownerFilePath = path.join(keyDir, 'owner.json'); + + await fs.mkdir(queueDir, { recursive: true }); + await fs.writeFile( + ownerFilePath, + JSON.stringify({ + ticketId: 'stale-owner', + key, + pid: 999999, + createdAt: Date.now() - 1000, + heartbeatAt: Date.now() - 1000, + }), + 'utf8' + ); + + const lease = await manager.acquire(key); + const owner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8')) as { + ticketId: string; + }; + expect(owner.ticketId).not.toBe('stale-owner'); + + await lease.release(); + }); +}); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index bfbfe63b..6acb7757 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -41,9 +41,16 @@ import { } from './crash-supervisor.js'; import { createClientLogListener } from './client-log-handler.js'; import path from 'node:path'; -import { logMetroCacheReused } from './logs.js'; +import { + logMetroCacheReused, + logRunnerStarting, + logRunnerStillWaitingInQueue, + logRunnerWaitingInQueue, +} from './logs.js'; +import { createResourceLockManager } from './resource-lock.js'; const harnessLogger = logger.child('runtime'); +const resourceLockManager = createResourceLockManager(); export type HarnessRunTestsOptions = Exclude; @@ -78,10 +85,7 @@ export const maybeLogMetroCacheReuse = ( platform: HarnessPlatform, projectRoot: string ): void => { - if ( - config.unstable__enableMetroCache && - isMetroCacheReusable(projectRoot) - ) { + if (config.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) { logMetroCacheReused(platform); } }; @@ -209,483 +213,571 @@ const getHarnessInternal = async ( platform.name, platform.platformId ); - maybeLogMetroCacheReuse(config, platform, projectRoot); - const pluginAbortController = new AbortController(); - const pluginManager = createHarnessPluginManager({ - plugins: (config.plugins ?? []) as Array< - HarnessPlugin - >, - projectRoot, - config, - runner: platform, - abortSignal: pluginAbortController.signal, - }); - let currentRun: HarnessRunState | null = null; - let activeTestFilePath: string | undefined; - const pendingHookPromises = new Set>(); - let pendingHookError: unknown; - - const getCurrentRunId = () => currentRun?.runId; - const toRelativeTestFilePath = (testFilePath?: string) => - testFilePath == null ? undefined : path.relative(projectRoot, testFilePath); - const setActiveTestFilePath = (testFilePath?: string) => { - activeTestFilePath = toRelativeTestFilePath(testFilePath); - }; - const flushPendingHooks = async () => { - if (pendingHookPromises.size > 0) { - await Promise.allSettled([...pendingHookPromises]); - } - - if (pendingHookError !== undefined) { - const error = pendingHookError; - pendingHookError = undefined; - throw error; - } - }; - const trackHook = (promise: Promise) => { - const trackedPromise = promise - .catch((error) => { - pendingHookError ??= error; - }) - .finally(() => { - pendingHookPromises.delete(trackedPromise); - }); - - pendingHookPromises.add(trackedPromise); - }; - const scheduleHook = < - TName extends keyof FlatHarnessHookContexts, - >( - name: TName, - payload: Omit< - FlatHarnessHookContexts[TName], - | 'plugin' - | 'logger' - | 'projectRoot' - | 'config' - | 'runner' - | 'platform' - | 'state' - | 'timestamp' - | 'abortSignal' - | 'meta' - > - ) => { - trackHook(pluginManager.callHook(name, payload)); - }; + const resourceLockKey = await platform.getResourceLockKey(); + let didWaitForResourceLock = false; + let lastStillWaitingLogAt = 0; + const resourceLease = await resourceLockManager.acquire(resourceLockKey, { + signal, + onWait: () => { + didWaitForResourceLock = true; + logRunnerWaitingInQueue(platform); + harnessLogger.debug( + 'waiting in queue for runner=%s key=%s', + platform.name, + resourceLockKey + ); + }, + onStillWaiting: (elapsedMs) => { + if (elapsedMs - lastStillWaitingLogAt < 5000) { + return; + } - const serverBridge = await getBridgeServer({ - noServer: true, - timeout: config.bridgeTimeout, - context, + lastStillWaitingLogAt = elapsedMs; + logRunnerStillWaitingInQueue(platform); + harnessLogger.debug( + 'still waiting in queue for runner=%s key=%s elapsedMs=%d', + platform.name, + resourceLockKey, + elapsedMs + ); + }, }); + if (didWaitForResourceLock) { + logRunnerStarting(platform); + } harnessLogger.debug( - 'starting Metro, platform runner, and bridge initialization' - ); - harnessLogger.debug( - 'bridge server initialized on Metro websocket path %s', - HARNESS_BRIDGE_PATH + 'resource lock acquired for runner=%s key=%s', + platform.name, + resourceLockKey ); - const [metroInstance, platformInstance] = await (async () => { - try { - return await Promise.all([ - getMetroInstance( - { - projectRoot, - harnessConfig: config, - websocketEndpoints: { - [HARNESS_BRIDGE_PATH]: - serverBridge.ws as unknown as MetroWebSocketEndpoint, - }, - }, - signal - ).then((instance) => { - harnessLogger.debug('Metro initialized'); - return instance; - }), - import(platform.runner).then((module) => - module.default(platform.config, config) - ).then((instance) => { - harnessLogger.debug('platform runner initialized'); - return instance; - }), - ]); - } catch (error) { - serverBridge.dispose(); - throw error; - } - })(); - const crashArtifactWriter = createCrashArtifactWriter({ - runnerName: platform.name, - platformId: platform.platformId, - }); - const appMonitor = platformInstance.createAppMonitor({ - crashArtifactWriter, - }); - const appLaunchOptions = ( - platform.config as { appLaunchOptions?: AppLaunchOptions } - ).appLaunchOptions; - - const clientLogListener = createClientLogListener(); - const bridgeEventListener = (event: BridgeEvents) => { - const runId = getCurrentRunId(); - if (!runId) { - return; - } + try { + maybeLogMetroCacheReuse(config, platform, projectRoot); + const pluginAbortController = new AbortController(); + const pluginManager = createHarnessPluginManager< + HarnessConfig, + HarnessPlatform + >({ + plugins: (config.plugins ?? []) as Array< + HarnessPlugin + >, + projectRoot, + config, + runner: platform, + abortSignal: pluginAbortController.signal, + }); + let currentRun: HarnessRunState | null = null; + let activeTestFilePath: string | undefined; + const pendingHookPromises = new Set>(); + let pendingHookError: unknown; + + const getCurrentRunId = () => currentRun?.runId; + const toRelativeTestFilePath = (testFilePath?: string) => + testFilePath == null + ? undefined + : path.relative(projectRoot, testFilePath); + const setActiveTestFilePath = (testFilePath?: string) => { + activeTestFilePath = toRelativeTestFilePath(testFilePath); + }; + const flushPendingHooks = async () => { + if (pendingHookPromises.size > 0) { + await Promise.allSettled([...pendingHookPromises]); + } - switch (event.type) { - case 'collection-started': - scheduleHook('collection:started', { - runId, - file: event.file, - }); - break; - case 'collection-finished': - scheduleHook('collection:finished', { - runId, - file: event.file, - duration: event.duration, - totalTests: event.totalTests, - }); - break; - case 'suite-started': - scheduleHook('suite:started', { - runId, - file: event.file, - name: event.name, - }); - break; - case 'suite-finished': - scheduleHook('suite:finished', { - runId, - file: event.file, - name: event.name, - duration: event.duration, - status: event.status, - error: event.error, - }); - break; - case 'test-started': - scheduleHook('test:started', { - runId, - file: event.file, - suite: event.suite, - name: event.name, - }); - break; - case 'test-finished': - scheduleHook('test:finished', { - runId, - file: event.file, - suite: event.suite, - name: event.name, - duration: event.duration, - status: event.status, - error: event.error, - }); - break; - case 'module-bundling-started': - scheduleHook('metro:bundle-started', { - runId, - target: 'module', - file: event.file, - }); - break; - case 'module-bundling-finished': - scheduleHook('metro:bundle-finished', { - runId, - target: 'module', - file: event.file, - duration: event.duration, - }); - break; - case 'module-bundling-failed': - scheduleHook('metro:bundle-failed', { - runId, - target: 'module', - file: event.file, - duration: event.duration, - error: event.error, - }); - break; - case 'setup-file-bundling-started': - scheduleHook('metro:bundle-started', { - runId, - target: 'setupFile', - file: event.file, - setupType: event.setupType, - }); - break; - case 'setup-file-bundling-finished': - scheduleHook('metro:bundle-finished', { - runId, - target: 'setupFile', - file: event.file, - setupType: event.setupType, - duration: event.duration, - }); - break; - case 'setup-file-bundling-failed': - scheduleHook('metro:bundle-failed', { - runId, - target: 'setupFile', - file: event.file, - setupType: event.setupType, - duration: event.duration, - error: event.error, + if (pendingHookError !== undefined) { + const error = pendingHookError; + pendingHookError = undefined; + throw error; + } + }; + const trackHook = (promise: Promise) => { + const trackedPromise = promise + .catch((error) => { + pendingHookError ??= error; + }) + .finally(() => { + pendingHookPromises.delete(trackedPromise); }); - break; - } - }; - const onMetroEvent = (event: ReportableEvent) => { - const runId = getCurrentRunId(); - - if (runId && event.type === 'client_log') { - scheduleHook('metro:client-log', { - runId, - level: event.level, - data: event.data, - }); - } - }; - const crashSupervisor = createCrashSupervisor({ - appMonitor, - platformRunner: platformInstance, - }); - const onReady = (device: DeviceDescriptor) => { - crashSupervisor.markReady(); + pendingHookPromises.add(trackedPromise); + }; + const scheduleHook = < + TName extends keyof FlatHarnessHookContexts< + object, + HarnessConfig, + HarnessPlatform + > + >( + name: TName, + payload: Omit< + FlatHarnessHookContexts[TName], + | 'plugin' + | 'logger' + | 'projectRoot' + | 'config' + | 'runner' + | 'platform' + | 'state' + | 'timestamp' + | 'abortSignal' + | 'meta' + > + ) => { + trackHook(pluginManager.callHook(name, payload)); + }; + + const serverBridge = await getBridgeServer({ + noServer: true, + timeout: config.bridgeTimeout, + context, + }); + harnessLogger.debug( + 'starting Metro, platform runner, and bridge initialization' + ); + harnessLogger.debug( + 'bridge server initialized on Metro websocket path %s', + HARNESS_BRIDGE_PATH + ); + const [metroInstance, platformInstance] = await (async () => { + try { + return await Promise.all([ + getMetroInstance( + { + projectRoot, + harnessConfig: config, + websocketEndpoints: { + [HARNESS_BRIDGE_PATH]: + serverBridge.ws as unknown as MetroWebSocketEndpoint, + }, + }, + signal + ).then((instance) => { + harnessLogger.debug('Metro initialized'); + return instance; + }), + import(platform.runner) + .then((module) => module.default(platform.config, config)) + .then((instance) => { + harnessLogger.debug('platform runner initialized'); + return instance; + }), + ]); + } catch (error) { + await Promise.allSettled([ + resourceLease.release(), + serverBridge.dispose(), + ]); + throw error; + } + })(); + const crashArtifactWriter = createCrashArtifactWriter({ + runnerName: platform.name, + platformId: platform.platformId, + }); + const appMonitor = platformInstance.createAppMonitor({ + crashArtifactWriter, + }); + const appLaunchOptions = ( + platform.config as { appLaunchOptions?: AppLaunchOptions } + ).appLaunchOptions; + + const clientLogListener = createClientLogListener(); + const bridgeEventListener = (event: BridgeEvents) => { + const runId = getCurrentRunId(); + if (!runId) { + return; + } - const runId = getCurrentRunId(); - if (!runId) { - return; - } + switch (event.type) { + case 'collection-started': + scheduleHook('collection:started', { + runId, + file: event.file, + }); + break; + case 'collection-finished': + scheduleHook('collection:finished', { + runId, + file: event.file, + duration: event.duration, + totalTests: event.totalTests, + }); + break; + case 'suite-started': + scheduleHook('suite:started', { + runId, + file: event.file, + name: event.name, + }); + break; + case 'suite-finished': + scheduleHook('suite:finished', { + runId, + file: event.file, + name: event.name, + duration: event.duration, + status: event.status, + error: event.error, + }); + break; + case 'test-started': + scheduleHook('test:started', { + runId, + file: event.file, + suite: event.suite, + name: event.name, + }); + break; + case 'test-finished': + scheduleHook('test:finished', { + runId, + file: event.file, + suite: event.suite, + name: event.name, + duration: event.duration, + status: event.status, + error: event.error, + }); + break; + case 'module-bundling-started': + scheduleHook('metro:bundle-started', { + runId, + target: 'module', + file: event.file, + }); + break; + case 'module-bundling-finished': + scheduleHook('metro:bundle-finished', { + runId, + target: 'module', + file: event.file, + duration: event.duration, + }); + break; + case 'module-bundling-failed': + scheduleHook('metro:bundle-failed', { + runId, + target: 'module', + file: event.file, + duration: event.duration, + error: event.error, + }); + break; + case 'setup-file-bundling-started': + scheduleHook('metro:bundle-started', { + runId, + target: 'setupFile', + file: event.file, + setupType: event.setupType, + }); + break; + case 'setup-file-bundling-finished': + scheduleHook('metro:bundle-finished', { + runId, + target: 'setupFile', + file: event.file, + setupType: event.setupType, + duration: event.duration, + }); + break; + case 'setup-file-bundling-failed': + scheduleHook('metro:bundle-failed', { + runId, + target: 'setupFile', + file: event.file, + setupType: event.setupType, + duration: event.duration, + error: event.error, + }); + break; + } + }; + const onMetroEvent = (event: ReportableEvent) => { + const runId = getCurrentRunId(); - scheduleHook('runtime:ready', { - runId, - device, + if (runId && event.type === 'client_log') { + scheduleHook('metro:client-log', { + runId, + level: event.level, + data: event.data, + }); + } + }; + const crashSupervisor = createCrashSupervisor({ + appMonitor, + platformRunner: platformInstance, }); - }; - const onDisconnect = () => { - const runId = getCurrentRunId(); - if (!runId) { - return; - } - scheduleHook('runtime:disconnected', { - runId, - reason: 'bridge-disconnected', - }); - }; - const onAppMonitorEvent = (event: AppMonitorEvent) => { - const runId = getCurrentRunId(); - if (!runId) { - return; - } + const onReady = (device: DeviceDescriptor) => { + crashSupervisor.markReady(); - if (event.type === 'app_started') { - scheduleHook('app:started', { - runId, - testFile: activeTestFilePath, - pid: event.pid, - source: event.source, - line: event.line, - }); - return; - } + const runId = getCurrentRunId(); + if (!runId) { + return; + } - if (event.type === 'app_exited') { - scheduleHook('app:exited', { + scheduleHook('runtime:ready', { runId, - testFile: activeTestFilePath, - pid: event.pid, - source: event.source, - line: event.line, - isConfirmed: event.isConfirmed, - crashDetails: event.crashDetails, + device, }); - return; - } + }; + const onDisconnect = () => { + const runId = getCurrentRunId(); + if (!runId) { + return; + } - if (event.type === 'possible_crash') { - scheduleHook('app:possible-crash', { + scheduleHook('runtime:disconnected', { runId, - testFile: activeTestFilePath, - pid: event.pid, - source: event.source, - line: event.line, - isConfirmed: event.isConfirmed, - crashDetails: event.crashDetails, + reason: 'bridge-disconnected', }); - } - }; + }; + const onAppMonitorEvent = (event: AppMonitorEvent) => { + const runId = getCurrentRunId(); + if (!runId) { + return; + } - serverBridge.on('ready', onReady); - serverBridge.on('disconnect', onDisconnect); - serverBridge.on('event', bridgeEventListener); - metroInstance.events.addListener(onMetroEvent); - appMonitor.addListener(onAppMonitorEvent); - harnessLogger.debug('registered runtime, bridge, and Metro listeners'); + if (event.type === 'app_started') { + scheduleHook('app:started', { + runId, + testFile: activeTestFilePath, + pid: event.pid, + source: event.source, + line: event.line, + }); + return; + } - if (config.forwardClientLogs) { - metroInstance.events.addListener(clientLogListener); - harnessLogger.debug('client log forwarding enabled'); - } + if (event.type === 'app_exited') { + scheduleHook('app:exited', { + runId, + testFile: activeTestFilePath, + pid: event.pid, + source: event.source, + line: event.line, + isConfirmed: event.isConfirmed, + crashDetails: event.crashDetails, + }); + return; + } - const dispose = async (reason: 'normal' | 'abort' | 'error' = 'normal') => { - harnessLogger.debug('disposing Harness (reason=%s)', reason); - let hookError: unknown; + if (event.type === 'possible_crash') { + scheduleHook('app:possible-crash', { + runId, + testFile: activeTestFilePath, + pid: event.pid, + source: event.source, + line: event.line, + isConfirmed: event.isConfirmed, + crashDetails: event.crashDetails, + }); + } + }; - try { - await flushPendingHooks(); - await pluginManager.callHook('harness:before-dispose', { - runId: currentRun?.runId, - reason, - summary: currentRun?.summary, - status: currentRun?.status, - error: currentRun?.error, - }); - await flushPendingHooks(); - } catch (error) { - hookError = error; - } + serverBridge.on('ready', onReady); + serverBridge.on('disconnect', onDisconnect); + serverBridge.on('event', bridgeEventListener); + metroInstance.events.addListener(onMetroEvent); + appMonitor.addListener(onAppMonitorEvent); + harnessLogger.debug('registered runtime, bridge, and Metro listeners'); if (config.forwardClientLogs) { - metroInstance.events.removeListener(clientLogListener); - } - metroInstance.events.removeListener(onMetroEvent); - appMonitor.removeListener(onAppMonitorEvent); - serverBridge.off('ready', onReady); - serverBridge.off('disconnect', onDisconnect); - serverBridge.off('event', bridgeEventListener); - await Promise.all([ - crashSupervisor.dispose(), - serverBridge.dispose(), - platformInstance.dispose(), - metroInstance.dispose(), - ]); - pluginAbortController.abort(); - harnessLogger.debug('Harness resources disposed'); - - if (hookError) { - throw hookError; + metroInstance.events.addListener(clientLogListener); + harnessLogger.debug('client log forwarding enabled'); } - }; - - if (signal.aborted) { - await dispose('abort'); - throw new DOMException('The operation was aborted', 'AbortError'); - } + const dispose = async (reason: 'normal' | 'abort' | 'error' = 'normal') => { + harnessLogger.debug('disposing Harness (reason=%s)', reason); + let hookError: unknown; - try { - await pluginManager.callHook('harness:before-creation', { - appLaunchOptions, - }); - await appMonitor.start(); - harnessLogger.debug('app monitor started'); - } catch (error) { - const runState = currentRun as HarnessRunState | null; + try { + await flushPendingHooks(); + await pluginManager.callHook('harness:after-run', { + runId: currentRun?.runId, + reason, + summary: currentRun?.summary, + status: currentRun?.status, + error: currentRun?.error, + }); + await flushPendingHooks(); + await pluginManager.callHook('harness:before-dispose', { + runId: currentRun?.runId, + reason, + summary: currentRun?.summary, + status: currentRun?.status, + error: currentRun?.error, + }); + await flushPendingHooks(); + } catch (error) { + hookError = error; + } - if (runState) { - runState.error = error; - currentRun = runState; - } - await dispose(error instanceof DOMException && error.name === 'AbortError' ? 'abort' : 'error'); - throw error; - } + if (config.forwardClientLogs) { + metroInstance.events.removeListener(clientLogListener); + } + metroInstance.events.removeListener(onMetroEvent); + appMonitor.removeListener(onAppMonitorEvent); + serverBridge.off('ready', onReady); + serverBridge.off('disconnect', onDisconnect); + serverBridge.off('event', bridgeEventListener); - const ensureAppReady = async (testFilePath: string) => { - await flushPendingHooks(); - setActiveTestFilePath(testFilePath); - crashSupervisor.setActiveTestFile(testFilePath); - harnessLogger.debug('ensuring app is ready for %s', testFilePath); + let cleanupError: unknown; + try { + await Promise.all([ + crashSupervisor.dispose(), + serverBridge.dispose(), + platformInstance.dispose(), + metroInstance.dispose(), + ]); + } catch (error) { + cleanupError = error; + } finally { + await resourceLease.release(); + pluginAbortController.abort(); + } + harnessLogger.debug('Harness resources disposed'); - if (crashSupervisor.isReady() && (await platformInstance.isAppRunning())) { - harnessLogger.debug('reusing existing ready app for %s', testFilePath); - return; - } + if (hookError) { + throw hookError; + } - crashSupervisor.reset(); - harnessLogger.debug('app not ready, waiting for launch and runtime readiness'); - await waitForAppReady({ - metroInstance, - serverBridge, - platformInstance: platformInstance as HarnessPlatformRunner, - platformId: platform.platformId, - bundleStartTimeout: config.bundleStartTimeout ?? 60000, - readyTimeout: config.bridgeTimeout, - maxAppRestarts: config.maxAppRestarts ?? 2, - testFilePath, - crashSupervisor, - appLaunchOptions, - }); - await flushPendingHooks(); - harnessLogger.debug('app is ready for %s', testFilePath); - }; + if (cleanupError) { + throw cleanupError; + } + }; - const restart = async (testFilePath?: string) => { - await flushPendingHooks(); - await crashSupervisor.stop(); - setActiveTestFilePath(testFilePath); - harnessLogger.debug( - 'restarting app (testFile=%s mode=%s)', - testFilePath ?? 'n/a', - testFilePath ? 'stop-and-ensure-ready' : 'direct-restart' - ); + if (signal.aborted) { + await dispose('abort'); - if (testFilePath) { - harnessLogger.debug('stopping app before restart'); - await platformInstance.stopApp(); - } else { - harnessLogger.debug('requesting direct app restart'); - await platformInstance.restartApp(appLaunchOptions); + throw new DOMException('The operation was aborted', 'AbortError'); } - crashSupervisor.reset(); - await crashSupervisor.start(); + try { + await pluginManager.callHook('harness:before-creation', { + appLaunchOptions, + }); + await flushPendingHooks(); + await appMonitor.start(); + harnessLogger.debug('app monitor started'); + await pluginManager.callHook('harness:before-run', { + appLaunchOptions, + }); + await flushPendingHooks(); + } catch (error) { + const runState = currentRun as HarnessRunState | null; - if (testFilePath) { - await ensureAppReady(testFilePath); + if (runState) { + runState.error = error; + currentRun = runState; + } + await dispose( + error instanceof DOMException && error.name === 'AbortError' + ? 'abort' + : 'error' + ); + throw error; } - await flushPendingHooks(); - harnessLogger.debug('restart completed'); - }; - - return { - context, - runTests: async (path, options) => { + const ensureAppReady = async (testFilePath: string) => { await flushPendingHooks(); - activeTestFilePath = path; - const client = serverBridge.rpc.clients.at(-1); - - if (!client) { - throw new Error('No client found'); + setActiveTestFilePath(testFilePath); + crashSupervisor.setActiveTestFile(testFilePath); + harnessLogger.debug('ensuring app is ready for %s', testFilePath); + + if ( + crashSupervisor.isReady() && + (await platformInstance.isAppRunning()) + ) { + harnessLogger.debug('reusing existing ready app for %s', testFilePath); + return; } - harnessLogger.debug('running test file on client: %s', path); - const result = await client.runTests(path, { - ...options, - runner: platform.runner, + crashSupervisor.reset(); + harnessLogger.debug( + 'app not ready, waiting for launch and runtime readiness' + ); + await waitForAppReady({ + metroInstance, + serverBridge, + platformInstance: platformInstance as HarnessPlatformRunner, + platformId: platform.platformId, + bundleStartTimeout: config.bundleStartTimeout ?? 60000, + readyTimeout: config.bridgeTimeout, + maxAppRestarts: config.maxAppRestarts ?? 2, + testFilePath, + crashSupervisor, + appLaunchOptions, }); await flushPendingHooks(); - return result; - }, - ensureAppReady, - restart, - dispose: () => dispose('normal'), - crashSupervisor, - callHook: async (name, payload) => { + harnessLogger.debug('app is ready for %s', testFilePath); + }; + + const restart = async (testFilePath?: string) => { await flushPendingHooks(); - await pluginManager.callHook(name, payload); + await crashSupervisor.stop(); + setActiveTestFilePath(testFilePath); + harnessLogger.debug( + 'restarting app (testFile=%s mode=%s)', + testFilePath ?? 'n/a', + testFilePath ? 'stop-and-ensure-ready' : 'direct-restart' + ); + + if (testFilePath) { + harnessLogger.debug('stopping app before restart'); + await platformInstance.stopApp(); + } else { + harnessLogger.debug('requesting direct app restart'); + await platformInstance.restartApp(appLaunchOptions); + } + + crashSupervisor.reset(); + await crashSupervisor.start(); + + if (testFilePath) { + await ensureAppReady(testFilePath); + } + await flushPendingHooks(); - }, - setRunState: (runState) => { - currentRun = runState; - }, - getRunState: () => currentRun, - }; + harnessLogger.debug('restart completed'); + }; + + return { + context, + runTests: async (path, options) => { + await flushPendingHooks(); + activeTestFilePath = path; + const client = serverBridge.rpc.clients.at(-1); + + if (!client) { + throw new Error('No client found'); + } + + harnessLogger.debug('running test file on client: %s', path); + const result = await client.runTests(path, { + ...options, + runner: platform.runner, + }); + await flushPendingHooks(); + return result; + }, + ensureAppReady, + restart, + dispose: () => dispose('normal'), + crashSupervisor, + callHook: async (name, payload) => { + await flushPendingHooks(); + await pluginManager.callHook(name, payload); + await flushPendingHooks(); + }, + setRunState: (runState) => { + currentRun = runState; + }, + getRunState: () => currentRun, + }; + } catch (error) { + await resourceLease.release(); + throw error; + } }; export const getHarness = async ( diff --git a/packages/jest/src/logs.ts b/packages/jest/src/logs.ts index 88b23ad8..0527252b 100644 --- a/packages/jest/src/logs.ts +++ b/packages/jest/src/logs.ts @@ -25,10 +25,20 @@ export const logTestEnvironmentReady = (runner: HarnessPlatform): void => { log(`${TAG} Runner ${chalk.bold(runner.name)} ready\n`); }; +export const logRunnerWaitingInQueue = (runner: HarnessPlatform): void => { + log(`${TAG} Runner ${chalk.bold(runner.name)} is busy, waiting in queue\n`); +}; + +export const logRunnerStillWaitingInQueue = (runner: HarnessPlatform): void => { + log(`${TAG} Still waiting in queue for ${chalk.bold(runner.name)} runner\n`); +}; + +export const logRunnerStarting = (runner: HarnessPlatform): void => { + log(`${TAG} Runner ${chalk.bold(runner.name)} is starting\n`); +}; + export const logMetroPrewarmCompleted = (runner: HarnessPlatform): void => { - log( - `${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n` - ); + log(`${TAG} Metro pre-warm for ${chalk.bold(runner.name)} completed\n`); }; export const logMetroCacheReused = (runner: HarnessPlatform): void => { diff --git a/packages/jest/src/resource-lock.ts b/packages/jest/src/resource-lock.ts new file mode 100644 index 00000000..1727960e --- /dev/null +++ b/packages/jest/src/resource-lock.ts @@ -0,0 +1,449 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { logger, type HarnessLogger } from '@react-native-harness/tools'; + +const resourceLockLogger = logger.child('resource-lock'); + +const DEFAULT_ROOT_DIR = path.join(os.tmpdir(), 'react-native-harness-locks'); +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 2000; +const DEFAULT_STALE_LOCK_TIMEOUT_MS = 15000; + +type ResourceLockMetadata = { + ticketId: string; + key: string; + pid: number; + createdAt: number; + heartbeatAt: number; +}; + +export type ResourceLockAcquireOptions = { + signal?: AbortSignal; + onWait?: () => void; + onStillWaiting?: (elapsedMs: number) => void; +}; + +export type ResourceLease = { + release: () => Promise; +}; + +export type ResourceLockManager = { + acquire: ( + key: string, + options?: ResourceLockAcquireOptions + ) => Promise; +}; + +type ResourceLockManagerOptions = { + rootDir?: string; + pollIntervalMs?: number; + heartbeatIntervalMs?: number; + staleLockTimeoutMs?: number; + pid?: number; + logger?: HarnessLogger; + isProcessActive?: (pid: number) => boolean; +}; + +type LockPaths = { + rootDir: string; + keyDir: string; + queueDir: string; + ownerFilePath: string; +}; + +const wait = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +const createAbortError = () => + new DOMException('The operation was aborted', 'AbortError'); + +const waitForAbort = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(signal.reason ?? createAbortError()); + } + + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(signal.reason ?? createAbortError()); + }, + { once: true } + ); + }); +}; + +export const hashResourceLockKey = (key: string): string => { + return crypto.createHash('sha256').update(key).digest('hex'); +}; + +const getPathsForKey = (rootDir: string, key: string): LockPaths => { + const keyDir = path.join(rootDir, hashResourceLockKey(key)); + return { + rootDir, + keyDir, + queueDir: path.join(keyDir, 'queue'), + ownerFilePath: path.join(keyDir, 'owner.json'), + }; +}; + +const createTicketId = (createdAt: number, pid: number): string => { + return `${createdAt + .toString() + .padStart(16, '0')}-${pid}-${crypto.randomUUID()}`; +}; + +const isMissingFileError = (error: unknown): boolean => { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ENOENT' + ); +}; + +const isExclusiveCreateError = (error: unknown): boolean => { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'EEXIST' + ); +}; + +const ensureLockDirectories = async (paths: LockPaths): Promise => { + await fs.mkdir(paths.queueDir, { recursive: true }); +}; + +const readJsonFile = async (filePath: string): Promise => { + try { + const content = await fs.readFile(filePath, 'utf8'); + return JSON.parse(content) as T; + } catch (error) { + if (isMissingFileError(error)) { + return null; + } + + throw error; + } +}; + +const removeFileIfPresent = async (filePath: string): Promise => { + try { + await fs.rm(filePath, { force: true }); + } catch (error) { + if (!isMissingFileError(error)) { + throw error; + } + } +}; + +const isPidActive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ESRCH' + ); + } +}; + +const isMetadataStale = ( + metadata: ResourceLockMetadata, + now: number, + staleLockTimeoutMs: number, + isProcessActive: (pid: number) => boolean +): boolean => { + if (!isProcessActive(metadata.pid)) { + return true; + } + + return now - metadata.heartbeatAt > staleLockTimeoutMs; +}; + +const readQueueTickets = async ( + queueDir: string +): Promise => { + const ticketEntries = await fs.readdir(queueDir, { withFileTypes: true }); + const tickets = await Promise.all( + ticketEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map(async (entry) => ({ + name: entry.name, + metadata: await readJsonFile( + path.join(queueDir, entry.name) + ), + })) + ); + + return tickets + .filter( + (entry): entry is { name: string; metadata: ResourceLockMetadata } => + entry.metadata !== null + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => entry.metadata); +}; + +const cleanupQueue = async (options: { + paths: LockPaths; + staleLockTimeoutMs: number; + now: number; + currentTicketId: string; + logger: HarnessLogger; + isProcessActive: (pid: number) => boolean; +}): Promise => { + const { + paths, + staleLockTimeoutMs, + now, + currentTicketId, + logger, + isProcessActive, + } = options; + const tickets = await readQueueTickets(paths.queueDir); + const activeTickets: ResourceLockMetadata[] = []; + + for (const ticket of tickets) { + const isCurrentTicket = ticket.ticketId === currentTicketId; + const isStale = + !isCurrentTicket && + isMetadataStale(ticket, now, staleLockTimeoutMs, isProcessActive); + + if (isStale) { + logger.debug( + 'removing stale queued ticket %s for key %s', + ticket.ticketId, + ticket.key + ); + await removeFileIfPresent( + path.join(paths.queueDir, `${ticket.ticketId}.json`) + ); + continue; + } + + activeTickets.push(ticket); + } + + return activeTickets; +}; + +const maybeClearStaleOwner = async (options: { + ownerFilePath: string; + staleLockTimeoutMs: number; + now: number; + logger: HarnessLogger; + isProcessActive: (pid: number) => boolean; +}): Promise => { + const { ownerFilePath, staleLockTimeoutMs, now, logger, isProcessActive } = + options; + const owner = await readJsonFile(ownerFilePath); + + if (!owner) { + return null; + } + + if (!isMetadataStale(owner, now, staleLockTimeoutMs, isProcessActive)) { + return owner; + } + + logger.debug( + 'removing stale owner ticket %s for key %s', + owner.ticketId, + owner.key + ); + await removeFileIfPresent(ownerFilePath); + return null; +}; + +const claimOwnership = async ( + ownerFilePath: string, + metadata: ResourceLockMetadata +): Promise => { + try { + await fs.writeFile(ownerFilePath, JSON.stringify(metadata), { + encoding: 'utf8', + flag: 'wx', + }); + return true; + } catch (error) { + if (isExclusiveCreateError(error)) { + return false; + } + + throw error; + } +}; + +export const createResourceLockManager = ( + options: ResourceLockManagerOptions = {} +): ResourceLockManager => { + const rootDir = options.rootDir ?? DEFAULT_ROOT_DIR; + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const heartbeatIntervalMs = + options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + const staleLockTimeoutMs = + options.staleLockTimeoutMs ?? DEFAULT_STALE_LOCK_TIMEOUT_MS; + const pid = options.pid ?? process.pid; + const scopedLogger = options.logger ?? resourceLockLogger; + const isProcessActive = options.isProcessActive ?? isPidActive; + + return { + acquire: async (key, acquireOptions = {}) => { + const paths = getPathsForKey(rootDir, key); + await ensureLockDirectories(paths); + + const createdAt = Date.now(); + const ticketId = createTicketId(createdAt, pid); + const ticketPath = path.join(paths.queueDir, `${ticketId}.json`); + const metadata: ResourceLockMetadata = { + ticketId, + key, + pid, + createdAt, + heartbeatAt: createdAt, + }; + + await fs.writeFile(ticketPath, JSON.stringify(metadata), 'utf8'); + scopedLogger.debug('queued ticket %s for key %s', ticketId, key); + + let heartbeatTimer: NodeJS.Timeout | null = null; + let released = false; + let didNotifyWait = false; + const waitStartedAt = Date.now(); + + const release = async () => { + if (released) { + return; + } + + released = true; + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + + const owner = await readJsonFile( + paths.ownerFilePath + ); + if (owner?.ticketId === ticketId) { + await removeFileIfPresent(paths.ownerFilePath); + } + + await removeFileIfPresent(ticketPath); + scopedLogger.debug('released ticket %s for key %s', ticketId, key); + }; + + const startHeartbeat = () => { + heartbeatTimer = setInterval(async () => { + const nextHeartbeatAt = Date.now(); + const owner = await readJsonFile( + paths.ownerFilePath + ); + + if (released || owner?.ticketId !== ticketId) { + return; + } + + const nextMetadata: ResourceLockMetadata = { + ...owner, + heartbeatAt: nextHeartbeatAt, + }; + + if (released) { + return; + } + + await fs.writeFile( + paths.ownerFilePath, + JSON.stringify(nextMetadata), + 'utf8' + ); + scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId); + }, heartbeatIntervalMs); + heartbeatTimer.unref?.(); + }; + + try { + while (true) { + acquireOptions.signal?.throwIfAborted(); + + const now = Date.now(); + const activeTickets = await cleanupQueue({ + paths, + staleLockTimeoutMs, + now, + currentTicketId: ticketId, + logger: scopedLogger, + isProcessActive, + }); + const ownIndex = activeTickets.findIndex( + (entry) => entry.ticketId === ticketId + ); + + if (ownIndex === -1) { + throw new Error( + `Queued ticket ${ticketId} disappeared before acquisition.` + ); + } + + const owner = await maybeClearStaleOwner({ + ownerFilePath: paths.ownerFilePath, + staleLockTimeoutMs, + now, + logger: scopedLogger, + isProcessActive, + }); + + if (ownIndex === 0 && owner === null) { + const claimed = await claimOwnership(paths.ownerFilePath, { + ...metadata, + heartbeatAt: Date.now(), + }); + + if (claimed) { + await removeFileIfPresent(ticketPath); + startHeartbeat(); + scopedLogger.debug( + 'acquired lock for key %s with ticket %s', + key, + ticketId + ); + return { release }; + } + } + + if (!didNotifyWait) { + didNotifyWait = true; + acquireOptions.onWait?.(); + } + + acquireOptions.onStillWaiting?.(Date.now() - waitStartedAt); + scopedLogger.debug( + 'waiting for key %s with ticket %s at queue position %d', + key, + ticketId, + ownIndex + 1 + ); + + await Promise.race([ + wait(pollIntervalMs), + acquireOptions.signal + ? waitForAbort(acquireOptions.signal) + : new Promise(() => undefined), + ]); + } + } catch (error) { + await release(); + throw error; + } + }, + }; +}; diff --git a/packages/platform-android/src/factory.ts b/packages/platform-android/src/factory.ts index 217ff06f..89273413 100644 --- a/packages/platform-android/src/factory.ts +++ b/packages/platform-android/src/factory.ts @@ -31,4 +31,8 @@ export const androidPlatform = ( config, runner: import.meta.resolve('./runner.js'), platformId: 'android', + getResourceLockKey: () => + config.device.type === 'emulator' + ? `android:emulator:${config.device.name}` + : `android:physical:${config.device.manufacturer}:${config.device.model}`, }); diff --git a/packages/platform-ios/src/factory.ts b/packages/platform-ios/src/factory.ts index 2ae483f0..a4dfc237 100644 --- a/packages/platform-ios/src/factory.ts +++ b/packages/platform-ios/src/factory.ts @@ -26,4 +26,8 @@ export const applePlatform = ( config, runner: import.meta.resolve('./runner.js'), platformId: 'ios', + getResourceLockKey: () => + config.device.type === 'simulator' + ? `ios:simulator:${config.device.name}:${config.device.systemVersion}` + : `ios:physical:${config.device.name}`, }); diff --git a/packages/platform-vega/src/factory.ts b/packages/platform-vega/src/factory.ts index 915f9245..e24c499c 100644 --- a/packages/platform-vega/src/factory.ts +++ b/packages/platform-vega/src/factory.ts @@ -13,4 +13,5 @@ export const vegaPlatform = ( config, runner: import.meta.resolve('./runner.js'), platformId: 'vega', + getResourceLockKey: () => `vega:${config.device.deviceId}`, }); diff --git a/packages/platform-web/src/factory.ts b/packages/platform-web/src/factory.ts index 26e47ffb..fd77c00c 100644 --- a/packages/platform-web/src/factory.ts +++ b/packages/platform-web/src/factory.ts @@ -8,6 +8,8 @@ export const webPlatform = ( config, runner: import.meta.resolve('./runner.js'), platformId: 'web', + getResourceLockKey: () => + `web:browser:${config.browser.channel ?? config.browser.type}`, }); export const chromium = ( diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index d61cb108..4846e5a6 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -7,9 +7,7 @@ export type AppCrashDetails = { pid?: number; stackTrace?: string[]; rawLines?: string[]; - artifactType?: - | 'logcat' - | 'ios-crash-report'; + artifactType?: 'logcat' | 'ios-crash-report'; artifactPath?: string; }; @@ -117,6 +115,7 @@ export type HarnessPlatform> = { config: TConfig; runner: string; platformId: string; + getResourceLockKey: () => string | Promise; }; export type AndroidEmulatorRunTarget = { From 5246b37259329c5afdf5d0cd0eea1ba8b251f386 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 2 Apr 2026 10:25:05 +0200 Subject: [PATCH 2/3] fix: keep queued resource locks alive --- packages/jest/src/__tests__/harness.test.ts | 35 +++++++++ .../jest/src/__tests__/resource-lock.test.ts | 20 +++++ packages/jest/src/harness.ts | 6 +- packages/jest/src/resource-lock.ts | 73 ++++++++++--------- packages/platforms/src/types.ts | 2 +- 5 files changed, 98 insertions(+), 38 deletions(-) diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index d572c057..1ff8c7da 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -386,6 +386,41 @@ describe('getHarness', () => { await harness.dispose(); }); + it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => { + const { serverBridge } = createBridgeServer(); + const appMonitor = createAppMonitor(); + const platformInstance = createPlatformRunner({ + createAppMonitor: () => appMonitor.appMonitor, + }); + const metroInstance = createMetroInstance(); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance); + + const platform: HarnessPlatform = { + config: {}, + name: 'legacy-ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + const harness = await getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + await harness.dispose(); + }); + it('routes ensureAppReady through the shared Metro startup helper', async () => { const { serverBridge, emitReady } = createBridgeServer(); const appMonitor = createAppMonitor(); diff --git a/packages/jest/src/__tests__/resource-lock.test.ts b/packages/jest/src/__tests__/resource-lock.test.ts index c2e5e864..740a0dea 100644 --- a/packages/jest/src/__tests__/resource-lock.test.ts +++ b/packages/jest/src/__tests__/resource-lock.test.ts @@ -81,6 +81,26 @@ describe('resource lock manager', () => { await firstLease.release(); }); + it('keeps queued tickets alive while the waiting process is still active', async () => { + const manager = createResourceLockManager({ + rootDir, + pollIntervalMs: 5, + heartbeatIntervalMs: 20, + staleLockTimeoutMs: 30, + isProcessActive: () => true, + }); + const key = 'ios:simulator:iPhone 17 Pro:26.2'; + const firstLease = await manager.acquire(key); + + const secondAcquire = manager.acquire(key); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + await firstLease.release(); + const secondLease = await secondAcquire; + await secondLease.release(); + }); + it('reclaims a stale owner before granting the lock', async () => { const manager = createResourceLockManager({ rootDir, diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 41484846..3dddc414 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -99,6 +99,9 @@ export const maybeLogMetroCacheReuse = ( const createAbortError = () => new DOMException('The operation was aborted', 'AbortError'); +const getDefaultResourceLockKey = (platform: HarnessPlatform): string => + `${platform.platformId}:${platform.name}`; + const waitForAbort = (signal: AbortSignal): Promise => { if (signal.aborted) { return Promise.reject(signal.reason ?? createAbortError()); @@ -243,7 +246,8 @@ const getHarnessInternal = async ( platform.name, platform.platformId ); - const resourceLockKey = await platform.getResourceLockKey(); + const resourceLockKey = await (platform.getResourceLockKey?.() ?? + getDefaultResourceLockKey(platform)); let didWaitForResourceLock = false; let lastStillWaitingLogAt = 0; const resourceLease = await resourceLockManager.acquire(resourceLockKey, { diff --git a/packages/jest/src/resource-lock.ts b/packages/jest/src/resource-lock.ts index 1727960e..f9fcc5dc 100644 --- a/packages/jest/src/resource-lock.ts +++ b/packages/jest/src/resource-lock.ts @@ -60,22 +60,6 @@ const wait = async (ms: number): Promise => { const createAbortError = () => new DOMException('The operation was aborted', 'AbortError'); -const waitForAbort = (signal: AbortSignal): Promise => { - if (signal.aborted) { - return Promise.reject(signal.reason ?? createAbortError()); - } - - return new Promise((_, reject) => { - signal.addEventListener( - 'abort', - () => { - reject(signal.reason ?? createAbortError()); - }, - { once: true } - ); - }); -}; - export const hashResourceLockKey = (key: string): string => { return crypto.createHash('sha256').update(key).digest('hex'); }; @@ -168,6 +152,40 @@ const isMetadataStale = ( return now - metadata.heartbeatAt > staleLockTimeoutMs; }; +const isQueuedTicketStale = ( + metadata: ResourceLockMetadata, + isProcessActive: (pid: number) => boolean +): boolean => { + return !isProcessActive(metadata.pid); +}; + +const waitForPollInterval = ( + ms: number, + signal?: AbortSignal +): Promise => { + if (!signal) { + return wait(ms); + } + + if (signal.aborted) { + return Promise.reject(signal.reason ?? createAbortError()); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + reject(signal.reason ?? createAbortError()); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + }); +}; + const readQueueTickets = async ( queueDir: string ): Promise => { @@ -194,28 +212,18 @@ const readQueueTickets = async ( const cleanupQueue = async (options: { paths: LockPaths; - staleLockTimeoutMs: number; - now: number; currentTicketId: string; logger: HarnessLogger; isProcessActive: (pid: number) => boolean; }): Promise => { - const { - paths, - staleLockTimeoutMs, - now, - currentTicketId, - logger, - isProcessActive, - } = options; + const { paths, currentTicketId, logger, isProcessActive } = options; const tickets = await readQueueTickets(paths.queueDir); const activeTickets: ResourceLockMetadata[] = []; for (const ticket of tickets) { const isCurrentTicket = ticket.ticketId === currentTicketId; const isStale = - !isCurrentTicket && - isMetadataStale(ticket, now, staleLockTimeoutMs, isProcessActive); + !isCurrentTicket && isQueuedTicketStale(ticket, isProcessActive); if (isStale) { logger.debug( @@ -378,8 +386,6 @@ export const createResourceLockManager = ( const now = Date.now(); const activeTickets = await cleanupQueue({ paths, - staleLockTimeoutMs, - now, currentTicketId: ticketId, logger: scopedLogger, isProcessActive, @@ -433,12 +439,7 @@ export const createResourceLockManager = ( ownIndex + 1 ); - await Promise.race([ - wait(pollIntervalMs), - acquireOptions.signal - ? waitForAbort(acquireOptions.signal) - : new Promise(() => undefined), - ]); + await waitForPollInterval(pollIntervalMs, acquireOptions.signal); } } catch (error) { await release(); diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 8bb51896..406e9581 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -119,7 +119,7 @@ export type HarnessPlatform> = { config: TConfig; runner: string; platformId: string; - getResourceLockKey: () => string | Promise; + getResourceLockKey?: () => string | Promise; }; export type AndroidEmulatorRunTarget = { From 796d1b36062d6fb038037f5e58d0882436f50cb3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 2 Apr 2026 10:38:45 +0200 Subject: [PATCH 3/3] docs: add version plan for resource lock queueing --- .nx/version-plans/queue-runs-by-resource-lock.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/queue-runs-by-resource-lock.md diff --git a/.nx/version-plans/queue-runs-by-resource-lock.md b/.nx/version-plans/queue-runs-by-resource-lock.md new file mode 100644 index 00000000..2a9ddfb4 --- /dev/null +++ b/.nx/version-plans/queue-runs-by-resource-lock.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness now queues concurrent runs before starting Metro when they target the same locked resource, such as the same simulator, device, or browser. Queueing is keyed by the platform resource lock rather than the configured Metro port, so runs using different ports still wait if they target the same resource.