Skip to content

Commit e04177f

Browse files
authored
feat: queue harness runs on shared targets (#91)
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.
1 parent 7617f33 commit e04177f

12 files changed

Lines changed: 1277 additions & 474 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: patch
3+
---
4+
5+
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.

packages/jest/src/__tests__/harness-cache.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const platform: HarnessPlatform = {
2424
platformId: 'ios',
2525
runner: '/virtual/platform-runner.js',
2626
config: {},
27+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
2728
};
2829

2930
const createHarnessConfig = (
@@ -37,7 +38,7 @@ const createHarnessConfig = (
3738
unstable__enableMetroCache: true,
3839
forwardClientLogs: false,
3940
...overrides,
40-
}) as HarnessConfig;
41+
} as HarnessConfig);
4142

4243
describe('maybeLogMetroCacheReuse', () => {
4344
beforeEach(() => {

packages/jest/src/__tests__/harness.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const mocks = vi.hoisted(() => ({
2525
getMetroInstance: vi.fn(),
2626
isMetroCacheReusable: vi.fn(() => false),
2727
logMetroCacheReused: vi.fn(),
28+
logRunnerStarting: vi.fn(),
29+
logRunnerStillWaitingInQueue: vi.fn(),
30+
logRunnerWaitingInQueue: vi.fn(),
2831
waitForMetroBackedAppReady: vi.fn(),
2932
}));
3033

@@ -47,6 +50,9 @@ vi.mock('@react-native-harness/bridge/server', () => ({
4750

4851
vi.mock('../logs.js', () => ({
4952
logMetroCacheReused: mocks.logMetroCacheReused,
53+
logRunnerStarting: mocks.logRunnerStarting,
54+
logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
55+
logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
5056
}));
5157

5258
vi.mock('@react-native-harness/tools', async () => {
@@ -316,6 +322,7 @@ describe('getHarness', () => {
316322

317323
const platform: HarnessPlatform = {
318324
config: {},
325+
getResourceLockKey: () => 'ios:test-platform-ready-timeout',
319326
name: 'ios',
320327
platformId: 'ios',
321328
runner: `data:text/javascript,${encodeURIComponent(
@@ -354,6 +361,7 @@ describe('getHarness', () => {
354361

355362
const platform: HarnessPlatform = {
356363
config: {},
364+
getResourceLockKey: () => 'ios:test-platform-init-signal',
357365
name: 'ios',
358366
platformId: 'ios',
359367
runner: `data:text/javascript,${encodeURIComponent(
@@ -378,6 +386,41 @@ describe('getHarness', () => {
378386
await harness.dispose();
379387
});
380388

389+
it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
390+
const { serverBridge } = createBridgeServer();
391+
const appMonitor = createAppMonitor();
392+
const platformInstance = createPlatformRunner({
393+
createAppMonitor: () => appMonitor.appMonitor,
394+
});
395+
const metroInstance = createMetroInstance();
396+
397+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
398+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
399+
400+
(
401+
globalThis as typeof globalThis & {
402+
__HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
403+
}
404+
).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance);
405+
406+
const platform: HarnessPlatform = {
407+
config: {},
408+
name: 'legacy-ios',
409+
platformId: 'ios',
410+
runner: `data:text/javascript,${encodeURIComponent(
411+
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
412+
)}`,
413+
};
414+
415+
const harness = await getHarness(
416+
createHarnessConfig(),
417+
platform,
418+
'/tmp/project'
419+
);
420+
421+
await harness.dispose();
422+
});
423+
381424
it('routes ensureAppReady through the shared Metro startup helper', async () => {
382425
const { serverBridge, emitReady } = createBridgeServer();
383426
const appMonitor = createAppMonitor();
@@ -418,6 +461,7 @@ describe('getHarness', () => {
418461
runner: `data:text/javascript,${encodeURIComponent(
419462
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
420463
)}`,
464+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
421465
};
422466

423467
const harness = await getHarness(
@@ -483,6 +527,7 @@ describe('getHarness', () => {
483527
runner: `data:text/javascript,${encodeURIComponent(
484528
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
485529
)}`,
530+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
486531
};
487532

488533
const harness = await getHarness(
@@ -612,6 +657,7 @@ describe('plugins', () => {
612657
runner: `data:text/javascript,${encodeURIComponent(
613658
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
614659
)}`,
660+
getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2',
615661
};
616662

617663
const harness = await getHarness(
@@ -656,6 +702,75 @@ describe('plugins', () => {
656702
'beforeDispose:1:normal',
657703
]);
658704
});
705+
706+
it('waits in queue before starting Metro and releases the lock on dispose', async () => {
707+
const resourceKey = 'ios:simulator:iPhone 17 Pro:26.2';
708+
const firstPlatformRunner = createPlatformRunner();
709+
const secondPlatformRunner = createPlatformRunner();
710+
const secondAppMonitor = createAppMonitor();
711+
const firstMetroInstance = createMetroInstance();
712+
const secondMetroInstance = createMetroInstance();
713+
const firstBridge = createBridgeServer();
714+
const secondBridge = createBridgeServer();
715+
716+
mocks.getBridgeServer
717+
.mockResolvedValueOnce(firstBridge.serverBridge)
718+
.mockResolvedValueOnce(secondBridge.serverBridge);
719+
mocks.getMetroInstance
720+
.mockResolvedValueOnce(firstMetroInstance)
721+
.mockResolvedValueOnce(secondMetroInstance);
722+
723+
let invocationCount = 0;
724+
(
725+
globalThis as typeof globalThis & {
726+
__HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
727+
}
728+
).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => {
729+
invocationCount += 1;
730+
return invocationCount === 1
731+
? firstPlatformRunner
732+
: createPlatformRunner({
733+
createAppMonitor: () => secondAppMonitor.appMonitor,
734+
dispose: secondPlatformRunner.dispose,
735+
});
736+
});
737+
738+
const platform: HarnessPlatform = {
739+
config: {},
740+
name: 'ios',
741+
platformId: 'ios',
742+
runner: `data:text/javascript,${encodeURIComponent(
743+
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
744+
)}`,
745+
getResourceLockKey: () => resourceKey,
746+
};
747+
748+
const firstHarness = await getHarness(
749+
createHarnessConfig(),
750+
platform,
751+
'/tmp/project'
752+
);
753+
754+
const secondHarnessPromise = getHarness(
755+
createHarnessConfig(),
756+
platform,
757+
'/tmp/project'
758+
);
759+
760+
await new Promise((resolve) => setTimeout(resolve, 1100));
761+
762+
expect(mocks.logRunnerWaitingInQueue).toHaveBeenCalledWith(platform);
763+
expect(mocks.logRunnerStarting).not.toHaveBeenCalled();
764+
expect(mocks.getMetroInstance).toHaveBeenCalledTimes(1);
765+
766+
await firstHarness.dispose();
767+
const secondHarness = await secondHarnessPromise;
768+
769+
expect(mocks.logRunnerStarting).toHaveBeenCalledWith(platform);
770+
expect(mocks.getMetroInstance).toHaveBeenCalledTimes(2);
771+
772+
await secondHarness.dispose();
773+
});
659774
});
660775

661776
describe('StartupStallError', () => {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import fs from 'node:fs/promises';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import {
6+
createResourceLockManager,
7+
hashResourceLockKey,
8+
} from '../resource-lock.js';
9+
10+
describe('resource lock manager', () => {
11+
let rootDir: string;
12+
13+
beforeEach(async () => {
14+
rootDir = await fs.mkdtemp(
15+
path.join(os.tmpdir(), 'react-native-harness-resource-lock-test-')
16+
);
17+
});
18+
19+
afterEach(async () => {
20+
await fs.rm(rootDir, { recursive: true, force: true });
21+
});
22+
23+
it('queues access in FIFO order', async () => {
24+
const manager = createResourceLockManager({
25+
rootDir,
26+
pollIntervalMs: 5,
27+
heartbeatIntervalMs: 20,
28+
staleLockTimeoutMs: 200,
29+
});
30+
const order: string[] = [];
31+
32+
const firstLease = await manager.acquire(
33+
'ios:simulator:iPhone 17 Pro:26.2'
34+
);
35+
const secondAcquire = manager
36+
.acquire('ios:simulator:iPhone 17 Pro:26.2', {
37+
onWait: () => {
38+
order.push('waiting');
39+
},
40+
})
41+
.then(async (lease) => {
42+
order.push('acquired');
43+
await lease.release();
44+
});
45+
46+
await new Promise((resolve) => setTimeout(resolve, 30));
47+
expect(order).toEqual(['waiting']);
48+
49+
await firstLease.release();
50+
await secondAcquire;
51+
52+
expect(order).toEqual(['waiting', 'acquired']);
53+
});
54+
55+
it('removes the queued ticket when waiting is aborted', async () => {
56+
const manager = createResourceLockManager({
57+
rootDir,
58+
pollIntervalMs: 5,
59+
heartbeatIntervalMs: 20,
60+
staleLockTimeoutMs: 200,
61+
});
62+
const key = 'android:emulator:Pixel_8_API_35';
63+
const firstLease = await manager.acquire(key);
64+
const controller = new AbortController();
65+
66+
const acquirePromise = manager.acquire(key, {
67+
signal: controller.signal,
68+
});
69+
70+
await new Promise((resolve) => setTimeout(resolve, 30));
71+
controller.abort();
72+
73+
await expect(acquirePromise).rejects.toMatchObject({
74+
name: 'AbortError',
75+
});
76+
77+
const queueDir = path.join(rootDir, hashResourceLockKey(key), 'queue');
78+
const queuedEntries = await fs.readdir(queueDir);
79+
expect(queuedEntries).toHaveLength(0);
80+
81+
await firstLease.release();
82+
});
83+
84+
it('keeps queued tickets alive while the waiting process is still active', async () => {
85+
const manager = createResourceLockManager({
86+
rootDir,
87+
pollIntervalMs: 5,
88+
heartbeatIntervalMs: 20,
89+
staleLockTimeoutMs: 30,
90+
isProcessActive: () => true,
91+
});
92+
const key = 'ios:simulator:iPhone 17 Pro:26.2';
93+
const firstLease = await manager.acquire(key);
94+
95+
const secondAcquire = manager.acquire(key);
96+
97+
await new Promise((resolve) => setTimeout(resolve, 80));
98+
99+
await firstLease.release();
100+
const secondLease = await secondAcquire;
101+
await secondLease.release();
102+
});
103+
104+
it('reclaims a stale owner before granting the lock', async () => {
105+
const manager = createResourceLockManager({
106+
rootDir,
107+
pollIntervalMs: 5,
108+
heartbeatIntervalMs: 20,
109+
staleLockTimeoutMs: 50,
110+
isProcessActive: () => false,
111+
});
112+
const key = 'web:browser:chromium';
113+
const keyDir = path.join(rootDir, hashResourceLockKey(key));
114+
const queueDir = path.join(keyDir, 'queue');
115+
const ownerFilePath = path.join(keyDir, 'owner.json');
116+
117+
await fs.mkdir(queueDir, { recursive: true });
118+
await fs.writeFile(
119+
ownerFilePath,
120+
JSON.stringify({
121+
ticketId: 'stale-owner',
122+
key,
123+
pid: 999999,
124+
createdAt: Date.now() - 1000,
125+
heartbeatAt: Date.now() - 1000,
126+
}),
127+
'utf8'
128+
);
129+
130+
const lease = await manager.acquire(key);
131+
const owner = JSON.parse(await fs.readFile(ownerFilePath, 'utf8')) as {
132+
ticketId: string;
133+
};
134+
expect(owner.ticketId).not.toBe('stale-owner');
135+
136+
await lease.release();
137+
});
138+
});

0 commit comments

Comments
 (0)