Skip to content

Fix debug assertion abort when inspecting Bun after a lazy property initializer throws - #37191

Closed
robobun wants to merge 2 commits into
mainfrom
farm/8f66dcea/fix-inspect-lazy-property-exception
Closed

robobun wants to merge 2 commits into
mainfrom
farm/8f66dcea/fix-inspect-lazy-property-exception

Conversation

@robobun

@robobun robobun commented Aug 8, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes a debug/ASAN assertion abort found by fuzzing (fingerprint 83e57e37b0902557).

Repro

globalThis.Symbol = new URL("https://example.com/");
console.log(Bun); // or Bun.inspect(Bun), or a failing expect(Bun) matcher message
ASSERTION FAILED: Unexpected exception observed on thread ...
Error Exception: Symbol is not a function. (In 'Symbol("cwd")', 'Symbol' is an instance of URL)
!exception()
JavaScriptCore/ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

The fuzzer hit this through expect(Bun).toBeDate(), whose failure message pretty-prints the received value. Any inspect of the Bun object reaches the same code.

Root cause

Bun.$ is a lazy static property. Its initializer runs the shell builtin, which calls the global Symbol (replaceable by user code), so reification can throw. setUpStaticFunctionSlot then reports the slot as not found and leaves the exception pending for the caller.

In JSC__JSValue__forEachPropertyImpl (the property walk behind Bun.inspect / console.log), the not-found case skipped the property before the CLEAR_IF_EXCEPTION:

if (!object->getPropertySlot(globalObject, property, slot))
    continue; // exception still pending
CLEAR_IF_EXCEPTION(scope);

The stale exception survived into the next property's lazy initialization, and the host function return glue asserts that an exception is pending if and only if the call failed, so debug and ASAN builds abort. JSC__JSValue__forEachPropertyOrdered already clears before skipping; this change makes the unordered walk match it.

With iteration no longer cut short, a second assert surfaced behind it: the BUN_DEBUG-only diagnostic for a failed bun:sql internal module load (the sql module also calls Symbol(...) at top level) passed the still-pending exception to reportUncaughtExceptionAtEventLoop. That runs property gets with a pending exception (tripping a stale-structure assert in getPropertySlot) and marks the process as failed, turning console.log(Bun) into exit code 1 in debug builds. The diagnostic now clears the exception, prints through the error printer only, and rethrows.

Release builds were not crashing here (the asserts compile out), but the walk silently dropped all remaining properties once one lazy initializer threw; that is fixed too.

Test

test/js/bun/util/inspect.test.js: spawns a child that replaces globalThis.Symbol and inspects Bun, asserting clean output and exit 0. Fails with an abort on the unfixed debug build, passes with the fix.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/inspect.test.js

…object formatting

JSC__JSValue__forEachPropertyImpl skipped a property without clearing the
exception scope when getPropertySlot returned false after a static lazy
property initializer threw (for example Bun.$ when the global Symbol has
been replaced with a non-callable). The stale exception then failed
exception scope assertions in debug and ASAN builds as soon as the next
lazy property initialized, aborting the process from Bun.inspect,
console.log, or an expect() failure message. Clear the exception before
the continue, matching JSC__JSValue__forEachPropertyOrdered.

With iteration no longer cut short, the debug-only BunSql module load
diagnostic also ran with the require exception still pending and routed
it through the uncaught exception machinery, which asserts on property
gets and marks the process as failed. Print the error with the exception
cleared and rethrow it afterwards.
@github-actions github-actions Bot added the claude label Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 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: a8722375-1a6d-46a2-aa62-b0c5ae531a86

📥 Commits

Reviewing files that changed from the base of the PR and between df54400 and fe2b6d9.

📒 Files selected for processing (1)
  • test/js/bun/util/inspect.test.js

Walkthrough

Changes

JSC exception safety

Layer / File(s) Summary
SQL module-load exception reporting
src/jsc/bindings/BunObject.cpp
Debug builds use a shared helper to log non-termination SQL load exceptions, restore them, and rethrow them from both SQL object loaders.
Property enumeration exception handling
src/jsc/bindings/bindings.cpp, test/js/bun/util/inspect.test.js
Property enumeration clears exceptions from failed lookups and skips unavailable properties. A child-process test verifies that inspecting Bun remains successful after replacing globalThis.Symbol.

Possibly related PRs

  • oven-sh/bun#37107: Modifies the same property enumeration function and related inspection test.
  • oven-sh/bun#37160: Overlaps with the SQL loader and property enumeration exception handling.
  • oven-sh/bun#37175: Shares the SQL loader, property enumeration, and inspection regression changes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the debug assertion fix and the lazy property inspection scenario.
Description check ✅ Passed The description explains the cause, implementation, regression test, and verification status in sufficient detail.
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.

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/util/inspect.test.js`:
- Around line 945-951: Update the Promise.all call in the proc test to
concurrently consume proc.stderr via proc.stderr.text() alongside stdout.text()
and proc.exited. Preserve the existing stdout and exit-code handling, and do not
add an assertion for empty stderr.
🪄 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: 2631e766-f871-400e-8c78-addc3834d37f

📥 Commits

Reviewing files that changed from the base of the PR and between 52bf09c and df54400.

📒 Files selected for processing (3)
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/util/inspect.test.js

Comment thread test/js/bun/util/inspect.test.js Outdated
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the undrained stderr pipe: the debug build writes the bun:sql load diagnostic there, so the child could block once the buffer filled. The test now reads stderr in the same Promise.all without asserting on its contents (fe2b6d9).

Comment on lines +320 to +323
// Print why the internal BunSql module failed to load. The exception must be
// cleared while printing (formatting the error reads its properties, which
// must not happen with a pending exception), then rethrown. Print-only: the
// uncaught-exception machinery would also flag the process as failed.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +5579 to +5580
// Ignore exceptions from "Get" proxy traps and throwing lazy
// property initializers (which report the slot as not found).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate: this fingerprint is another surface of the crash already fixed by #37107 (same forEachPropertyImpl change, equivalent test via clobbering Promise instead of Symbol) and by #37160, which goes further (per-level prototype re-reads, termination handling, the ordered variant, and removal of the debug-only bun:sql load diagnostic).

Two findings from this session that may be useful to whichever PR lands:

  1. The BUN_DEBUG sql diagnostic in defaultBunSQLObject / constructBunSQLObject calls reportUncaughtExceptionAtEventLoop with the exception still pending. Besides the stale-structure assert in Structure::storedPrototype (reached through Bun__handleUncaughtException reading process._fatalException, which reifies and transitions while setUpStaticFunctionSlot reports not-found due to the stale exception), it also routes through the uncaught-exception machinery and flags the process as failed, so console.log(Bun) exits 1 in debug builds even after the enumeration fix. Fix crashes when a lazy property or util.inspect fails while a value is being inspected #37160's removal of the diagnostic covers both.

  2. Replacing globalThis.Symbol (the fuzzer used a URL instance) reaches the same path through Symbol("cwd") in the shell builtin and Symbol("resolve") in internal/sql/query.ts, so any lazy property whose initializer runs a builtin that touches a clobbered global can trigger it, not just Promise.

@robobun robobun closed this Aug 8, 2026
Comment thread test/js/bun/util/inspect.test.js Outdated
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

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.

🔴 stderr is set to "pipe" but never drained — only stdout and exited are awaited. In debug builds this test's child writes several full error dumps to stderr via reportBunSqlModuleLoadError (added in this PR), and an unread pipe can fill and deadlock the child. Include proc.stderr.text() in the Promise.all, matching the neighboring tests in this file (lines 471, 906).

Extended reasoning...

What the bug is

The new test spawns a child with stderr: "pipe" but the Promise.all at line 951 only reads proc.stdout.text() and proc.exited:

await using proc = Bun.spawn({
  cmd: [bunExe(), "-e", code],
  env: bunEnv,
  stdout: "pipe",
  stderr: "pipe",   // piped…
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);  // …but never drained

REVIEW.md's subprocess-test rule states this exact pattern is blocked: "Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) — an unread pipe fills the ~64KB OS buffer and deadlocks the child. assert a combined { stdout, stderr, exitCode } object."

Why this test in particular writes to stderr

This isn't a theoretical concern for this test — it specifically exercises a stderr-writing debug path introduced by this same PR. The child replaces globalThis.Symbol and then inspects Bun twice (Bun.inspect(Bun) + console.log(Bun)). Each property walk hits the lazy sql, postgres, and SQL properties, whose initializers call internalModuleRegistry()->requireId(..., BunSql). The BunSql module calls Symbol(...) at top level, so the load throws.

In debug builds, reportBunSqlModuleLoadError (added in BunObject.cpp by this PR) then calls Bun__logUnhandledException, which routes through VirtualMachine::runErrorHandler and prints a full error dump — message, source snippet, and stack trace — to stderr for each failing property. bunEnv sets BUN_DEBUG_QUIET_LOGS=1, but that gates scoped debug loggers, not the error printer.

Step-by-step

  1. Child process replaces globalThis.Symbol with a URL instance.
  2. Bun.inspect(Bun) walks own properties → reifies Bun.sql → BunSql module load throws "Symbol is not a function".
  3. #if BUN_DEBUG → reportBunSqlModuleLoadError → Bun__logUnhandledException writes error+stack to stderr (fd 2, which is the piped fd).
  4. Same happens for Bun.postgres and Bun.SQL.
  5. console.log(Bun) walks the properties again → the lazy properties were never successfully reified (the exception was cleared by the fix in bindings.cpp), so steps 2–4 repeat.
  6. Meanwhile, the parent test never reads the stderr pipe. If accumulated stderr reaches the OS pipe buffer limit (~64KB on Linux), the child's write(2) blocks and proc.exited never resolves — the test hangs until the runner times out instead of failing with a useful message.

Why existing code doesn't prevent it

Nothing in the test consumes stderr. await using proc will kill the child on scope exit, but scope exit only happens after the awaited Promise.all resolves — which it won't if the child is blocked writing to a full stderr pipe.

Impact and likelihood

Today the ~6 error dumps are on the order of a few KB — likely under the 64KB threshold, so an actual deadlock is not expected on current CI. However: (1) REVIEW.md flags this exact pattern as merge-blocking and states "everything here has blocked merges"; (2) the two neighboring subprocess tests in this same file (lines ~471 and ~906) already follow the correct drain-all pattern, so this diverges from local convention; (3) the volume of debug-build stderr output is not stable — any future change that makes error dumps more verbose, or adds another lazy property with a debug diagnostic, could tip this into a hang; and (4) not draining stderr discards the diagnostic output entirely, so if this test does fail in CI the most useful information is thrown away.

Fix

One line — drain stderr in the same Promise.all, matching the neighboring tests:

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

Optionally assert on it (or at minimum keep it available for the failure message).

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