Skip to content

fix(bridge): keep the heartbeat alive while the app evaluates a test bundle - #184

Open
appfr3d wants to merge 2 commits into
callstackincubator:mainfrom
appfr3d:fix/heartbeat-during-bundle-eval
Open

fix(bridge): keep the heartbeat alive while the app evaluates a test bundle#184
appfr3d wants to merge 2 commits into
callstackincubator:mainfrom
appfr3d:fix/heartbeat-during-bundle-eval

Conversation

@appfr3d

@appfr3d appfr3d commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The problem

A test run can fail with [harness][bridge] app heartbeat timed out and Tests: 0 total purely because the test bundle is large — nothing crashes, and there is no way to configure around it.

Two mechanisms collide:

  1. runtime/src/bundler/bundle.ts fetches each test file from Metro as its own ?modulesOnly=true bundle (async, thread stays responsive), and runtime/src/bundler/evaluate.ts then evaluates it with a synchronous eval(). Hermes parses the whole multi-MB bundle text before running a single line of it, with no bytecode cache, so the JS thread is blocked for the entire parse.
  2. bridge/src/heartbeat.ts pings every 5s and requires a pong within 20s. The pong is produced from JS (bridge/src/client.ts), so a blocked JS thread cannot possibly answer. createHeartbeat was called from bridge/src/server.ts with no intervalMs/timeoutMs, so 20s was hard-coded and unreachable from user config (bridgeTimeout is a different mechanism and does not apply).

The result is that a sufficiently large test bundle kills its own run.

Evidence

Reported from a private downstream repo, so quoting rather than linking. Same CI job, same cached, byte-identical simulator .app binary, same single test file, iPhone 17 sim / Xcode 26.2:

  • harness 1.1.0 → PASS, 38 tests, run finished in 131s.
  • harness 1.4.1 → FAIL (twice), app heartbeat timed out, Tests: 0 total.

.harness/crash-reports was empty in the failing runs, confirming a stall rather than a crash. Timeline of a failing run: Run started 06:07:29 → Running "<App>" 06:07:32 (bridge alive, logs forwarding) → app heartbeat timed out 06:08:42, i.e. the thread went silent ~50s after render — right when the cold Metro build finished and eval() began. The first log line inside the test module never appears at all, which places the block in the parse, before any user module code runs.

For calibration, the passing 1.1.0 run had 33s between Running "<App>" and the first log line inside the test module, then the first suite ~2s later. So the fetch+parse phase is tens of seconds even on a healthy run — the 20s budget is simply below what a large real-world test bundle needs.

This is not fixable from the app side. inlineRequires: true is already RN's default, so module execution is already deferred; what remains is Hermes parsing the bundle text, which scales with bundle size. In the reporting repo the graph is dominated by a 7.1 MB generated protobuf package that the SDK under test imports transitively, so it cannot be removed.

The fix

1. Suspend the heartbeat around the blocking phase (primary fix).

The runtime knows exactly when it is about to block, so it says so:

  • New busy control message in the bridge protocol ({ type: 'busy', busy: boolean, label?: string }).
  • HarnessHandle.setBusy() on the app side; the server suspends the heartbeat on busy: true and resumes on busy: false.
  • evaluateModuleAsync() in the runtime wraps evaluateModule(): it sends busy: true, yields one macrotask so the message is actually flushed to native, then evaluates, then sends busy: false in a finally. The yield matters — a timer cannot fire from a blocked thread, so anything sent in the same tick as the eval() would sit in the RN message queue until the eval() finished, which is precisely what we are trying to avoid.

Suspension is bounded by maxSuspendMs (default 5min): if the app never reports the end of the phase — because it really did crash, or the message was lost — the heartbeat resumes on its own and the run fails as before. A blocking phase cannot silently disable liveness detection.

2. Make heartbeat timing configurable (safety valve).

New heartbeatInterval (default 5000) and heartbeatTimeout (default 20000) config options, threaded through createHarnessBridge. Documented in website/src/docs/getting-started/configuration.mdx.

3. Improve the diagnostic.

The old message led with "the app was killed, crashed, became unresponsive". It now leads with the blocked-JS-thread explanation, notes that an empty .harness/crash-reports means the app did not crash, and points at heartbeatTimeout. When the app had announced a blocking phase, the error also names it: "The app last reported it was busy with: evaluating example.harness.tsx."

Tests

  • bridge/src/__tests__/heartbeat.test.ts — suspension survives past the timeout; auto-resume after maxSuspendMs restores normal timeout behaviour; suspend/resume are no-ops after disposal.
  • bridge/src/__tests__/protocol.test.tsbusy round-trip and validation.
  • jest/src/__tests__/bridge.test.ts — regression tests pairing a real server and client, with an app that stops answering pings (the pongs are dropped at the transport, since server and client share one event loop here and a real block would freeze both): silent app without busy → run fails; app that announces the blocking phase first → run completes; app that stays blocked past maxSuspendMs → run fails and names the phase. Verified that the middle test fails without the server-side heartbeat.suspend().
  • runtime/src/bundler/evaluate.test.ts — the busy phase is announced before evaluation and cleared afterwards, including when evaluation throws, and evaluation still works with no bridge handle attached.

A fixture with a genuinely multi-MB module graph would also reproduce this end to end, but it would be slow and machine-dependent; the bridge-level tests pin the same behaviour deterministically.

nx run-many -t lint typecheck test passes across the monorepo.

🤖 Generated with Claude Code

…bundle

The runtime fetches each test file as its own Metro bundle and then evaluates
it with a synchronous eval(). Hermes parses the entire bundle text before
running a single line of it, so on a large module graph the JS thread is
blocked for tens of seconds. The bridge heartbeat expects a pong -- produced on
that same JS thread -- every 20s, so a sufficiently large test bundle would kill
its own run with "app heartbeat timed out" and 0 tests executed, with no crash
and no way to configure around it.

The runtime now sends a `busy` control message before it blocks (and yields once
so the message actually reaches the socket, since a timer cannot fire from a
blocked thread), and the server suspends the heartbeat until the matching
`busy: false` arrives. Suspension is bounded by `maxSuspendMs` (5min) so a real
crash or a lost message still fails the run.

Also adds `heartbeatInterval` / `heartbeatTimeout` config options as a safety
valve, and rewords the heartbeat-timeout error, which previously blamed a killed
or crashed app first even though the connection was still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@appfr3d is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

Rebuild the bundled GitHub Action so its embedded config schema knows about
heartbeatInterval and heartbeatTimeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant