Skip to content

console.takeHeapSnapshot: report snapshot parse failures instead of aborting - #37070

Open
robobun wants to merge 4 commits into
mainfrom
farm/03035019/console-take-heap-snapshot-exceptions
Open

console.takeHeapSnapshot: report snapshot parse failures instead of aborting#37070
robobun wants to merge 4 commits into
mainfrom
farm/03035019/console-take-heap-snapshot-exceptions

Conversation

@robobun

@robobun robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

JSC__JSGlobalObject__generateHeapSnapshot (src/jsc/bindings/bindings.cpp, behind console.takeHeapSnapshot()) ended with:

WTF::String jsonString = snapshotBuilder.json();
JSC::EncodedJSValue result = JSC::JSValue::encode(JSONParse(globalObject, jsonString));
scope.releaseAssertNoException();
return result;

JSONParse can throw, realistically an out-of-memory error while building the parse tree for a large heap's snapshot JSON. When it does, the process aborts:

ASSERTION FAILED: Unexpected exception observed on thread ...
Error Exception: Out of memory
!exception()
JavaScriptCore/ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

via JSC__JSGlobalObject__generateHeapSnapshot. The fuzzer crash that motivated #30460 hit this same assert with an exception leaked from elsewhere, so the assert also turns unrelated pending exceptions into aborts.

There is a second failure mode with no assert at all: HeapSnapshotBuilder::json() returns the null string when the snapshot JSON overflows the maximum string length or its buffer allocation fails, and JSONParse maps a null string to an empty JSValue without throwing, so the old code passed an empty value into the console formatter. The same hole existed in Bun.generateHeapSnapshot()'s JSC branch (BunObject.cpp), where JSONParseWithException on a null string returns empty without throwing, which is not a valid host function result. Snapshots of multi-gigabyte heaps are where this lands, matching the crash pattern reported in #23393.

This is the same bug class as #37065 (samplingProfilerStackTraces) and #36857 (generateHeapSnapshotForDebugging). This call site was intentionally excluded from #37065 because the two-line reorder is not enough here: the result feeds the console formatter through the Rust caller, and the exception would then surface under JSC's consoleProtoFuncTakeHeapSnapshot, whose throw scope performs no exception check after the client call.

Fix

  • bindings.cpp: check for a pending exception after building the JSON, throw an out-of-memory error when json() returned the null string, and parse with JSONParseWithException + RELEASE_AND_RETURN. The function now returns empty if and only if an exception is pending.
  • BunObject.cpp: give Bun.generateHeapSnapshot()'s JSC branch the same null-string guard (its parse-exception handling was already correct).
  • JSGlobalObject.rs: generate_heap_snapshot() returns JsResult<JSValue> via from_js_host_call, so the empty-means-thrown contract is checked at the FFI boundary.
  • ConsoleObject.rs: on error, the takeHeapSnapshot hook reports the exception with report_active_exception_as_unhandled instead of printing the snapshot, and routes formatter errors from message_with_type_and_level_ the same way (the previous code re-entered the host shim, which intentionally leaves formatter errors pending for C++ callers that check). The hook cannot leave an exception pending: consoleProtoFuncTakeHeapSnapshot does not check for exceptions after the client call (the console API is void), and with BUN_JSC_validateExceptionChecks=1 its unreleased scope would abort in VM::verifyExceptionCheckNeedIsSatisfied. report_active_exception_as_unhandled is the runtime's method for exactly this situation (an exception raised in a native context with nowhere to propagate), keeps the error visible to process.on("uncaughtException") and the test runner, and skips reporting when the pending exception is a termination.

So a failed snapshot now behaves like an error thrown at the console.takeHeapSnapshot() call site that nothing caught: reported with a stack, interceptable, exit code 1 by default, instead of SIGABRT.

Test

The real triggers cannot be staged from JS: forcing an OOM inside JSONParse with constrained memory makes the earlier WTF-side snapshot allocations fail first (a non-recoverable crash, not a JS exception; #30460 was closed after reaching the same conclusion), and the null-string path needs a snapshot over the 2 GiB string limit. So the failure is injected at the seam where it would surface: a debug-only fault-injection env var, BUN_INTERNAL_FAIL_TAKE_HEAP_SNAPSHOT, makes generate_heap_snapshot() throw an out-of-memory error, following the existing BUN_INTERNAL_FAIL_PIPE_READER_START convention.

test/js/bun/console/console-take-heap-snapshot.test.ts (this API had no coverage):

  • console.takeHeapSnapshot() and the labeled variant print the parsed snapshot and exit cleanly under BUN_JSC_validateExceptionChecks=1.
  • An exception thrown while coercing the label argument propagates to the caller.
  • With the injected failure (debug builds): the error is reported as uncaught, execution continues, and the process exits 1; process.on("uncaughtException") intercepts it and the process exits 0. Both stay clean under the exception-check validator. These two tests fail on the previous code and pass with this change.

The unfixed abort itself was reproduced by temporarily forcing a throw at the parse site in a local build:

Transcript of the previous behavior with a simulated parse failure
$ bun -e 'process.on("uncaughtException", e => console.log("handled:", e.message)); console.takeHeapSnapshot(); console.log("after")'
ASSERTION FAILED: Unexpected exception observed on thread Thread:0x7b61290000c0 at:
The exception was thrown from thread Thread:0x7b61290000c0 at:
Error Exception: Out of memory

!exception()
JavaScriptCore/ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()
(exit 134, the handler never runs)

[review] gate passed · iteration 1 · 6 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/console/console-take-heap-snapshot.test.ts
bun test v1.4.0 (56cf03b9b)

test/js/bun/console/console-take-heap-snapshot.test.ts:
(pass) console.takeHeapSnapshot > propagates exceptions thrown while coercing the label [276.52ms]
23 |       env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" },
24 |       stdout: "pipe",
25 |       stderr: "pipe",
26 |     });
27 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
28 |     expect(stdout).toContain(`type: "Inspector"`);
                        ^
error: expect(received).toContain(expected)

Expected to contain: "type: \"Inspector\""
Received: ""

      at <anonymous> (/workspace/bun/test/js/bun/console/console-take-heap-snapshot.test.ts:28:20)
(fail) console.takeHeapSnapshot > prints the parsed snapshot and survives BUN_JSC_validateExceptionChecks [519.46ms]
66 |       env: { ...bunEnv, BUN_INTERNAL_FAIL_TAKE_HEAP_SNAPSHOT: "1", BUN_JSC_validateExceptionChecks: "1" },
67 |       stdout: "pipe",
68 |       stderr: 
... (truncated)

release without fix: 2 skipped
bun test v1.4.0-canary.1 (7a093dbb7)

test/js/bun/console/console-take-heap-snapshot.test.ts:
(skip) console.takeHeapSnapshot > reports a failed snapshot as an uncaught exception
(skip) console.takeHeapSnapshot > a failed snapshot error is interceptable via process.on(uncaughtException)
(pass) console.takeHeapSnapshot > propagates exceptions thrown while coercing the label [5.59ms]
(pass) console.takeHeapSnapshot > prints the parsed snapshot and survives BUN_JSC_validateExceptionChecks [13.39ms]

 2 pass
 2 skip
 0 fail
 7 expect() calls
Ran 4 tests across 1 file. [177.00ms]
__F:0:S:2
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/console/console-take-heap-snapshot.test.ts
bun test v1.4.0 (56cf03b9b)

test/js/bun/console/console-take-heap-snapshot.test.ts:
(pass) console.takeHeapSnapshot > propagates exceptions thrown while coercing the label [285.37ms]
(pass) console.takeHeapSnapshot > reports a failed snapshot as an uncaught exception [278.56ms]
(pass) console.takeHeapSnapshot > a failed snapshot error is interceptable via process.on(uncaughtException) [276.57ms]
(pass) console.takeHeapSnapshot > prints the parsed snapshot and survives BUN_JSC_validateExceptionChecks [510.62ms]

 4 pass
 0 fail
 14 expect() calls
Ran 4 tests across 1 file. [2.53s]
__F:0:S:0

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 668ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/25] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 239 extern-C blocks audited
[2/25] gen cpp.rs (cppbind)
[3/25] gen BunObject.lut.h
Generating /workspace/bun/build/release/codegen/BunObject.lut.h from /workspace/bun/src/jsc/bindings/BunObject.cpp
[4/25] gen JS modules (bundle-modules)
Preprocess modules (8840ms)
Bundle modules (43ms)
Postprocesss modules (31ms)
Bundle Functions (633ms)
Generate Code (28ms)

[9.59s] Bundled "src/js" for production
  2570 kb
  193 internal modules
  13 native modules
  84 internal functions across 17 files
[4/13] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
src/bun_core/env_var.rs                            |  2 +
 src/jsc/ConsoleObject.rs                           | 27 +++---
 src/jsc/JSGlobalObject.rs                          | 16 +++-
 src/jsc/bindings/BunObject.cpp                     |  8 ++
 src/jsc/bindings/bindings.cpp                      | 11 ++-
 .../bun/console/console-take-heap-snapshot.test.ts | 97 ++++++++++++++++++++++
 6 files changed, 145 insertions(+), 16 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                    reads  edits  tests
src/bun_core/env_var.rs                                     1      2      0
src/jsc/ConsoleObject.rs                                    2      4      0
src/jsc/JSGlobalObject.rs                                   2      3      0
src/jsc/bindings/BunObject.cpp                              1      2      0
src/jsc/bindings/bindings.cpp                               2      3      0
test/js/bun/console/console-take-heap-snapshot.test.ts      0      2      0

JSC__JSGlobalObject__generateHeapSnapshot ended with JSONParse followed
by scope.releaseAssertNoException(), so an exception from JSONParse
(out of memory building the parse tree, or one left pending during
snapshot generation) aborted the process. A null string from
HeapSnapshotBuilder::json(), returned when the snapshot overflows the
maximum string length, passed an empty JSValue into the console
formatter with no exception at all.

The binding now returns empty with the exception pending (throwing an
out-of-memory error for the null-string case), and the
console.takeHeapSnapshot hook reports that exception through the
uncaught-exception path instead of printing the snapshot, because JSC's
consoleProtoFuncTakeHeapSnapshot performs no exception check after the
client call, so the exception must not stay pending there.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Heap snapshot exception handling

Layer / File(s) Summary
Exception-aware snapshot generation
src/jsc/bindings/bindings.cpp, src/jsc/JSGlobalObject.rs
Heap snapshot serialization checks exceptions, handles null output as an out-of-memory error, uses JSONParseWithException, and returns JsResult<JSValue>.
Console handling and validation
src/jsc/ConsoleObject.rs, test/js/bun/console/console-take-heap-snapshot.test.ts
Console handling reports or terminates snapshot errors. Tests cover repeated snapshots, exception checks, successful completion, and label-coercion errors.

Possibly related PRs

  • oven-sh/bun#36857: Both changes update heap-snapshot JSON parsing and exception handling.
  • oven-sh/bun#36912: Both changes propagate JavaScript errors as JsResult.
  • oven-sh/bun#37064: Both changes address missing JavaScript exception checks in JSC heap operations.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly states that console.takeHeapSnapshot now reports snapshot parse failures instead of aborting.
Description check ✅ Passed The description clearly explains the problem, implementation, behavior change, and verification results, despite using different section headings than the template.

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

@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/console/console-take-heap-snapshot.test.ts`:
- Around line 5-28: The heap snapshot test only covers successful parsing;
extend it with a deterministic failure case that exercises the production
JSONParseWithException or null snapshot-string guard. Register an
uncaughtException handler to verify the parsing error is delivered, then run an
unhandled-error variant and assert it exits with code 1 while preserving the
existing successful-path assertions.
🪄 Autofix

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: d8e26f9e-8fd8-4cc6-af4f-55137c53625d

📥 Commits

Reviewing files that changed from the base of the PR and between d18ddfc and 88419b2.

📒 Files selected for processing (4)
  • src/jsc/ConsoleObject.rs
  • src/jsc/JSGlobalObject.rs
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/console/console-take-heap-snapshot.test.ts

Comment thread test/js/bun/console/console-take-heap-snapshot.test.ts

@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.

Beyond the inline nit, I traced report_error_or_terminatetake_exception (Task.rs:50) and confirmed it clears the pending exception off the VM before reporting, so the Err arm satisfies the "must not stay pending" invariant the new comment states. Also confirmed from_js_host_call uses the zero_is_throw shape, which matches the C++ side's new empty-iff-thrown contract.

Extended reasoning...

The core change is sound and mirrors sibling PRs #37065/#36857, but it threads through JSC exception-scope machinery across three layers (C++ ThrowScope, Rust FFI boundary, Rust console hook), which is subtle enough to warrant a human look. Deferring rather than approving for that reason; the inline nit is pre-existing and non-blocking.

Comment thread src/jsc/ConsoleObject.rs
…pending

The takeHeapSnapshot hook called the message_with_type_and_level host
shim, which leaves formatter errors pending on the VM for the C++
caller to check. consoleProtoFuncTakeHeapSnapshot performs no such
check, so a formatter failure hit the same unchecked-exception state
the previous commit fixed for the parse step. Call the inner
JsResult-returning function directly and route its error through the
same uncaught-exception reporting.

@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.

LGTM — the follow-up commit routes message_with_type_and_level_'s error through report_error_or_terminate as suggested, so the hook now upholds its no-pending-exception invariant on both the parse and format paths.

What was reviewed:

  • C++ binding matches the sibling shape in BunObject.cpp (JSONParseWithException + RELEASE_AND_RETURN) and the null-string OOM guard.
  • from_js_host_call enforces the empty-means-thrown contract at the FFI boundary; report_error_or_terminate clears the exception off the VM and reports it as uncaught, leaving termination to unwind.
  • Tests follow harness conventions (concurrent pipe drain, bunEnv spread, stdout asserted before exit code) and pin the reachable paths under validateExceptionChecks.
Extended reasoning...

Overview

Replaces releaseAssertNoException() in JSC__JSGlobalObject__generateHeapSnapshot with proper exception propagation across three layers: the C++ binding now checks for a pending exception, throws OOM if HeapSnapshotBuilder::json() returned null, and uses JSONParseWithException + RELEASE_AND_RETURN; JSGlobalObject::generate_heap_snapshot returns JsResult<JSValue> via from_js_host_call; and the takeHeapSnapshot console hook reports both snapshot-generation and formatting failures through report_error_or_terminate rather than leaving an exception pending under JSC's consoleProtoFuncTakeHeapSnapshot (which performs no post-call exception check). A new test file exercises the success path under BUN_JSC_validateExceptionChecks=1 and the label-coercion throw path.

Security risks

None. This is a debug/diagnostic API (console.takeHeapSnapshot); no untrusted input parsing, auth, or data exposure is involved. The change strictly narrows failure modes from abort → reported error.

Level of scrutiny

Low-to-moderate. The change is small (~30 net lines across 3 files plus a test), mechanical, and mirrors two already-merged sibling fixes (#37065, #36857) for the same releaseAssertNoException bug class at adjacent call sites. The C++ shape is copied from BunObject.cpp:845-846. The success path is behaviorally unchanged; only the previously-aborting failure path is affected.

Other factors

  • My prior inline finding (the format step could still leave an exception pending) was addressed exactly as suggested in 3562d82: the hook now calls the inner message_with_type_and_level_ directly and routes its Err through the same report_error_or_terminate helper, and the unsafe re-entry block is gone as a side effect.
  • CodeRabbit's request for a deterministic failure test was withdrawn after the author explained (consistent with the sibling PRs and #30460) that neither the OOM parse nor the 2 GiB null-string path is reachable in CI without hitting an earlier non-recoverable WTF-side allocation failure. The PR body documents instrumented before/after transcripts covering the uncaughtException handler, exit-code, and validator paths.
  • I verified report_error_or_terminate takes the exception off the VM before reporting (so no exception remains pending on return), and that discarding its Err(JsTerminated) via let _ is correct — termination should keep unwinding, not be re-reported.
  • Tests use describe.concurrent, drain stdout/stderr/exited concurrently, spread bunEnv, and assert output before exit code per repo conventions.

robobun added 2 commits August 6, 2026 21:16
…same hole in Bun.generateHeapSnapshot

Self-review follow-ups:

- Report failures through JSGlobalObject::report_active_exception_as_unhandled,
  the method built for exceptions raised in a native context with nowhere to
  propagate, instead of borrowing the task dispatcher's helper and discarding
  its termination sentinel.
- Add BUN_INTERNAL_FAIL_TAKE_HEAP_SNAPSHOT, a debug-only fault-injection flag
  (same convention as BUN_INTERNAL_FAIL_PIPE_READER_START) that makes
  generate_heap_snapshot throw out-of-memory at the seam where a real JSONParse
  failure would surface. Two new debug-only tests pin the reported behavior:
  uncaught report with exit 1 and execution continuing, and interception via
  process.on(uncaughtException), both clean under
  BUN_JSC_validateExceptionChecks=1. These fail on the previous code.
- Give Bun.generateHeapSnapshot the same null-string guard: its JSC branch
  parsed builder.json() with no isNull() check, so an overflowed snapshot
  returned an empty value with no exception, which is not a valid host
  function result.

@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.

LGTM — my earlier note about the format step leaving an exception pending was addressed in 3562d82, and the follow-up commit made the failure path testable via the debug-only fault-injection flag.

What was reviewed:

  • bindings.cpp / BunObject.cpp: the RETURN_IF_EXCEPTION → null-string guard → RELEASE_AND_RETURN(JSONParseWithException) shape matches the sibling fixes in #37065/#36857; both snapshot call sites now cover the null-string-returns-empty-without-throwing hole.
  • ConsoleObject.rs: both generate_heap_snapshot() and message_with_type_and_level_() errors now route through report_active_exception_as_unhandled, which materializes JsError::OutOfMemory via take_exception before reporting — so no unchecked exception can escape the hook.
  • generate_heap_snapshot()'s only caller was updated for the JsResult signature; the fault-injection flag is #[cfg(debug_assertions)]-gated and follows the existing BUN_INTERNAL_FAIL_PIPE_READER_START convention.
Extended reasoning...

Overview

This PR fixes console.takeHeapSnapshot() aborting via releaseAssertNoException() when JSONParse of the heap snapshot JSON throws (realistically OOM on a large heap), and closes a second hole where HeapSnapshotBuilder::json() returning the null string produced an empty JSValue that flowed into the console formatter (or out of Bun.generateHeapSnapshot() as an invalid host-function result). Six files touched: the C++ binding (bindings.cpp), the sibling Bun.generateHeapSnapshot host function (BunObject.cpp), the Rust FFI wrapper (JSGlobalObject.rs), the console hook (ConsoleObject.rs), a new debug-only fault-injection flag (env_var.rs), and a new test file.

Security risks

None. This is diagnostic tooling (console.takeHeapSnapshot / Bun.generateHeapSnapshot); no user-controlled input reaches new parsing or allocation logic. The change replaces a process abort with a reported uncaught exception, which is strictly less severe.

Level of scrutiny

Moderate. JSC exception-scope handling at the Rust↔C++ boundary is subtle, but the fix follows the exact idiom mandated by the review guidelines (RETURN_IF_EXCEPTION after fallible calls, RELEASE_AND_RETURN on the throwing tail call, throwOutOfMemoryError for the null-string case) and mirrors two recently-merged sibling fixes (#37065 samplingProfilerStackTraces, #36857 generateHeapSnapshotForDebugging) for the same bug class. The Rust side uses the standard from_js_host_call zero-is-throw contract and the documented report_active_exception_as_unhandled pattern for exceptions with nowhere to propagate.

Other factors

  • I previously flagged (inline, now resolved) that the format step via message_with_type_and_level(...) could also leave an exception pending under the same consoleProtoFuncTakeHeapSnapshot scope; commit 3562d82 addressed that by calling message_with_type_and_level_ directly and routing its Err through the same reporter, and dropped the unsafe re-entry block as a side effect.
  • CodeRabbit's request for a deterministic failure test was resolved after the author explained the constraints (WTF-side allocations fail non-recoverably before JSONParse can OOM; the null-string path needs a >2 GiB snapshot), then added the BUN_INTERNAL_FAIL_TAKE_HEAP_SNAPSHOT debug-only injection so the console hook's error path is exercised in CI on debug builds.
  • Verified generate_heap_snapshot() has exactly one caller (the console hook), so the JSValueJsResult<JSValue> signature change is fully covered. Bun.generateHeapSnapshot() is a separate C++ host function that doesn't go through the Rust wrapper, so the fault-injection flag correctly scopes to console.takeHeapSnapshot() only.
  • Verified take_exception(JsError::OutOfMemory) calls throw_out_of_memory() before reading the pending exception, so report_active_exception_as_unhandled handles the OOM variant that message_with_type_and_level_ can return.
  • The PR body's evidence block shows the new tests fail on main (3 failures under ASAN debug) and pass with the fix; the two fault-injection tests correctly skipIf(!isDebug) since the flag is compiled out of release builds.

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