Skip to content

Commit 3a84310

Browse files
authored
feat: allocate Metro ports for concurrent harness runs (#96)
1 parent bba4bd0 commit 3a84310

14 files changed

Lines changed: 340 additions & 44 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 falls back to the next available Metro port when the configured port is already in use, which lets multiple Harness runs start at the same time without colliding on Metro. When this happens, Harness keeps the selected port consistent for the whole run and prints a message showing which port it ended up using.

packages/bundler-metro/src/factory.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,13 @@ export const getMetroInstance = async (
9292
): Promise<MetroInstance> => {
9393
const { projectRoot, harnessConfig, websocketEndpoints = {} } = options;
9494
const metroPort = harnessConfig.metroPort;
95+
const metroBindHost = harnessConfig.host?.trim();
9596
metroLogger.debug(
9697
'creating Metro instance for %s on port %d',
9798
projectRoot,
9899
metroPort
99100
);
100-
const isMetroPortAvailable = await isPortAvailable(metroPort);
101+
const isMetroPortAvailable = await isPortAvailable(metroPort, metroBindHost);
101102

102103
if (!isMetroPortAvailable) {
103104
throw new MetroPortUnavailableError(metroPort);
@@ -118,12 +119,14 @@ export const getMetroInstance = async (
118119

119120
const middleware = connect()
120121
.use(nocache())
121-
.use('/', getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter))
122+
.use(
123+
'/',
124+
getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter)
125+
)
122126
.use('/', getExpoMiddleware(projectRoot, harnessConfig))
123127
.use('/status', getStatusMiddleware(projectRoot));
124128

125129
const ready = waitForBundler(reporter, abortSignal);
126-
const metroBindHost = harnessConfig.host?.trim();
127130
if (metroBindHost) {
128131
metroLogger.debug('binding Metro server to host %s', metroBindHost);
129132
}

packages/bundler-metro/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export {
1616
waitForMetroBackedAppReady,
1717
type WaitForMetroBackedAppReadyOptions,
1818
} from './startup.js';
19+
export { isPortAvailable } from './utils.js';

packages/bundler-metro/src/utils.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { MetroNotInstalledError } from './errors.js';
44

55
const require = createRequire(import.meta.url);
66

7-
export const isPortAvailable = (port: number): Promise<boolean> => {
7+
export const isPortAvailable = (
8+
port: number,
9+
host?: string
10+
): Promise<boolean> => {
811
return new Promise((resolve) => {
912
const server = net.createServer();
1013
server.once('error', () => {
@@ -15,7 +18,7 @@ export const isPortAvailable = (port: number): Promise<boolean> => {
1518
server.close();
1619
resolve(true);
1720
});
18-
server.listen(port);
21+
server.listen(port, host);
1922
});
2023
};
2124

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

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
22
import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge';
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44
import type { Config as HarnessConfig } from '@react-native-harness/config';
5+
import { MetroPortRangeExhaustedError } from '../errors.js';
56
import { definePlugin } from '@react-native-harness/plugins';
67
import type {
78
AppMonitor,
@@ -25,10 +26,12 @@ const mocks = vi.hoisted(() => ({
2526
getMetroInstance: vi.fn(),
2627
isMetroCacheReusable: vi.fn(() => false),
2728
logMetroCacheReused: vi.fn(),
29+
logMetroPortFallback: vi.fn(),
2830
logRunnerStarting: vi.fn(),
2931
logRunnerStillWaitingInQueue: vi.fn(),
3032
logRunnerWaitingInQueue: vi.fn(),
3133
waitForMetroBackedAppReady: vi.fn(),
34+
isPortAvailable: vi.fn(async () => true),
3235
}));
3336

3437
vi.mock('@react-native-harness/bundler-metro', async () => {
@@ -39,6 +42,7 @@ vi.mock('@react-native-harness/bundler-metro', async () => {
3942
return {
4043
...actual,
4144
getMetroInstance: mocks.getMetroInstance,
45+
isPortAvailable: mocks.isPortAvailable,
4246
isMetroCacheReusable: mocks.isMetroCacheReusable,
4347
waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
4448
};
@@ -50,6 +54,7 @@ vi.mock('@react-native-harness/bridge/server', () => ({
5054

5155
vi.mock('../logs.js', () => ({
5256
logMetroCacheReused: mocks.logMetroCacheReused,
57+
logMetroPortFallback: mocks.logMetroPortFallback,
5358
logRunnerStarting: mocks.logRunnerStarting,
5459
logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
5560
logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
@@ -195,6 +200,8 @@ const createHarnessConfig = (
195200

196201
beforeEach(() => {
197202
vi.clearAllMocks();
203+
mocks.isPortAvailable.mockReset();
204+
mocks.isPortAvailable.mockResolvedValue(true);
198205
});
199206

200207
afterEach(() => {
@@ -377,7 +384,9 @@ describe('getHarness', () => {
377384

378385
expect(runner).toHaveBeenCalledWith(
379386
platform.config,
380-
expect.any(Object),
387+
expect.objectContaining({
388+
metroPort: 8081,
389+
}),
381390
expect.objectContaining({
382391
signal: expect.any(AbortSignal),
383392
})
@@ -386,6 +395,85 @@ describe('getHarness', () => {
386395
await harness.dispose();
387396
});
388397

398+
it('resolves and exposes a fallback Metro port before platform init', async () => {
399+
const { serverBridge } = createBridgeServer();
400+
const appMonitor = createAppMonitor();
401+
const platformInstance = createPlatformRunner({
402+
createAppMonitor: () => appMonitor.appMonitor,
403+
});
404+
const metroInstance = createMetroInstance();
405+
406+
mocks.getBridgeServer.mockResolvedValue(serverBridge);
407+
mocks.getMetroInstance.mockResolvedValue(metroInstance);
408+
mocks.isPortAvailable
409+
.mockResolvedValueOnce(false)
410+
.mockResolvedValueOnce(true);
411+
412+
const runner = vi.fn(async () => platformInstance);
413+
(
414+
globalThis as typeof globalThis & {
415+
__HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
416+
}
417+
).__HARNESS_PLATFORM_RUNNER__ = runner;
418+
419+
const platform: HarnessPlatform = {
420+
config: {},
421+
getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
422+
name: 'android',
423+
platformId: 'android',
424+
runner: `data:text/javascript,${encodeURIComponent(
425+
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
426+
)}`,
427+
};
428+
429+
const harness = await getHarness(
430+
createHarnessConfig(),
431+
platform,
432+
'/tmp/project'
433+
);
434+
435+
expect(harness.config.metroPort).toBe(8082);
436+
expect(mocks.getMetroInstance).toHaveBeenCalledWith(
437+
expect.objectContaining({
438+
harnessConfig: expect.objectContaining({
439+
metroPort: 8082,
440+
}),
441+
}),
442+
expect.any(AbortSignal)
443+
);
444+
expect(runner).toHaveBeenCalledWith(
445+
platform.config,
446+
expect.objectContaining({
447+
metroPort: 8082,
448+
}),
449+
expect.objectContaining({
450+
signal: expect.any(AbortSignal),
451+
})
452+
);
453+
expect(mocks.logMetroPortFallback).toHaveBeenCalledWith(8081, 8082);
454+
455+
await harness.dispose();
456+
});
457+
458+
it('fails when no Metro port is available in the retry window', async () => {
459+
mocks.isPortAvailable.mockResolvedValue(false);
460+
461+
const platform: HarnessPlatform = {
462+
config: {},
463+
getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
464+
name: 'android',
465+
platformId: 'android',
466+
runner: 'data:text/javascript,export default async () => ({})',
467+
};
468+
469+
await expect(
470+
getHarness(createHarnessConfig(), platform, '/tmp/project')
471+
).rejects.toBeInstanceOf(MetroPortRangeExhaustedError);
472+
473+
expect(mocks.getBridgeServer).not.toHaveBeenCalled();
474+
expect(mocks.getMetroInstance).not.toHaveBeenCalled();
475+
});
476+
389477
it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
390478
const { serverBridge } = createBridgeServer();
391479
const appMonitor = createAppMonitor();
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { Config as HarnessConfig } from '@react-native-harness/config';
3+
import type { HarnessPlatform } from '@react-native-harness/platforms';
4+
import { resolveHarnessMetroPort } from '../metro-port.js';
5+
6+
const mocks = vi.hoisted(() => ({
7+
isPortAvailable: vi.fn(async () => true),
8+
}));
9+
10+
vi.mock('@react-native-harness/bundler-metro', () => ({
11+
isPortAvailable: mocks.isPortAvailable,
12+
}));
13+
14+
const createConfig = (overrides: Partial<HarnessConfig> = {}): HarnessConfig =>
15+
({
16+
appRegistryComponentName: 'App',
17+
bridgeTimeout: 60_000,
18+
bundleStartTimeout: 60_000,
19+
crashDetectionInterval: 500,
20+
defaultRunner: 'ios-device',
21+
detectNativeCrashes: true,
22+
disableViewFlattening: false,
23+
entryPoint: 'index.js',
24+
forwardClientLogs: false,
25+
maxAppRestarts: 2,
26+
metroPort: 8081,
27+
platformReadyTimeout: 300_000,
28+
resetEnvironmentBetweenTestFiles: true,
29+
runners: [],
30+
unstable__enableMetroCache: false,
31+
unstable__skipAlreadyIncludedModules: false,
32+
...overrides,
33+
} as HarnessConfig);
34+
35+
describe('resolveHarnessMetroPort', () => {
36+
it('skips fallback allocation for iOS physical device runners', async () => {
37+
const acquire = vi.fn();
38+
const config = createConfig();
39+
const platform: HarnessPlatform = {
40+
config: {},
41+
name: 'ios-device',
42+
platformId: 'ios',
43+
runner: 'unused',
44+
};
45+
46+
const result = await resolveHarnessMetroPort({
47+
config,
48+
platform,
49+
resourceLockManager: {
50+
acquire,
51+
},
52+
signal: new AbortController().signal,
53+
});
54+
55+
expect(result.config).toBe(config);
56+
expect(result.metroPortLease).toBeNull();
57+
expect(result.didFallback).toBe(false);
58+
expect(acquire).not.toHaveBeenCalled();
59+
expect(mocks.isPortAvailable).not.toHaveBeenCalled();
60+
});
61+
});

packages/jest/src/errors.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ export class PlatformReadyTimeoutError extends HarnessError {
3636
}
3737
}
3838

39+
export class MetroPortRangeExhaustedError extends HarnessError {
40+
constructor(
41+
public readonly initialPort: number,
42+
public readonly attempts: number
43+
) {
44+
const finalPort = initialPort + attempts - 1;
45+
super(
46+
`Harness could not find an available Metro port in the range ${initialPort}-${finalPort}.`
47+
);
48+
this.name = 'MetroPortRangeExhaustedError';
49+
}
50+
}
51+
3952
export type NativeCrashPhase = 'startup' | 'execution';
4053

4154
export type NativeCrashDetails = AppCrashDetails & {

0 commit comments

Comments
 (0)