fix(bridge): keep the heartbeat alive while the app evaluates a test bundle - #184
Open
appfr3d wants to merge 2 commits into
Open
fix(bridge): keep the heartbeat alive while the app evaluates a test bundle#184appfr3d wants to merge 2 commits into
appfr3d wants to merge 2 commits into
Conversation
…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>
|
@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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A test run can fail with
[harness][bridge] app heartbeat timed outandTests: 0 totalpurely because the test bundle is large — nothing crashes, and there is no way to configure around it.Two mechanisms collide:
runtime/src/bundler/bundle.tsfetches each test file from Metro as its own?modulesOnly=truebundle (async, thread stays responsive), andruntime/src/bundler/evaluate.tsthen evaluates it with a synchronouseval(). 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.bridge/src/heartbeat.tspings every 5s and requires apongwithin 20s. Thepongis produced from JS (bridge/src/client.ts), so a blocked JS thread cannot possibly answer.createHeartbeatwas called frombridge/src/server.tswith nointervalMs/timeoutMs, so 20s was hard-coded and unreachable from user config (bridgeTimeoutis 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
.appbinary, same single test file, iPhone 17 sim / Xcode 26.2:app heartbeat timed out,Tests: 0 total..harness/crash-reportswas empty in the failing runs, confirming a stall rather than a crash. Timeline of a failing run:Run started06:07:29 →Running "<App>"06:07:32 (bridge alive, logs forwarding) →app heartbeat timed out06:08:42, i.e. the thread went silent ~50s after render — right when the cold Metro build finished andeval()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: trueis 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:
busycontrol message in the bridge protocol ({ type: 'busy', busy: boolean, label?: string }).HarnessHandle.setBusy()on the app side; the server suspends the heartbeat onbusy: trueand resumes onbusy: false.evaluateModuleAsync()in the runtime wrapsevaluateModule(): it sendsbusy: true, yields one macrotask so the message is actually flushed to native, then evaluates, then sendsbusy: falsein afinally. The yield matters — a timer cannot fire from a blocked thread, so anything sent in the same tick as theeval()would sit in the RN message queue until theeval()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) andheartbeatTimeout(default 20000) config options, threaded throughcreateHarnessBridge. Documented inwebsite/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-reportsmeans the app did not crash, and points atheartbeatTimeout. 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 aftermaxSuspendMsrestores normal timeout behaviour; suspend/resume are no-ops after disposal.bridge/src/__tests__/protocol.test.ts—busyround-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 withoutbusy→ run fails; app that announces the blocking phase first → run completes; app that stays blocked pastmaxSuspendMs→ run fails and names the phase. Verified that the middle test fails without the server-sideheartbeat.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 testpasses across the monorepo.🤖 Generated with Claude Code