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/allocate-metro-ports-for-concurrent-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

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.
9 changes: 6 additions & 3 deletions packages/bundler-metro/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ export const getMetroInstance = async (
): Promise<MetroInstance> => {
const { projectRoot, harnessConfig, websocketEndpoints = {} } = options;
const metroPort = harnessConfig.metroPort;
const metroBindHost = harnessConfig.host?.trim();
metroLogger.debug(
'creating Metro instance for %s on port %d',
projectRoot,
metroPort
);
const isMetroPortAvailable = await isPortAvailable(metroPort);
const isMetroPortAvailable = await isPortAvailable(metroPort, metroBindHost);

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

const middleware = connect()
.use(nocache())
.use('/', getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter))
.use(
'/',
getBundleRequestObserverMiddleware(projectRoot, harnessConfig, reporter)
)
.use('/', getExpoMiddleware(projectRoot, harnessConfig))
.use('/status', getStatusMiddleware(projectRoot));

const ready = waitForBundler(reporter, abortSignal);
const metroBindHost = harnessConfig.host?.trim();
if (metroBindHost) {
metroLogger.debug('binding Metro server to host %s', metroBindHost);
}
Expand Down
1 change: 1 addition & 0 deletions packages/bundler-metro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export {
waitForMetroBackedAppReady,
type WaitForMetroBackedAppReadyOptions,
} from './startup.js';
export { isPortAvailable } from './utils.js';
7 changes: 5 additions & 2 deletions packages/bundler-metro/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { MetroNotInstalledError } from './errors.js';

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

export const isPortAvailable = (port: number): Promise<boolean> => {
export const isPortAvailable = (
port: number,
host?: string
): Promise<boolean> => {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
Expand All @@ -15,7 +18,7 @@ export const isPortAvailable = (port: number): Promise<boolean> => {
server.close();
resolve(true);
});
server.listen(port);
server.listen(port, host);
});
};

Expand Down
90 changes: 89 additions & 1 deletion packages/jest/src/__tests__/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config as HarnessConfig } from '@react-native-harness/config';
import { MetroPortRangeExhaustedError } from '../errors.js';
import { definePlugin } from '@react-native-harness/plugins';
import type {
AppMonitor,
Expand All @@ -25,10 +26,12 @@ const mocks = vi.hoisted(() => ({
getMetroInstance: vi.fn(),
isMetroCacheReusable: vi.fn(() => false),
logMetroCacheReused: vi.fn(),
logMetroPortFallback: vi.fn(),
logRunnerStarting: vi.fn(),
logRunnerStillWaitingInQueue: vi.fn(),
logRunnerWaitingInQueue: vi.fn(),
waitForMetroBackedAppReady: vi.fn(),
isPortAvailable: vi.fn(async () => true),
}));

vi.mock('@react-native-harness/bundler-metro', async () => {
Expand All @@ -39,6 +42,7 @@ vi.mock('@react-native-harness/bundler-metro', async () => {
return {
...actual,
getMetroInstance: mocks.getMetroInstance,
isPortAvailable: mocks.isPortAvailable,
isMetroCacheReusable: mocks.isMetroCacheReusable,
waitForMetroBackedAppReady: mocks.waitForMetroBackedAppReady,
};
Expand All @@ -50,6 +54,7 @@ vi.mock('@react-native-harness/bridge/server', () => ({

vi.mock('../logs.js', () => ({
logMetroCacheReused: mocks.logMetroCacheReused,
logMetroPortFallback: mocks.logMetroPortFallback,
logRunnerStarting: mocks.logRunnerStarting,
logRunnerStillWaitingInQueue: mocks.logRunnerStillWaitingInQueue,
logRunnerWaitingInQueue: mocks.logRunnerWaitingInQueue,
Expand Down Expand Up @@ -195,6 +200,8 @@ const createHarnessConfig = (

beforeEach(() => {
vi.clearAllMocks();
mocks.isPortAvailable.mockReset();
mocks.isPortAvailable.mockResolvedValue(true);
});

afterEach(() => {
Expand Down Expand Up @@ -377,7 +384,9 @@ describe('getHarness', () => {

expect(runner).toHaveBeenCalledWith(
platform.config,
expect.any(Object),
expect.objectContaining({
metroPort: 8081,
}),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
Expand All @@ -386,6 +395,85 @@ describe('getHarness', () => {
await harness.dispose();
});

it('resolves and exposes a fallback Metro port before platform init', async () => {
const { serverBridge } = createBridgeServer();
const appMonitor = createAppMonitor();
const platformInstance = createPlatformRunner({
createAppMonitor: () => appMonitor.appMonitor,
});
const metroInstance = createMetroInstance();

mocks.getBridgeServer.mockResolvedValue(serverBridge);
mocks.getMetroInstance.mockResolvedValue(metroInstance);
mocks.isPortAvailable
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);

const runner = vi.fn(async () => platformInstance);
(
globalThis as typeof globalThis & {
__HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise<unknown>;
}
).__HARNESS_PLATFORM_RUNNER__ = runner;

const platform: HarnessPlatform = {
config: {},
getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
name: 'android',
platformId: 'android',
runner: `data:text/javascript,${encodeURIComponent(
'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);'
)}`,
};

const harness = await getHarness(
createHarnessConfig(),
platform,
'/tmp/project'
);

expect(harness.config.metroPort).toBe(8082);
expect(mocks.getMetroInstance).toHaveBeenCalledWith(
expect.objectContaining({
harnessConfig: expect.objectContaining({
metroPort: 8082,
}),
}),
expect.any(AbortSignal)
);
expect(runner).toHaveBeenCalledWith(
platform.config,
expect.objectContaining({
metroPort: 8082,
}),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
);
expect(mocks.logMetroPortFallback).toHaveBeenCalledWith(8081, 8082);

await harness.dispose();
});

it('fails when no Metro port is available in the retry window', async () => {
mocks.isPortAvailable.mockResolvedValue(false);

const platform: HarnessPlatform = {
config: {},
getResourceLockKey: () => 'android:emulator:Pixel_8_API_35',
name: 'android',
platformId: 'android',
runner: 'data:text/javascript,export default async () => ({})',
};

await expect(
getHarness(createHarnessConfig(), platform, '/tmp/project')
).rejects.toBeInstanceOf(MetroPortRangeExhaustedError);

expect(mocks.getBridgeServer).not.toHaveBeenCalled();
expect(mocks.getMetroInstance).not.toHaveBeenCalled();
});

it('falls back to a default resource lock key for platforms without getResourceLockKey', async () => {
const { serverBridge } = createBridgeServer();
const appMonitor = createAppMonitor();
Expand Down
61 changes: 61 additions & 0 deletions packages/jest/src/__tests__/metro-port.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import type { Config as HarnessConfig } from '@react-native-harness/config';
import type { HarnessPlatform } from '@react-native-harness/platforms';
import { resolveHarnessMetroPort } from '../metro-port.js';

const mocks = vi.hoisted(() => ({
isPortAvailable: vi.fn(async () => true),
}));

vi.mock('@react-native-harness/bundler-metro', () => ({
isPortAvailable: mocks.isPortAvailable,
}));

const createConfig = (overrides: Partial<HarnessConfig> = {}): HarnessConfig =>
({
appRegistryComponentName: 'App',
bridgeTimeout: 60_000,
bundleStartTimeout: 60_000,
crashDetectionInterval: 500,
defaultRunner: 'ios-device',
detectNativeCrashes: true,
disableViewFlattening: false,
entryPoint: 'index.js',
forwardClientLogs: false,
maxAppRestarts: 2,
metroPort: 8081,
platformReadyTimeout: 300_000,
resetEnvironmentBetweenTestFiles: true,
runners: [],
unstable__enableMetroCache: false,
unstable__skipAlreadyIncludedModules: false,
...overrides,
} as HarnessConfig);

describe('resolveHarnessMetroPort', () => {
it('skips fallback allocation for iOS physical device runners', async () => {
const acquire = vi.fn();
const config = createConfig();
const platform: HarnessPlatform = {
config: {},
name: 'ios-device',
platformId: 'ios',
runner: 'unused',
};

const result = await resolveHarnessMetroPort({
config,
platform,
resourceLockManager: {
acquire,
},
signal: new AbortController().signal,
});

expect(result.config).toBe(config);
expect(result.metroPortLease).toBeNull();
expect(result.didFallback).toBe(false);
expect(acquire).not.toHaveBeenCalled();
expect(mocks.isPortAvailable).not.toHaveBeenCalled();
});
});
13 changes: 13 additions & 0 deletions packages/jest/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export class PlatformReadyTimeoutError extends HarnessError {
}
}

export class MetroPortRangeExhaustedError extends HarnessError {
constructor(
public readonly initialPort: number,
public readonly attempts: number
) {
const finalPort = initialPort + attempts - 1;
super(
`Harness could not find an available Metro port in the range ${initialPort}-${finalPort}.`
);
this.name = 'MetroPortRangeExhaustedError';
}
}

export type NativeCrashPhase = 'startup' | 'execution';

export type NativeCrashDetails = AppCrashDetails & {
Expand Down
Loading
Loading