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 0000000..2a9ddfb --- /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. diff --git a/packages/jest/src/__tests__/harness-cache.test.ts b/packages/jest/src/__tests__/harness-cache.test.ts index b81200e..9139066 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 d2b38bb..1ff8c7d 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(), })); @@ -47,6 +50,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 () => { @@ -316,6 +322,7 @@ describe('getHarness', () => { const platform: HarnessPlatform = { config: {}, + getResourceLockKey: () => 'ios:test-platform-ready-timeout', name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( @@ -354,6 +361,7 @@ describe('getHarness', () => { const platform: HarnessPlatform = { config: {}, + getResourceLockKey: () => 'ios:test-platform-init-signal', name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( @@ -378,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(); @@ -418,6 +461,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( @@ -483,6 +527,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( @@ -612,6 +657,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( @@ -656,6 +702,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 0000000..740a0de --- /dev/null +++ b/packages/jest/src/__tests__/resource-lock.test.ts @@ -0,0 +1,138 @@ +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('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, + 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 06e6694..3dddc41 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -47,9 +47,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; @@ -92,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()); @@ -236,519 +246,581 @@ const getHarnessInternal = async ( platform.name, platform.platformId ); - 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]); - } - - 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< - 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 resourceLockKey = await (platform.getResourceLockKey?.() ?? + getDefaultResourceLockKey(platform)); + 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; - }), - withPlatformReadyTimeout({ - timeout: config.platformReadyTimeout, - signal, - work: async () => { - return await import(platform.runner) - .then((module) => - module.default(platform.config, config, { - signal, - } satisfies HarnessPlatformInitOptions) - ) - .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; + }), + withPlatformReadyTimeout({ + timeout: config.platformReadyTimeout, + signal, + work: async () => { + return await import(platform.runner) + .then((module) => + module.default(platform.config, config, { + signal, + } satisfies HarnessPlatformInitOptions) + ) + .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: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; - } + 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.addListener(clientLogListener); + harnessLogger.debug('client log forwarding enabled'); } - 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; - } - }; - - 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 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 (runState) { - runState.error = error; - currentRun = runState; - } - await dispose( - error instanceof DOMException && error.name === 'AbortError' - ? 'abort' - : 'error' - ); - throw error; - } + 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; + } - const ensureAppReady = async (testFilePath: string) => { - await flushPendingHooks(); - setActiveTestFilePath(testFilePath); - crashSupervisor.setActiveTestFile(testFilePath); - harnessLogger.debug('ensuring app is ready for %s', testFilePath); + 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); + 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 88b23ad..0527252 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 0000000..f9fcc5d --- /dev/null +++ b/packages/jest/src/resource-lock.ts @@ -0,0 +1,450 @@ +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'); + +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 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 => { + 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; + currentTicketId: string; + logger: HarnessLogger; + isProcessActive: (pid: number) => boolean; +}): Promise => { + 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 && isQueuedTicketStale(ticket, 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, + 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 waitForPollInterval(pollIntervalMs, acquireOptions.signal); + } + } catch (error) { + await release(); + throw error; + } + }, + }; +}; diff --git a/packages/platform-android/src/factory.ts b/packages/platform-android/src/factory.ts index 217ff06..8927341 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 2ae483f..a4dfc23 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 915f924..e24c499 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 26e47ff..fd77c00 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 a5c58a1..406e958 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -119,6 +119,7 @@ export type HarnessPlatform> = { config: TConfig; runner: string; platformId: string; + getResourceLockKey?: () => string | Promise; }; export type AndroidEmulatorRunTarget = {