Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/queue-runs-by-resource-lock.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/jest/src/__tests__/harness-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -37,7 +38,7 @@ const createHarnessConfig = (
unstable__enableMetroCache: true,
forwardClientLogs: false,
...overrides,
}) as HarnessConfig;
} as HarnessConfig);

describe('maybeLogMetroCacheReuse', () => {
beforeEach(() => {
Expand Down
115 changes: 115 additions & 0 deletions packages/jest/src/__tests__/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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<unknown>;
}
).__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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<unknown>;
}
).__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', () => {
Expand Down
138 changes: 138 additions & 0 deletions packages/jest/src/__tests__/resource-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading