Skip to content

Propagate exceptions from heap snapshot generation instead of asserting - #30460

Closed
robobun wants to merge 4 commits into
mainfrom
farm/7c4b72b5/heap-snapshot-exception-assert
Closed

robobun wants to merge 4 commits into
mainfrom
farm/7c4b72b5/heap-snapshot-exception-assert

Conversation

@robobun

@robobun robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator

Fuzzilli found a flaky crash where console.takeHeapSnapshot() aborted with:

ASSERTION FAILED: Unexpected exception observed
Error Exception: calling ArrayBuffer constructor without new is invalid
!exception()
ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

JSC__JSGlobalObject__generateHeapSnapshot (backing console.takeHeapSnapshot) called scope.releaseAssertNoException() after building the snapshot and running JSONParse. Any exception that ended up pending at that point — e.g. from property enumeration of exotic objects during HeapSnapshotBuilder::json(), or an exception left on the VM by other work running while the snapshot is taken — would abort the process rather than propagate.

Changes

  • bindings.cpp / BunJSCModule.h: replace releaseAssertNoException() with RETURN_IF_EXCEPTION + RELEASE_AND_RETURN in JSC__JSGlobalObject__generateHeapSnapshot, functionGenerateHeapSnapshotForDebugging, and functionSamplingProfilerStackTraces so exceptions propagate instead of crashing.
  • ConsoleObject.zig: bail out of takeHeapSnapshot if the snapshot call threw so we do not pass a zero value (and a pending exception) into messageWithTypeAndLevel.
  • html_rewriter.zig: after waitForPromise / unhandledRejection in HandlerCallback, capture and clear any exception that surfaced while draining the event loop so it does not escape as an unchecked pending exception on the outer host-call scope.

Test

Added a subprocess test that populates the heap with Proxy / ShadowRealm / node:vm contexts (objects whose constructor lookup may throw) and calls console.takeHeapSnapshot(), asserting a clean exit.

JSC__JSGlobalObject__generateHeapSnapshot (used by console.takeHeapSnapshot)
called scope.releaseAssertNoException() after building the snapshot and
parsing its JSON. If any exception ended up pending during that sequence
the process would abort with a release assertion instead of surfacing the
error.

Also apply the same treatment to the two bun:jsc helpers that used the
same pattern, teach console.takeHeapSnapshot to bail out cleanly if the
snapshot call threw, and capture exceptions that surface after
waitForPromise/unhandledRejection in HTMLRewriter handler callbacks so
they do not escape as an unchecked pending exception.
@robobun

robobun commented May 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:22 AM PT - May 10th, 2026

❌ @robobun, your commit 6905aa4 has 2 failures in Build #53160 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30460

That installs a local version of the PR into your bun-30460 executable, so you can run:

bun-30460 --bun

@coderabbitai

coderabbitai Bot commented May 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6296bcf8-2de0-4afd-835a-475fb64013c3

📥 Commits

Reviewing files that changed from the base of the PR and between d809def and 93944c1.

📒 Files selected for processing (1)
  • test/js/bun/util/take-heap-snapshot.test.ts

Walkthrough

This PR hardens heap snapshot generation by adopting consistent exception macros in C++ JSC bindings, adding early-return validation in Zig's takeHeapSnapshot, capturing exceptions thrown during unhandledRejection handling, and adding a test that verifies snapshotting with exotic heap objects.

Changes

Heap Snapshot Generation and Exception Handling

Layer / File(s) Summary
C++ Exception Handling Patterns
src/jsc/bindings/bindings.cpp, src/jsc/modules/BunJSCModule.h
JSC__JSGlobalObject__generateHeapSnapshot, functionSamplingProfilerStackTraces, and functionGenerateHeapSnapshotForDebugging now use RETURN_IF_EXCEPTION/RELEASE_AND_RETURN around JSONParse instead of manual scope.releaseAssertNoException(), aligning exception-return control flow.
Zig Validation and Guard
src/jsc/ConsoleObject.zig
takeHeapSnapshot adds early-return checks for .zero snapshot results and pre-existing VM exceptions, preventing emission of debug output for invalid or failed snapshots.
Promise Rejection Exception Propagation
src/runtime/api/html_rewriter.zig
HandlerCallback's rejected-path now inspects the TopExceptionScope after unhandledRejection; if an exception exists it stores it in unhandled_pending_rejection_to_capture, clears the scope exception, and returns true.
Test Coverage
test/js/bun/util/take-heap-snapshot.test.ts
Adds an integration test that spawns a subprocess which creates exotic objects (ShadowRealm, Proxy, vm context), calls console.takeHeapSnapshot(), emits "ok" to stderr, and asserts the child exits with code 0 and stderr contains "ok".
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: converting assertion-based exception handling to proper exception propagation in heap snapshot generation.
Description check ✅ Passed The description follows the template with both required sections filled: it explains what the PR does (the exception handling issue and changes made) and how it was verified (added subprocess test with exotic objects).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. getHeapSnapshot causes lodestar beacon node to crash #23393 - getHeapSnapshot causes crash on a large real-world application (Ethereum beacon node); this PR propagates exceptions from heap snapshot generation instead of asserting, which directly addresses the crash pattern

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #23393

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/util/take-heap-snapshot.test.ts`:
- Around line 26-28: The test currently asserts raw stderr equals "ok" which
fails when ASAN emits "WARNING: ASAN interferes with JSC signal handlers";
update the post-processing of the proc.stderr.text() result (the stderr variable
used with Promise.all([... proc.stderr.text(), proc.exited])) to filter out any
ASAN warning lines (e.g., remove lines that include "WARNING: ASAN interferes
with JSC signal handlers" or match that regex), then trim and assert the
filtered stderr equals "ok" while leaving the exitCode assertion (exitCode
variable) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f12d11db-8de5-49fd-b70e-d4147489fad1

📥 Commits

Reviewing files that changed from the base of the PR and between 16cba24 and d809def.

📒 Files selected for processing (1)
  • test/js/bun/util/take-heap-snapshot.test.ts

Comment thread test/js/bun/util/take-heap-snapshot.test.ts Outdated
@robobun

robobun commented May 10, 2026

Copy link
Copy Markdown
Collaborator Author

Closing — the fuzzer crash that motivated this change is a flaky releaseAssertNoException() that depends on state accumulated across prior REPRL iterations (the stdout in the crash report shows a heap snapshot and globals from previous runs, not from the crashing script itself). I could not construct a standalone test that deterministically leaves an exception pending at the releaseAssertNoException() call sites; HeapSnapshotBuilder::json() uses VMInquiry property lookups with a self-clearing TopExceptionScope, and the event-loop paths that could leak an exception into the handler callback all route through call(..., exceptionPtr) / drainMicrotasks() which clear non-termination exceptions.

The code changes are straightforward defensive hardening (swap releaseAssertNoException() for RETURN_IF_EXCEPTION / RELEASE_AND_RETURN), but without a fail-before test I can't demonstrate the fix.

@robobun robobun closed this May 10, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/bun/util/take-heap-snapshot.test.ts:1-4 — nit: per CLAUDE.md / test/CLAUDE.md ("add your test to the existing test file for the code you're changing — do not create a new file"), this regression test could live in the existing test/js/bun/util/heap-snapshot.test.ts, which already exercises the same JSC__JSGlobalObject__generateHeapSnapshot binding. Alternatively, since this fixes a real reported crash (#23393), test/regression/issue/23393.test.ts would also fit. Not blocking — just flagging since d809def moved it out of heap-snapshot.test.ts without a stated reason.

    Extended reasoning...

    What this is

    This is a minor test-organization convention concern, not a functional bug.

    Both the root CLAUDE.md (§ Test Organization) and test/CLAUDE.md state:

    Default: add your test to the existing test file for the code you're changing. Do not create a new file.

    and list the only exceptions as: a real GitHub-issue regression (→ test/regression/issue/NNNNN.test.ts), an integration test spanning multiple features, or a genuinely new feature with no existing test file.

    Why it applies here

    There is already an existing test/js/bun/util/heap-snapshot.test.ts that exercises Bun.generateHeapSnapshot(), which calls the exact same C++ binding (JSC__JSGlobalObject__generateHeapSnapshot in bindings.cpp) that this PR modifies. console.takeHeapSnapshot() is just the Zig ConsoleObject.takeHeapSnapshot wrapper around that same call. So by the "existing test file for the code you're changing" rule, the new test belongs in heap-snapshot.test.ts.

    The git history shows the test was in fact originally added to heap-snapshot.test.ts in commit 16cba24, then moved out to its own file in commit d809def ("Move regression test to its own file") with no rationale in the commit message or PR description.

    Why colocating is safe

    The new test spawns a subprocess (Bun.spawn({ cmd: [bunExe(), "-e", code], ... })) and only asserts on the child's stderr/exit code, so it shares no in-process heap state with the existing FormData/Blob/SharedArrayBuffer tests in heap-snapshot.test.ts. There is no isolation reason it couldn't live alongside them.

    Alternative location

    The github-actions bot identified this as fixing #23393 ("getHeapSnapshot causes crash on a large real-world application"). If that linkage is accepted, test/regression/issue/23393.test.ts would also satisfy the convention, since CLAUDE.md explicitly carves out test/regression/issue/<number>.test.ts for fixes tied to a real GitHub issue.

    Step-by-step

    1. PR modifies JSC__JSGlobalObject__generateHeapSnapshot in src/jsc/bindings/bindings.cpp.
    2. Existing test/js/bun/util/heap-snapshot.test.ts already tests Bun.generateHeapSnapshot(), which is the JS entry point for that exact function.
    3. CLAUDE.md says: add to the existing test file for the code you're changing.
    4. PR instead creates test/js/bun/util/take-heap-snapshot.test.ts (after first adding it to the existing file in 16cba24, then moving it out in d809def).
    5. No documented reason for the move; the subprocess test has no state coupling that would require isolation.

    Impact & fix

    Zero functional impact — purely organizational. To address: either move the test back into test/js/bun/util/heap-snapshot.test.ts, or rename it to test/regression/issue/23393.test.ts and add Fixes #23393 to the PR description. If there is a deliberate reason for the separate file (e.g. wanting it to run in its own process group for the snapshot), a brief note would resolve this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant