Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/heartbeat-blocking-phase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@react-native-harness/bridge': patch
'@react-native-harness/runtime': patch
'@react-native-harness/config': minor
'@react-native-harness/jest': patch
---

Stop test runs from failing with `app heartbeat timed out` while the app is evaluating a large test bundle. The runtime now tells the bridge it is about to block the JS thread before the synchronous `eval()` of a bundled module, and the bridge suspends the heartbeat for that phase (bounded, so a real crash is still detected). Heartbeat timing is also configurable via the new `heartbeatInterval` and `heartbeatTimeout` options, and the timeout error now explains that a blocked JS thread — not only a crash — can cause it.
2 changes: 2 additions & 0 deletions actions/shared/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4448,6 +4448,8 @@ var ConfigSchema = external_exports.object({
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."),
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
heartbeatInterval: external_exports.number().min(100, "Heartbeat interval must be at least 100ms").default(5e3).describe("How often the harness pings the app to check that its JS thread is still alive."),
heartbeatTimeout: external_exports.number().min(1e3, "Heartbeat timeout must be at least 1 second").default(2e4).describe("How long the app may go without answering a heartbeat ping before the run fails as unresponsive. The harness suspends the heartbeat around phases the app reports as blocking (such as evaluating a test bundle), so raise this only if a run still times out with no crash report."),
testTimeout: external_exports.number().min(1e3, "Test timeout must be at least 1 second").default(5e3),
platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5),
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4),
Expand Down
2 changes: 2 additions & 0 deletions actions/shared/plan-restore.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4782,6 +4782,8 @@ var ConfigSchema = external_exports.object({
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."),
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
heartbeatInterval: external_exports.number().min(100, "Heartbeat interval must be at least 100ms").default(5e3).describe("How often the harness pings the app to check that its JS thread is still alive."),
heartbeatTimeout: external_exports.number().min(1e3, "Heartbeat timeout must be at least 1 second").default(2e4).describe("How long the app may go without answering a heartbeat ping before the run fails as unresponsive. The harness suspends the heartbeat around phases the app reports as blocking (such as evaluating a test bundle), so raise this only if a run still times out with no crash report."),
testTimeout: external_exports.number().min(1e3, "Test timeout must be at least 1 second").default(5e3),
platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5),
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4),
Expand Down
2 changes: 2 additions & 0 deletions actions/shared/plan-save.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4782,6 +4782,8 @@ var ConfigSchema = external_exports.object({
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."),
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
heartbeatInterval: external_exports.number().min(100, "Heartbeat interval must be at least 100ms").default(5e3).describe("How often the harness pings the app to check that its JS thread is still alive."),
heartbeatTimeout: external_exports.number().min(1e3, "Heartbeat timeout must be at least 1 second").default(2e4).describe("How long the app may go without answering a heartbeat ping before the run fails as unresponsive. The harness suspends the heartbeat around phases the app reports as blocking (such as evaluating a test bundle), so raise this only if a run still times out with no crash report."),
testTimeout: external_exports.number().min(1e3, "Test timeout must be at least 1 second").default(5e3),
platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5),
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4),
Expand Down
2 changes: 2 additions & 0 deletions actions/shared/snapshot-metro.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4705,6 +4705,8 @@ var ConfigSchema = external_exports.object({
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."),
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
heartbeatInterval: external_exports.number().min(100, "Heartbeat interval must be at least 100ms").default(5e3).describe("How often the harness pings the app to check that its JS thread is still alive."),
heartbeatTimeout: external_exports.number().min(1e3, "Heartbeat timeout must be at least 1 second").default(2e4).describe("How long the app may go without answering a heartbeat ping before the run fails as unresponsive. The harness suspends the heartbeat around phases the app reports as blocking (such as evaluating a test bundle), so raise this only if a run still times out with no crash report."),
testTimeout: external_exports.number().min(1e3, "Test timeout must be at least 1 second").default(5e3),
platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5),
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4),
Expand Down
88 changes: 88 additions & 0 deletions packages/bridge/src/__tests__/heartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,92 @@ describe('bridge heartbeat', () => {
heartbeat.dispose();
vi.useRealTimers();
});

it('does not time out while suspended for a blocking phase', () => {
vi.useFakeTimers();

const sendPing = vi.fn();
const onTimeout = vi.fn();
const heartbeat = createHeartbeat({
sendPing,
onTimeout,
intervalMs: 5,
timeoutMs: 10,
maxSuspendMs: 1_000,
});

// A ping is already in flight when the app announces it is about to block.
vi.advanceTimersByTime(5);
expect(sendPing).toHaveBeenCalledTimes(1);

heartbeat.suspend();
vi.advanceTimersByTime(500);

expect(onTimeout).not.toHaveBeenCalled();
expect(sendPing).toHaveBeenCalledTimes(1);

// Pinging restarts once the blocking phase is over, and the app gets a
// full timeout window to answer the next ping.
heartbeat.resume();
vi.advanceTimersByTime(5);
expect(sendPing).toHaveBeenLastCalledWith(2);

heartbeat.notifyPong(2);
vi.advanceTimersByTime(5);
expect(onTimeout).not.toHaveBeenCalled();

heartbeat.dispose();
vi.useRealTimers();
});

it('resumes on its own if the app never reports the blocking phase ended', () => {
vi.useFakeTimers();

const onSuspendExpired = vi.fn();
const onTimeout = vi.fn();
const heartbeat = createHeartbeat({
sendPing: vi.fn(),
onTimeout,
onSuspendExpired,
intervalMs: 5,
timeoutMs: 10,
maxSuspendMs: 100,
});

heartbeat.suspend();
expect(heartbeat.suspended).toBe(true);

vi.advanceTimersByTime(100);
expect(onSuspendExpired).toHaveBeenCalledTimes(1);
expect(heartbeat.suspended).toBe(false);

// Still blocked: liveness detection is back and the session fails as before.
vi.advanceTimersByTime(15);
expect(onTimeout).toHaveBeenCalledTimes(1);

heartbeat.dispose();
vi.useRealTimers();
});

it('ignores suspend and resume after disposal', () => {
vi.useFakeTimers();

const sendPing = vi.fn();
const heartbeat = createHeartbeat({
sendPing,
onTimeout: vi.fn(),
intervalMs: 5,
timeoutMs: 10,
});

heartbeat.dispose();
heartbeat.suspend();
heartbeat.resume();
vi.advanceTimersByTime(50);

expect(sendPing).not.toHaveBeenCalled();
expect(heartbeat.suspended).toBe(false);

vi.useRealTimers();
});
});
24 changes: 24 additions & 0 deletions packages/bridge/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ describe('bridge protocol', () => {
});
});

it('round-trips busy messages', () => {
const raw = serializeBridgeMessage({
type: 'busy',
busy: true,
label: 'evaluating example.harness.tsx',
});

expect(parseBridgeMessage(raw)).toEqual({
type: 'busy',
busy: true,
label: 'evaluating example.harness.tsx',
});

expect(
parseBridgeMessage(serializeBridgeMessage({ type: 'busy', busy: false })),
).toEqual({ type: 'busy', busy: false });
});

it('rejects busy messages without a boolean flag', () => {
expect(() => parseBridgeMessage('{"type":"busy","busy":"yes"}')).toThrow(
'Invalid bridge message: busy must be a boolean',
);
});

it('rejects malformed messages', () => {
expect(() => parseBridgeMessage('{"type":"invoke","id":"1"}')).toThrow(
'Invalid bridge message: id must be a number',
Expand Down
16 changes: 16 additions & 0 deletions packages/bridge/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export type HarnessHandle = {
options: ImageSnapshotOptions,
runner: string,
) => Promise<{ pass: boolean; message: string }>;
/**
* Tell the harness that the JS thread is about to block (or has stopped
* blocking). Heartbeat pings cannot be answered from a blocked thread, so
* the server suspends the heartbeat for the duration.
*
* Must be called *before* the blocking work starts, and the caller must yield
* to the event loop afterwards so the message actually reaches the socket.
*/
setBusy: (busy: boolean, label?: string) => void;
disconnect: () => void;
};

Expand Down Expand Up @@ -192,6 +201,13 @@ export const connectToHarness = (
options,
runner,
),
setBusy: (busy, label) => {
if (transport.state !== 'open') {
return;
}

transport.send(serializeBridgeMessage({ type: 'busy', busy, label }));
},
disconnect: () => {
closePeer(new Error('Harness connection closed by client'));
transport.close();
Expand Down
13 changes: 10 additions & 3 deletions packages/bridge/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const appBridgeDisconnectedMessage = (
case 'app-replaced':
return 'The app bridge was replaced by a newer app connection. This can happen when the app reloads, restarts, or reconnects while a test file is still running.';
case 'heartbeat-timeout':
return 'The app bridge stopped responding during test execution. This can happen if the app was killed, crashed, became unresponsive, or lost its WebSocket connection.';
return 'The app stopped answering harness heartbeats during test execution. The connection itself stayed open, so the most likely cause is a blocked JS thread — for example evaluating a very large test bundle — but the app may also have been killed, crashed, or lost its WebSocket connection. If no crash report was written to .harness/crash-reports, the app did not crash; raise `heartbeatTimeout` in your harness config to give the JS thread more room.';
case 'socket-error':
return 'The app bridge connection failed during test execution. This can happen if the app was killed, crashed, or the underlying WebSocket connection closed unexpectedly.';
case 'bridge-disposed':
Expand All @@ -35,8 +35,15 @@ const appBridgeDisconnectedMessage = (
};

export class AppBridgeDisconnectedError extends HarnessError {
constructor(public readonly reason: AppBridgeDisconnectedReason) {
super(appBridgeDisconnectedMessage(reason));
constructor(
public readonly reason: AppBridgeDisconnectedReason,
public readonly detail?: string
) {
super(
detail
? `${appBridgeDisconnectedMessage(reason)} ${detail}`
: appBridgeDisconnectedMessage(reason)
);
this.name = 'AppBridgeDisconnectedError';
this.stack = `${this.name}: ${this.message}`;
}
Expand Down
63 changes: 62 additions & 1 deletion packages/bridge/src/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 20_000;
/**
* Upper bound on how long a single `suspend()` may silence the heartbeat.
* Suspension is driven by the app (see `BridgeBusyMessage`), so a crash or a
* lost `busy: false` message must not disable liveness detection forever.
*/
export const DEFAULT_HEARTBEAT_MAX_SUSPEND_MS = 300_000;

export type BridgeHeartbeat = {
notifyPong: (id: number) => void;
/**
* Stop pinging and drop any in-flight ping. Used while the app reports that
* it is blocking its JS thread and therefore cannot answer. Automatically
* lifted after `maxSuspendMs`.
*/
suspend: () => void;
resume: () => void;
readonly suspended: boolean;
dispose: () => void;
};

Expand All @@ -11,13 +25,18 @@ export const createHeartbeat = (options: {
onTimeout: () => void;
intervalMs?: number;
timeoutMs?: number;
maxSuspendMs?: number;
onSuspendExpired?: () => void;
}): BridgeHeartbeat => {
const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
const timeoutMs = options.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
const maxSuspendMs = options.maxSuspendMs ?? DEFAULT_HEARTBEAT_MAX_SUSPEND_MS;
let nextPingId = 1;
let pendingPingId: number | null = null;
let disposed = false;
let suspended = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let suspendHandle: ReturnType<typeof setTimeout> | null = null;

const clearPendingTimeout = () => {
if (timeoutHandle) {
Expand All @@ -26,8 +45,15 @@ export const createHeartbeat = (options: {
}
};

const clearSuspendTimeout = () => {
if (suspendHandle) {
clearTimeout(suspendHandle);
suspendHandle = null;
}
};

const intervalHandle = setInterval(() => {
if (disposed || pendingPingId !== null) {
if (disposed || suspended || pendingPingId !== null) {
return;
}

Expand All @@ -45,6 +71,18 @@ export const createHeartbeat = (options: {
}, timeoutMs);
}, intervalMs);

const resume = () => {
if (disposed || !suspended) {
return;
}

clearSuspendTimeout();
suspended = false;
// Any ping sent before the suspension is unanswerable by now; start clean
// so the app gets a full `timeoutMs` to reply to the next one.
pendingPingId = null;
};

return {
notifyPong: (id) => {
if (id !== pendingPingId) {
Expand All @@ -54,6 +92,28 @@ export const createHeartbeat = (options: {
pendingPingId = null;
clearPendingTimeout();
},
suspend: () => {
if (disposed || suspended) {
return;
}

suspended = true;
pendingPingId = null;
clearPendingTimeout();
suspendHandle = setTimeout(() => {
suspendHandle = null;
if (disposed || !suspended) {
return;
}

options.onSuspendExpired?.();
resume();
}, maxSuspendMs);
},
resume,
get suspended() {
return suspended;
},
dispose: () => {
if (disposed) {
return;
Expand All @@ -62,6 +122,7 @@ export const createHeartbeat = (options: {
disposed = true;
clearInterval(intervalHandle);
clearPendingTimeout();
clearSuspendTimeout();
},
};
};
26 changes: 25 additions & 1 deletion packages/bridge/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,23 @@ export type BridgePongMessage = {
id: number;
};

/**
* Sent by the app right before (and right after) a phase that blocks the JS
* thread, such as the synchronous `eval()` of a freshly bundled test module.
* While the app is busy it cannot answer pings, so the server suspends the
* heartbeat instead of treating the silence as a dead app.
*/
export type BridgeBusyMessage = {
type: 'busy';
busy: boolean;
label?: string;
};

export type BridgeControlMessage =
| BridgeReadyMessage
| BridgePingMessage
| BridgePongMessage;
| BridgePongMessage
| BridgeBusyMessage;

export type BridgeMessage<Event extends { type: string } = BridgeEvents> =
| BridgeInvokeMessage
Expand Down Expand Up @@ -176,6 +189,17 @@ export const parseBridgeMessage = (raw: string): BridgeMessage => {
readNumber(parsed.id, 'id');
return parsed as BridgePingMessage | BridgePongMessage;
}
case 'busy': {
if (typeof parsed.busy !== 'boolean') {
throw new Error('Invalid bridge message: busy must be a boolean');
}

if (parsed.label !== undefined) {
readString(parsed.label, 'label');
}

return parsed as BridgeBusyMessage;
}
default:
throw new Error(`Invalid bridge message: unknown type ${messageType}`);
}
Expand Down
1 change: 1 addition & 0 deletions packages/bridge/src/rpc-peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export const createRpcPeer = <
case 'ready':
case 'ping':
case 'pong':
case 'busy':
return message;
}
},
Expand Down
Loading
Loading