Skip to content

Fix crashes when inspecting objects whose property enumeration throws - #37175

Closed
robobun wants to merge 11 commits into
mainfrom
farm/05daef36/fix-inspect-exception-leak
Closed

Fix crashes when inspecting objects whose property enumeration throws#37175
robobun wants to merge 11 commits into
mainfrom
farm/05daef36/fix-inspect-exception-leak

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash found by fuzzing (flaky under the fuzzer because it depends on global state from earlier iterations in the same process; deterministic repro below), plus several more crashes in the same family that surfaced while fixing it.

Inspecting an object can run arbitrary JS: lazy property initializers on native objects, getters, and proxy traps. The property enumeration loop used by console.log and Bun.inspect (JSC__JSValue__forEachPropertyImpl) mishandled exceptions from that JS in two places, and several things downstream of it crashed once those were fixed.

1. Exception leaked across loop iterations. When getPropertySlot reported not-found with an exception pending (a lazy property initializer threw), the continue skipped CLEAR_IF_EXCEPTION, so the next property's initializer ran with the exception still set and tripped exception scope verification in debug builds:

globalThis.Symbol = -6;
console.log(Bun); // aborts debug builds: Bun.$'s initializer evaluates shell.ts, which calls Symbol("cwd")
ASSERTION FAILED: Unexpected exception observed on thread ...
Error Exception: Symbol is not a function. (In 'Symbol("cwd")', 'Symbol' is -6)
!exception()
ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

In release builds the stale exception silently made later properties vanish from the output. The fix clears the exception before the continue, matching what JSC__JSValue__forEachPropertyOrdered already does.

2. Null deref in the prototype walk. The same loop advanced with iterating->getPrototype(globalObject).getObject(). A Proxy getPrototypeOf trap that throws makes getPrototype return an empty JSValue, and getObject() on an empty value calls a member function on a null JSCell. This crashes release builds:

const p = new Proxy({ a: 1 }, { getPrototypeOf() { throw new Error("trap"); } });
console.log(Object.create(p)); // SIGSEGV in current releases

The same pattern had two other live occurrences: the prototype walk in napi_get_all_property_names (napi.cpp, both the include_prototypes and own_only arms now check for a pending exception after each trap) and util.isError (NodeUtilTypesModule.cpp). The util.isError one had a second problem at the same line: a VMInquiry PropertySlot was still alive when getPrototype ran the trap, and a live VMInquiry slot forbids VM entry (debug builds abort in checkVMEntryPermission; in release the blocked entry is what produced the empty value). The slot is now scoped to die before the prototype read. util.isError(new Proxy({}, { getPrototypeOf() { throw 0 } })) segfaults current releases.

3. Debug-only re-entry with a pending exception, and the Bun.sql module tree. defaultBunSQLObject and constructBunSQLObject called reportUncaughtExceptionAtEventLoop while the exception was still pending; that re-enters JS and trips a structure assertion. Every other caller clears first and these two propagate right after, so the calls are removed. With them gone, the sql tree had the same hazard as item 4 below: internal/sql/shared.ts read Bun.env at module scope, which reifies a property on the Bun object while the Bun.sql / Bun.SQL lookup evaluating the module is still in progress, so a tree that then fails to evaluate (globalThis.Error = -6; Bun.sql) left debug builds asserting in storedPrototype. It now reads process.env, which is the same object. That is the only module-scope read of a Bun property in the sql tree.

4. Windows: env setup reified a Bun property mid-lookup. The windowsEnv builtin read Bun.inspect.custom while building the process.env proxy. Bun.$'s initializer evaluates process.env before Symbol("cwd"), so on Windows the first Bun.$ access transitioned the Bun object's structure and then threw, poisoning the in-progress static property lookup (caught by this PR's Windows CI). The symbol is now passed in from C++.

5. util.inspect lazy slots could not fail. m_utilInspectFunction and m_utilInspectStylizeColorFunction were set-once LazyProperty slots whose initializers bailed without init.set when requiring node:util threw, which violates the LazyProperty contract and aborts both debug and release builds:

const o = { [Symbol.for("nodejs.util.inspect.custom")]() { return "x"; } };
globalThis.Symbol = -6;
console.log(o); // SIGABRT in current releases; same with Bun.inspect(o, { colors: true })

The failure is usually transient (the registry does not cache a failed load; a stack overflow during the first custom inspect is enough), so caching a fallback would leave inspect degraded for the rest of the process. The two slots are now WriteBarrier members filled by accessors: on failure the accessor returns null with the exception pending and caches nothing, so the next inspect retries. callCustomInspectFunction clears it and falls back to default formatting for that one value; the other callers (URL, URLSearchParams, BroadcastChannel, streams) already propagate a pending exception. The unused Bun__REPL__formatValue is deleted.

Not in this PR: globalThis.Symbol = -6; require.cache aborts in the same way through m_lazyRequireCacheObject (NodeModuleModule.cpp). It is a different subsystem and is tracked for a separate fix using the same retryable-slot shape. Also left out: the Rust side has its own prototype read, JSValue::get_prototype, which returns a bare value with no way to report a throwing trap. Its formatter callers are not reachable with a throwing trap (the formatter unwraps proxies to their targets first; checked under exception scope validation), but napi_get_prototype returns napi_ok with an empty result and the exception left pending where Node returns napi_pending_exception. That is a parity bug rather than a crash and the right fix is at the get_prototype signature, so it is tracked separately as well.

How did you verify your code works?

Regression tests, each verified against the unfixed binary:

  • test/js/bun/util/inspect.test.js: the clobbered-Symbol enumeration of Bun, the throwing getPrototypeOf trap, and the custom inspect fallback. The fallback test pins that the fallback applies with and without colors, that a repeated call still falls back (nothing cached), and that the hook works again once the global is restored. All fail or crash on current releases.
  • test/js/node/util/util.test.js: spawned util.isError repro (segfaults current releases).
  • test/napi/napi.test.ts + addon: napi_get_all_property_names with a throwing descriptor trap in both key modes, asserting byte parity with Node (the include_prototypes case segfaults current releases).
  • test/js/sql/adapter-env-var-precedence.test.ts: spawned failing-sql-tree repro. This one is a debug assertion only: it aborts the debug build without the shared.ts change (verified by reverting just that file) and passes release builds either way. The rest of that file exercises the env resolution that shared.ts does, and passes unchanged with process.env.

Full files pass on Linux (bun bd test on inspect, util, bun-inspect, custom-inspect, url, broadcast-channel, napi, and the sql env file) and items 1, 2, 4 and the earlier version of 5 were also verified on a Windows machine. The original fuzzer reproduction exits cleanly on the fixed debug build.


no test proof · iteration 11 · 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 test/js/sql/adapter-env-var-precedence.test.ts test/napi/napi.test.ts

@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: 3ee3ff86-ea42-47ec-9342-e82b58b16cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 39fb3c1 and d4446c7.

📒 Files selected for processing (16)
  • src/js/builtins/ProcessObjectInternals.ts
  • src/js/internal/sql/shared.ts
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/JSEnvironmentVariableMap.cpp
  • src/jsc/bindings/UtilInspect.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers.h
  • src/jsc/bindings/napi.cpp
  • src/jsc/modules/NodeUtilTypesModule.cpp
  • test/js/bun/util/inspect.test.js
  • test/js/node/util/util.test.js
  • test/js/sql/adapter-env-var-precedence.test.ts
  • test/napi/napi-app/standalone_tests.cpp
  • test/napi/napi.test.ts
💤 Files with no reviewable changes (2)
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/headers.h

Walkthrough

Changes

The PR hardens inspection and property traversal against JavaScript exceptions. It adds Windows environment inspection symbol wiring, retryable inspection initialization, safe prototype traversal, updated SQL lazy evaluation, and regression tests.

Inspection exception safety

Layer / File(s) Summary
Windows environment inspection symbol wiring
src/js/builtins/ProcessObjectInternals.ts, src/jsc/bindings/JSEnvironmentVariableMap.cpp
windowsEnv receives the registered custom inspection symbol and uses it for the environment proxy handler.
Inspection and lazy initialization fallbacks
src/jsc/bindings/ZigGlobalObject.*, src/jsc/bindings/UtilInspect.cpp, src/jsc/bindings/bindings.cpp, src/jsc/bindings/BunObject.cpp, src/js/internal/sql/shared.ts
Inspection initialization and custom inspection use explicit exception handling. SQL environment access uses process.env, and debug-only SQL exception reporting is removed.
Exception-safe property traversal
src/jsc/bindings/bindings.cpp, src/jsc/bindings/napi.cpp, src/jsc/modules/NodeUtilTypesModule.cpp
Property and prototype traversal checks operation results before continuing.
Regression coverage
test/js/bun/util/inspect.test.js, test/js/node/util/util.test.js, test/js/sql/adapter-env-var-precedence.test.ts, test/napi/napi-app/standalone_tests.cpp, test/napi/napi.test.ts
Tests cover failed lazy initialization, custom inspection loading failures, throwing prototype traps, SQL evaluation failures, and N-API proxy descriptor failures.

Possibly related PRs

  • oven-sh/bun#37202: Overlaps the same inspection, exception-handling, environment initialization, and N-API traversal paths.
  • oven-sh/bun#38218: Modifies property enumeration in src/jsc/bindings/bindings.cpp.
  • oven-sh/bun#38560: Shares exception-safe JavaScriptCore error inspection and fallback changes.

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 and concisely summarizes the main crash fixes for object inspection with throwing property enumeration.
Description check ✅ Passed The description includes both required sections and provides detailed change context, reproduction cases, and verification results.

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

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 15th, 2026

@robobun, your commit 54367dc has 1 failures in Build #97827 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37175

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

bun-37175 --bun

@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):

  • 🟣 src/jsc/bindings/bindings.cpp:5637-5643 — The same getPrototype(globalObject).getObject() null-deref pattern this PR fixes has one other occurrence in src/jsc/bindings/napi.cpp:2071 (inside napi_get_all_property_names's descriptor-filter loop). Per REVIEW.md's "fix the whole class in the same PR" rule, it's worth applying the same guard there or noting the intentional exclusion — pre-existing and in a different subsystem (N-API), so non-blocking.

    Extended reasoning...

    What the bug is

    This PR identifies iterating->getPrototype(globalObject).getObject() as a crash pattern: when a Proxy getPrototypeOf trap throws (or getPrototype is called with an exception already pending), getPrototype returns an empty JSValue. On an empty JSValue, isCell() returns true (ValueEmpty == 0 passes the not-cell-mask test), asCell() returns nullptr, and asCell()->isObject() dereferences a null pointer → SIGSEGV. The PR fixes this at bindings.cpp:5637-5643 by checking scope.exception() before calling .getObject().

    Grepping for the exact pattern in src/jsc/bindings/ finds one remaining hit at napi.cpp:2071:

    while (!owner->getOwnPropertyDescriptor(globalObject, propKey, desc)) {
        JSObject* proto = owner->getPrototype(globalObject).getObject();
        if (!proto) {
            break;
        }

    The if (!proto) break; on the following line does not help — the crash happens inside .getObject() before proto is assigned.

    Code path that triggers it

    Reachable from napi_get_all_property_names when a native N-API module calls it with napi_key_include_prototypes and a descriptor filter (napi_key_enumerable / napi_key_writable / napi_key_configurable) on an object whose prototype chain contains a hostile Proxy.

    Step-by-step:

    1. collectInheritedPropertyKeys (line ~2051) walks the prototype chain to collect property names. A stateful getPrototypeOf trap returns a valid prototype (or null) here, so NAPI_RETURN_IF_EXCEPTION at line ~2056 passes.
    2. The descriptor-filter loop then re-walks the chain per property key. For a key not owned by the base object, getOwnPropertyDescriptor returns false and the loop body runs.
    3. Either (a) the stateful getPrototypeOf trap now throws on this second walk, so owner->getPrototype(globalObject) returns {}; or (b) a throwing getOwnPropertyDescriptor trap on the Proxy returns false with an exception pending, and the subsequent getPrototype call bails at its first RETURN_IF_EXCEPTION and returns {}.
    4. .getObject() on the empty JSValue calls asCell()->isObject() on a null cell → SIGSEGV.

    Why existing code doesn't prevent it

    The NAPI_RETURN_IF_EXCEPTION check happens before the filter loop, but there is no exception check between getOwnPropertyDescriptor and getPrototype, nor between getPrototype and .getObject(). The if (!proto) guard at line 2072 executes only after .getObject() has already dereferenced the null cell.

    Impact

    A native N-API module that enumerates properties (with prototype inclusion + descriptor filtering) on user-supplied objects can be crashed by a stateful/throwing Proxy in the prototype chain. Narrow attack surface — requires native module cooperation and an adversarial proxy — but it is the identical crash mechanism this PR is fixing for console.log.

    Fix

    Same shape as this PR's fix: split the call, check for a pending exception (returning napi_pending_exception via NAPI_RETURN_IF_EXCEPTION or breaking) before calling .getObject():

    JSValue protoValue = owner->getPrototype(globalObject);
    NAPI_RETURN_IF_EXCEPTION(scope);
    JSObject* proto = protoValue.getObject();
    if (!proto) break;

    Why this is flagged

    REVIEW.md's "Fix the whole class in the same PR" rule: "Grep for every sibling site sharing the pattern… If a site is intentionally excluded, say so in the PR." This is the only other hit for the pattern in src/jsc/bindings/ and the PR description does not mention excluding it.

    That said, this is pre-existing (not introduced or touched by this PR), lives in a different subsystem (N-API vs inspect), and has a much narrower trigger than console.log. Non-blocking — worth either applying the same one-line guard here or noting the intentional exclusion.

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

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed both findings in 60ded04:

  • Both new tests now drain stderr concurrently with stdout and assert { stderr: "", exitCode: 0 }, matching the sparse-array test above them.
  • Applied the same guard to the napi_get_all_property_names prototype walk in napi.cpp: getPrototype result is checked for a pending exception (returning napi_pending_exception) before calling getObject(). That was the only other occurrence of the pattern in src. I did not add an addon test for it since triggering it needs a native module enumerating a stateful hostile proxy with prototype inclusion plus a descriptor filter; the existing Node-parity tests for that function (test_napi_object_coercion) still pass byte-for-byte.

Comment thread src/jsc/bindings/bindings.cpp

@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 `@src/jsc/bindings/napi.cpp`:
- Around line 2071-2074: Update the prototype-walking loop around
owner->getOwnPropertyDescriptor() to call NAPI_RETURN_IF_EXCEPTION(env)
immediately after the descriptor lookup, before invoking owner->getPrototype().
Preserve the existing exception check after getPrototype() for proxy traps from
that operation.
🪄 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: 2a33d9ca-3333-48d1-8111-c9acefa3eaf9

📥 Commits

Reviewing files that changed from the base of the PR and between 37c4428 and c9a6d0f.

📒 Files selected for processing (8)
  • src/js/builtins/ProcessObjectInternals.ts
  • src/jsc/bindings/BunObject.cpp
  • src/jsc/bindings/JSEnvironmentVariableMap.cpp
  • src/jsc/bindings/UtilInspect.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/napi.cpp
  • test/js/bun/util/inspect.test.js
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/BunObject.cpp

Comment thread src/jsc/bindings/napi.cpp
Comment on lines +810 to +827
it.concurrent("console.log(Bun) survives a lazy property initializer throwing", async () => {
const code = `
globalThis.Symbol = -6;
console.log(Bun);
console.log("after-inspect");
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Enumeration must make it past the throwing initializers to the end of the table.
expect(stdout).toContain("zstdDecompress");
expect(stdout).toContain("after-inspect");
expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 });
});

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.

🔴 The first new test ("console.log(Bun) survives a lazy property initializer throwing") passes on unfixed release Bun 1.3.14 — USE_SYSTEM_BUN=1 bun test reports PASS, violating CLAUDE.md's CRITICAL rule and REVIEW.md's "a test that passes both ways is worse than no test". On release, a throwing PropertyCallback reifies the slot as undefined and getPropertySlot returns true, so there is no release-observable delta for this input; the condition it guards is debug-only. Either find an assertion that distinguishes fixed/unfixed release, or gate it as debug-lane coverage (e.g. it.skipIf(!isDebug) with a comment) like the describe.skipIf(!isASAN) block below it. The second new test correctly SIGSEGVs unfixed release, so its coverage is fine.

Extended reasoning...

What the finding is

CLAUDE.md marks the USE_SYSTEM_BUN=1 check CRITICAL: "Your test is NOT VALID if it passes with USE_SYSTEM_BUN=1." REVIEW.md lists it under merge-blocking test rejections: "Confirm deleting each load-bearing clause of your fix breaks at least one test — a test that passes both ways is worse than no test." The first of the two new tests in this PR — it.concurrent("console.log(Bun) survives a lazy property initializer throwing", …) at test/js/bun/util/inspect.test.js:810-827 — passes on the current unfixed release binary.

Empirical proof on Bun 1.3.14 (revision 0d9b296af, which lacks this PR)

Control — the same binary is definitively unfixed, because the second new test's repro crashes it:

$ bun -e 'const p = new Proxy({a:1},{getPrototypeOf(){throw new Error("trap")}}); console.log(Object.create(p));'
panic(main thread): Segmentation fault at address 0x5
exit=132

First test's child code on that same binary:

$ bun -e 'globalThis.Symbol = -6; console.log(Bun); console.log("after-inspect");'
exit=0
stderr: (0 bytes)
stdout: contains "zstdDecompress" (2×) and "after-inspect" (1×)

All four assertions in the test hold on the unfixed release binary: expect(stdout).toContain("zstdDecompress") ✓, expect(stdout).toContain("after-inspect") ✓, expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }) ✓. bunExe() returns process.execPath and bunEnv does not set BUN_JSC_validateExceptionChecks, so USE_SYSTEM_BUN=1 bun test test/js/bun/util/inspect.test.js -t "lazy property initializer" reports PASS.

Why there is no release-observable delta for this input

The PR reorders CLEAR_IF_EXCEPTION before if (!hasProperty) continue; in JSC__JSValue__forEachPropertyImpl. But on release, when a static PropertyCallback initializer throws, JSC still reifies the slot (as undefined) and getPropertySlot returns true — so hasProperty is true, control never took the continue branch for Bun.$ / Bun.sql / Bun.SQL / Bun.postgres, and it reached the pre-existing CLEAR_IF_EXCEPTION on the very next line anyway. The unfixed release output confirms this: the throwing properties render as $: undefined, sql: undefined, postgres: undefined, SQL: undefined, and enumeration continues cleanly to zstdDecompress.

The other two changes this test's input touches are also debug-only for release: the removed reportUncaughtExceptionAtEventLoop calls in defaultBunSQLObject / constructBunSQLObject were #if BUN_DEBUG, and the releaseAssertNoException / storedPrototype assertions this input trips are only compiled under BUN_JSC_validateExceptionChecks=1 on debug builds. So the PR description's claim that "in release builds the stale exception silently made later properties vanish from the output" is not what this test observes — release output is identical before and after, for this input.

Why this matters

The fix is real (it prevents a debug-build abort under exception-scope verification and keeps the fuzzer clean), but this test does not defend it. If the reorder in bindings.cpp:5575-5582 regressed tomorrow, this test would stay green on every release CI lane. That is exactly the failure mode REVIEW.md's rule targets.

How to fix

Two options:

  1. Gate it as debug-lane coverage. Import isDebug from harness and wrap the test in it.concurrent.skipIf(!isDebug)(…), with the comment updated to say the failure mode is a debug assertion (releaseAssertNoException under BUN_JSC_validateExceptionChecks). The describe.skipIf(!isASAN) block directly below at line 849 is precedent for exactly this pattern in the same file.
  2. Find an assertion that distinguishes release output. From direct observation there does not appear to be one for globalThis.Symbol = -6; console.log(Bun) — the throwing properties print as undefined and everything else is identical. If a different input can make the reordered !hasProperty → continue branch actually fire on release with an exception pending, use that instead.

The second new test (throwing getPrototypeOf trap) correctly SIGSEGVs the unfixed release binary (exit 132), so item #2's coverage is fine and needs no change.

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

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Responses to the latest round of review comments:

  • coderabbit's napi finding: applied in 7992a1f. The walk now checks for a pending exception right after the descriptor lookup, before getPrototype runs the next trap, and keeps the existing check after getPrototype.
  • The Windows failures at 60ded04 were real and are fixed in c9a6d0f: the windowsEnv builtin read Bun.inspect.custom during process.env setup, reifying a lazy Bun property mid-lookup (the symbol is now passed from C++), and the util.inspect LazyProperty initializers could bail without init.set when node:util failed to evaluate (they now always set a fallback). Verified on a Windows machine: the full file passes (75 pass, 1 ASAN-only skip), and the previously failing child command exits 0 with complete output. Details are in the updated PR description (items 4 and 5).
  • On the claim that the clobbered-Symbol test passes on unfixed release builds: against the current canary (45ee955, release mode, no assertions) it fails as intended, with the leak visibly dropping properties from the output (it contains neither "$" nor "zstdDecompress", starting at "main:" and ending at "semver"). The 1.3.14 stable binary predates changes to the Bun object's table, so its drops happen not to include the asserted property, but the binary the repo's verification rule prescribes (USE_SYSTEM_BUN) and the CI lanes both fail it without the fix, so the test is keeping its release coverage.

Comment thread src/jsc/bindings/napi.cpp
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

The descriptor-trap check suggested in the latest review comment is already in place: 7992a1f made NAPI_RETURN_IF_EXCEPTION the first statement of that while body, before getPrototype runs. The comment appears to have been generated against the earlier diff.

Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/bindings/napi.cpp
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Both findings from the latest review round are addressed in 6a062a9:

  • util.isError: confirmed, util.isError(new Proxy({}, { getPrototypeOf() { throw 0 } })) segfaults current releases. The root cause turned out to be one layer deeper than the missing exception check: the VMInquiry PropertySlot declared above is still alive when getPrototype runs the proxy trap, and a live VMInquiry slot forbids VM entry. Debug builds abort in checkVMEntryPermission before ever reaching the prototype checks; in release the blocked entry is what produces the empty JSValue that then null-derefs in inherits. The fix scopes the slot so it dies before the prototype read, and adds the exception check so the trap's error propagates like Node's instanceof does. Regression test added to test/js/node/util/util.test.js (the repro exits 139 on current release bun, passes with the fix).
  • napi else-arm: added the mirrored NAPI_RETURN_IF_EXCEPTION after the own-keys descriptor lookup.

I also swept the remaining getPrototype(globalObject) call sites in src/jsc for the widened pattern (any deref of a possibly-empty result, not just .getObject()): ObjectBindings.cpp, deepEquals, and the REPL completions already check exceptions; the JSBuffer and isAsyncFunction sites only run on cast-checked non-proxy receivers, so their prototype reads are direct and cannot throw.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/napi.cpp (1)

2070-2085: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add regression coverage for both descriptor lookup paths.

This change adds two exception-return paths. No same-change test exercises them. Add an N-API test through get_all_property_names with a throwing descriptor trap and napi_key_enumerable. Cover both napi_key_include_prototypes and napi_key_own_only. Assert that the trap error propagates without a crash.

As per coding guidelines, every behavioral change must include an automated regression test in the same change.

🤖 Prompt for 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.

In `@src/jsc/bindings/napi.cpp` around lines 2070 - 2085, Add regression coverage
for both descriptor lookup branches exercised by get_all_property_names: use a
proxy with a throwing getOwnPropertyDescriptor trap, call napi_key_enumerable
with napi_key_include_prototypes and napi_key_own_only, and assert the trap
error propagates without crashing.

Source: Coding guidelines

🤖 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 829-846: Strengthen the test around the console.log call by
asserting stdout contains the default rendering of the enumerable custom-inspect
symbol and does not contain "custom!". Keep the existing "after-inspect"
assertion and successful stderr/exitCode checks unchanged.

---

Outside diff comments:
In `@src/jsc/bindings/napi.cpp`:
- Around line 2070-2085: Add regression coverage for both descriptor lookup
branches exercised by get_all_property_names: use a proxy with a throwing
getOwnPropertyDescriptor trap, call napi_key_enumerable with
napi_key_include_prototypes and napi_key_own_only, and assert the trap error
propagates without crashing.
🪄 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: a97fc7cb-c092-45e2-b1d4-1734d63c625b

📥 Commits

Reviewing files that changed from the base of the PR and between c9a6d0f and 6a062a9.

📒 Files selected for processing (4)
  • src/jsc/bindings/napi.cpp
  • src/jsc/modules/NodeUtilTypesModule.cpp
  • test/js/bun/util/inspect.test.js
  • test/js/node/util/util.test.js

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

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Both remaining findings addressed in fed88ec:

  • napi regression coverage: added test_napi_get_all_property_names_throwing_traps to the addon, exercising an enumerable-filtered napi_get_all_property_names over proxies whose getOwnPropertyDescriptor trap throws for one key, in both napi_key_own_only and napi_key_include_prototypes modes. The traps are stateless (keyed on the property name) so Bun and Node invoke them identically, and checkSameOutput asserts byte parity: both modes report status=10 pending=1. The test fails against the current release (the include_prototypes case segfaults there) and passes with this branch.
  • inspect fallback test: now also asserts the custom hook did not run ("custom!" absent) and the object rendered with default formatting (the custom-inspect symbol key is visible in the output).

Comment thread test/js/node/util/util.test.js Outdated
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Applied in b399bc8: the util.isError repro now runs as a spawned fixture like the other crash-regression tests in this PR, asserting the child prints the caught trap error and exits 0. On the unfixed binary the child segfaults and only this one test fails instead of the whole runner.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for the latest build (90508): the PR's own tests (inspect, util.isError, napi) pass on every lane, including both Windows targets. The remaining failures are unrelated to this diff:

  • test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts, SIGABRT on the x64-asan lane: a pre-existing intermittent crash in worker terminate/transfer racing. The test's own header documents it as lane-specific and hard to reproduce (0/115 local runs); it also failed identically on build 90473 before this branch touched anything near it, and it is already filed for a separate fix.
  • Five entries tagged flaky by the CI tooling (bunx tarball extraction on Windows, next-pages SSR, bun-lockb verdaccio, terminal platform gaps, v8 heap snapshot), each passing on retry or in isolation.

From my side the change is complete and verified: six crash fixes in the inspect, enumeration, napi, and util.isError exception paths, with regression tests that fail on current releases and pass here, checked on Linux and Windows.

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

I reviewed this PR and the automated bug-hunt pass came up clean — every finding from the earlier rounds has been addressed through b399bc8. Because the change spans exception handling in seven JSC binding files (forEachPropertyImpl, LazyProperty initializer contracts, napi prototype walk, VMInquiry slot lifetime, Windows env re-entrancy) and bakes in a design choice to permanently cache a toString fallback for utilInspectFunction when node:util fails to evaluate once, a human look is still worthwhile.

What was reviewed:

  • The reordered CLEAR_IF_EXCEPTION in forEachPropertyImpl and the new getPrototype guard match what forEachPropertyOrdered already does.
  • m_utilInspectFunction / m_utilInspectStylizeColorFunction now always init.set on failure; the fallback is cached for the VM lifetime — worth a human confirming that tradeoff.
  • napi descriptor-trap checks now cover both include_prototypes and own_only arms, with a Node-parity checkSameOutput test.
  • windowsEnv receives the inspect symbol via vm.symbolRegistry().symbolForKey("nodejs.util.inspect.custom"), which is the same registry key Bun.inspect.custom resolves to.
Extended reasoning...

Overview

The PR fixes five related crashes in the property-enumeration path used by console.log / Bun.inspect when user JS (lazy property initializers, proxy traps) throws mid-enumeration. It touches bindings.cpp (forEachPropertyImpl exception ordering + getPrototype empty-value guard), napi.cpp (three new NAPI_RETURN_IF_EXCEPTION sites in the napi_get_all_property_names prototype walk), NodeUtilTypesModule.cpp (VMInquiry slot scoping + RETURN_IF_EXCEPTION after getPrototype in util.isError), ZigGlobalObject.cpp (LazyProperty initializers for utilInspectFunction / utilInspectStylizeColorFunction now always init.set a fallback), UtilInspect.cpp and the REPL formatter (clear-and-fallback when the lazy inspect function throws), BunObject.cpp (removed debug-only reportUncaughtExceptionAtEventLoop re-entry), and JSEnvironmentVariableMap.cpp + ProcessObjectInternals.ts (pass the inspect-custom symbol from C++ so Windows env setup no longer reifies a Bun lazy property mid-lookup). Four regression tests and a Node-parity napi test cover each fix.

Security risks

None identified. The changes tighten exception handling on paths reachable via hostile proxy traps and clobbered globals; no new user-controlled input reaches allocation sizes, paths, or credentials. The tryClearException calls swallow errors, but only on the best-effort formatting path where console.log is expected to keep going rather than crash — consistent with the existing CLEAR_IF_EXCEPTION pattern in the same loop.

Level of scrutiny

High. These are C++ JSC bindings on the exception-scope boundary — exactly the category REVIEW.md flags as most-blocked ("Exception checks after every call that can enter JS", "Never let a pointer or slice outlive the memory it points into"). The PR's own history bears this out: eight review rounds surfaced sibling sites (util.isError, the napi else-arm), a Windows-only re-entrancy crash, and a LazyProperty contract violation, each requiring a follow-up commit. A human should confirm the LazyProperty fallback design: once node:util fails to evaluate (e.g. a clobbered global), m_utilInspectFunction is permanently set to a toString-based stub for the VM's lifetime, so even if the global is later restored, custom inspect via that path won't recover. That's a defensible tradeoff (the alternative is a hard crash), but it's a behavioral choice.

Other factors

All prior automated findings (mine and CodeRabbit's) are resolved in the current diff. Test coverage is thorough — spawned crash repros for each SIGSEGV, a Node-parity checkSameOutput napi test, and the util.isError repro was moved to a spawned fixture per the last round's feedback. The one remaining unresolved inline (the first inspect test passing on 1.3.14 stable) was rebutted by the author: it fails on canary, which is what USE_SYSTEM_BUN targets. The Windows CI failures at 60ded04 that prompted the env-setup and LazyProperty changes are stated fixed in c9a6d0f; CI status on the current HEAD (b399bc8) should be verified before merge.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the one tradeoff the review flags for a human, the permanent toString fallback for utilInspectFunction when node:util fails to evaluate: LazyProperty initializers in vendored JSC are once-only by contract. callFunc asserts that init.set ran after the initializer returns, and there is no reset API, so "leave it uninitialized and retry on the next access" is not expressible without patching vendored WebKit. The failure itself only happens when evaluating node:util throws, which in practice means the script already clobbered a global the module depends on (the repro sets globalThis.Symbol = -6). The exception from the failed load is not swallowed at that moment: it stays pending, and the callers added in this PR clear it and use default formatting for that one inspect. What the cached fallback costs is the narrow case where a script breaks a global, inspects an object with a custom inspect hook, restores the global, and inspects again in the same VM; that later inspect uses String(value) through the fallback instead of the real util.inspect. The alternative on the first failure was an abort in both debug and release builds, so the fallback seemed like the right side of the tradeoff, but it is easy to revisit if a retry-capable slot is preferred.

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer fingerprint (7883fc5cf378b7ee) reduces to the same two defects this PR fixes, reached through a different entry point: the single-line inspect used for error messages, with no console.log involved.

globalThis.Symbol = NaN; // fuzzer did `Symbol++`
try { new CompressionStream(globalThis); } catch (e) {}

CompressionStream rejects the format argument with ERR_INVALID_ARG_VALUE, whose message renders the received value via JSValueToStringSafe -> Bun__inspect_singleline. Inspecting globalThis descends into Bun and reifies its lazy properties: Bun.$ throws (shell.ts calls Symbol("cwd")), and the exception leaks across the continue in forEachPropertyImpl, aborting debug builds with the releaseAssertNoException failure described in point 1. With that leak fixed, the same script then aborts through point 3: the BUN_DEBUG report in defaultBunSQLObject calls process->get("_fatalException") with the bun:sql load error still pending, setUpStaticFunctionSlot reifies _fatalException (transitioning the structure) but reports not-found because vm.exceptionForInspection() is set, and the lookup loop hits the storedPrototype structure assert.

A direct globalThis.Symbol = NaN; Bun.sql aborts debug builds through that second path on its own.

Verified on current main (9008ae7): both repros abort a debug build, and with this PR's bindings.cpp and BunObject.cpp changes applied they exit cleanly (the CompressionStream case throws a proper ERR_INVALID_ARG_VALUE), including the original Fuzzilli script replayed over the REPRL protocol.

@alii

alii commented Aug 15, 2026

Copy link
Copy Markdown
Member

@robobun this conflicts with main now, please rebase and get a fresh CI run so it can be merged.

robobun added 10 commits August 15, 2026 03:27
In the slow path of JSC__JSValue__forEachPropertyImpl, a property slot
lookup that reports not-found with an exception pending (a throwing lazy
property initializer) skipped the exception clear, so the next
property's initializer ran with the exception still set and tripped
exception scope verification in debug builds.

The prototype walk in the same loop called getObject() on the result of
getPrototype() without checking for an exception. A Proxy getPrototypeOf
trap that throws returns an empty JSValue, and getObject() on that
dereferences a null JSCell.

Also drop the debug-only reportUncaughtExceptionAtEventLoop calls in the
Bun.SQL lazy property callbacks: they re-entered JS while the exception
was still pending on the VM, which makes static table reification
misreport lookups, and the exception is propagated to the caller right
after anyway.
The napi_get_all_property_names descriptor-filter loop had the same
getPrototype().getObject() pattern: a throwing proxy trap returns an
empty JSValue and getObject() on it dereferences a null JSCell. Check
for the pending exception before converting.
Two more ways a hostile global environment crashed property enumeration,
both found by the Windows lanes on this PR:

The windowsEnv builtin read Bun.inspect.custom during process.env setup,
reifying a lazy property on the Bun object. When env setup runs inside
another Bun lazy property initializer that then throws (Bun.$ evaluates
process.env before Symbol("cwd")), that structure transition poisons
the in-progress static property lookup. Pass the registered symbol from
C++ instead.

The utilInspectFunction and utilInspectStylizeColorFunction LazyProperty
initializers bailed without init.set when requiring node:util threw,
which trips LazyProperty's post-init assertion and corrupts the
property. Always set a value (a toString-based fallback, or the no-color
stylize) and leave the exception pending. Callers clear it and fall back
to default formatting instead of custom inspect.
…n pending

A throwing getOwnPropertyDescriptor proxy trap left the exception
pending while getPrototype ran the next trap.
Two bugs at the same line: the VMInquiry PropertySlot above was still
alive when getPrototype ran a proxy's getPrototypeOf trap, and a live
VMInquiry slot forbids VM entry (debug builds abort in
checkVMEntryPermission, release builds turn the blocked entry into an
empty JSValue). The empty value then passed isCell() and the inherits
checks dereferenced a null JSCell. Scope the slot so it dies before the
prototype read, and propagate the trap's exception like Node's
instanceof does.

Also mirror the descriptor-trap exception check on the napi_key_own_only
arm of napi_get_all_property_names.
…fallback assertions

The napi addon test runs napi_get_all_property_names with an enumerable
filter over proxies whose getOwnPropertyDescriptor trap throws for one
key, in both own_only and include_prototypes modes, asserting Node
parity (napi_pending_exception with the trap error pending). The
include_prototypes case segfaulted before the fix.

The custom-inspect fallback test now also asserts the hook did not run
and the object rendered with default formatting.
… load

Replace the set-once LazyProperty slots for util.inspect and its color
stylize with WriteBarrier members filled by accessors. When node:util
fails to evaluate the accessor returns null with the exception pending
and caches nothing, so the next inspect retries instead of the whole
process being stuck on a toString stub. The callers already handle a
null return. Drop the stub function and the unused Bun__REPL__formatValue.

internal/sql/shared.ts read Bun.env at module scope, which reifies a
property on the Bun object while the Bun.sql / Bun.SQL lookup that is
evaluating the module is still in progress; if the tree then throws,
debug builds assert in storedPrototype. Read process.env instead, which
is the same object.

Tests: the custom inspect fallback test now covers colors, the repeated
call, and recovery once the global is restored; a spawned sql test
covers the failing module tree.
… calls

callCustomInspectFunction called utilInspectFunction() and then
createInspectOptionsObject() with no exception check in between. Both
accessors declare a ThrowScope, so when the first custom inspect in a
process asked for colors, exception scope verification aborted on the
x64-asan lane (bunshell.test.ts). The recovery test now performs the
colored call first so the same sequence is covered.
@robobun
robobun force-pushed the farm/05daef36/fix-inspect-exception-leak branch from efa764c to d4446c7 Compare August 15, 2026 03:37
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (39fb3c1) and force-pushed; the branch is now 10 linear commits (the earlier merge commit and the two empty retrigger commits are gone) and the resulting diff against main is the same 16 files as before. Fresh CI: https://buildkite.com/bun/bun/builds/97246

Three conflicts, all mechanical:

  • test/js/bun/util/inspect.test.js: the ASAN mutation test from Fix use-after-free in Bun.inspect when user code mutates the object mid-format #37169 landed at the same spot as the new tests; kept both.
  • ProcessObjectInternals.ts / JSEnvironmentVariableMap.cpp: main renamed the windowsEnv resetTZ callback to resetForDelete; the inspect symbol is still appended as the parameter after it on both sides.
  • bindings.cpp / headers.h: main added Bun__REPL__getProperty next to Bun__REPL__formatValue; getProperty is kept and formatValue is still deleted (re-checked that repl.rs only references evaluate, getCompletions and getProperty).

On the rebased tree the debug build passes the inspect, util, sql env, custom-inspect, url and broadcast-channel files under BUN_JSC_validateExceptionChecks=1 (458 tests) plus the two napi tests, and the new inspect and isError tests still fail against the current canary, so nothing on main fixed these independently.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Build 97246 is at 177 passed, 0 failed, but its two darwin 14 aarch64 test-bun shards have sat in scheduled for about five hours while newer main builds' arm64 shards have been picked up and passed, so they look stuck rather than queued. I only have read access on Buildkite; retrying those two jobs from the Buildkite side should complete the run (darwin x64 and every other lane are green), or it can be merged without them if you prefer.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

One more way to reach the utilInspectFunction() failure this PR reworks, with no global tampering, on every platform: the first custom inspect happening right after a stack overflow has been caught. requireId(NodeUtil) throws Maximum call stack size exceeded, the old LazyProperty initializer returns without init.set(), and LazyProperty::callFunc aborts the process.

const o = { [Symbol.for("nodejs.util.inspect.custom")]() { return "custom"; } };
let result;
function recurse() {
  try { recurse(); } catch {}
  if (result === undefined) { try { result = Bun.inspect(o); } catch {} }
}
recurse();
console.log(result);

On 1.4.0-canary (eabb96de7) this prints panic(main thread): abort() called and exits 134. Adding require("node:util") at the top makes it print custom and exit 0. Might be worth carrying as a test here since it exercises the retry-after-transient-failure path without relying on a clobbered global.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Build 97827 (the rerun): 176 passed. The one red lane is x64-asan, where spawn-stdin-readable-stream.test.ts hit a heap-use-after-free (a 97KB stream chunk read in the fetch-body-to-stdin path, last touched by #36087); this PR does not touch that code and I have filed it separately. The two darwin aarch64 test shards are again sitting in scheduled, so that queue looks like a general problem for this pipeline rather than something specific to this build. Everything this PR touches is green on both runs.

Comment thread src/jsc/bindings/napi.cpp
Comment on lines +2083 to +2085
JSValue protoValue = owner->getPrototype(globalObject);
// A throwing proxy trap leaves protoValue empty; getObject() on it is a null deref.
NAPI_RETURN_IF_EXCEPTION(env);

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.

🟡 The sibling sweep of getPrototype sites ("I also swept the remaining getPrototype(globalObject) call sites in src/jsc") was C++-only; the Rust N-API napi_get_prototype at src/runtime/napi/napi_body.rs:979-980 has the same bug class fixed here — object.get_prototype() on a Proxy with a throwing getPrototypeOf trap returns napi_ok with an empty result and a pending exception, whereas Node returns napi_pending_exception. Per REVIEW.md ("Fix the whole class… If a site is intentionally excluded, say so in the PR"), consider either checking env.has_pending_exception() after the call, or noting in the PR body that Rust-side get_prototype callers are excluded (the API returns JSValue, not JsResult<JSValue>; five sites across napi_body.rs / ConsoleObject.rs / pretty_format.rs). Nit: pre-existing, no release crash, narrow trigger.

Extended reasoning...

What the finding is

The PR's sibling sweep of throwing-getPrototype sites — stated in the review response as "I also swept the remaining getPrototype(globalObject) call sites in src/jsc for the widened pattern" — covered only C++ files under src/jsc/. The Rust N-API implementation napi_get_prototype at src/runtime/napi/napi_body.rs:979-980 has the identical bug class this PR fixes in napi_get_all_property_names (napi.cpp:2080-2086):

result.set(env, object.get_prototype(env.to_js()));
env.ok()

JSValue::get_prototype (JSValue.rs:2322) returns bare JSValue (not JsResult<JSValue>) and calls JSC__JSValue__getPrototype directly, with no from_js_host_call wrapper. JSC__JSValue__getPrototype (bindings.cpp:4163-4167) has no ThrowScope and no exception check — it just encodes value.getPrototype(arg1). env.ok() (napi_body.rs:124-126) unconditionally sets NapiStatus::ok without checking for a pending exception. So a throwing getPrototypeOf trap leaves the exception pending, writes an empty JSValue to *result, and returns napi_ok.

Step-by-step proof

  1. A native addon calls napi_get_prototype(env, proxy, &out) where proxy is new Proxy({}, { getPrototypeOf() { throw new Error('trap') } }).
  2. preamble! (napi_body.rs:477-485) checks for a pending exception at entry only — none is pending yet, so it passes.
  3. object.is_empty() and object.is_undefined_or_null() are both false (a Proxy is a non-empty object), so control reaches line 979.
  4. object.get_prototype(env.to_js())JSC__JSValue__getPrototypevalue.getPrototype(globalObject)ProxyObject::getPrototype, which calls the user's trap. The trap throws; getPrototype returns an empty JSValue (encoded 0) with the exception pending on the VM.
  5. result.set(env, <empty>) writes 0 to *out. This does not dereference a cell, so no release crash — this differs from the napi.cpp site, which called .getObject() on the empty value.
  6. env.ok() returns napi_ok. The addon receives napi_ok + an encoded-0 napi_value + a pending exception it did not expect.

Node.js returns napi_pending_exception here: its GetPrototypeV2() returns an empty MaybeLocal, and CHECK_MAYBE_EMPTY bails with the pending-exception status.

Why existing code doesn't prevent it

preamble! only checks exceptions at entry. The Rust JSValue::get_prototype type signature (fn get_prototype(&self, global: *mut JSGlobalObject) -> JSValue) does not surface the exception — the same shape as the C++ getPrototype(globalObject).getObject() before this PR fixed it, minus the null-deref. There is no post-body exception check between line 979 and the env.ok() return.

Impact

No release crash (the empty JSValue is stored, not dereferenced). The addon receives napi_ok with an invalid result and a pending exception, diverging from Node. The addon may then pass the empty napi_value to another N-API call and get napi_invalid_arg for no obvious reason, or the pending exception surfaces one N-API call late. Under BUN_JSC_validateExceptionChecks=1, the addon's next N-API call that declares a ThrowScope (via preamble!NAPI_PREAMBLE) would observe and return napi_pending_exception, so the exception is caught at the next call rather than this one — one call late.

Why this belongs in scope, and why it's still a nit

REVIEW.md, Correctness: the bug class, not the bug: "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep). Grep for every sibling site sharing the pattern… If a site is intentionally excluded, say so in the PR." This is a direct sibling of the napi.cpp site the PR fixes and adds a test for, in the same N-API surface (napi_get_prototype vs napi_get_all_property_names), missed because the sweep was scoped to src/jsc C++ files. The PR does not touch napi_body.rs, and the trigger requires a native addon plus a Proxy with a throwing getPrototypeOf trap — narrow. It does not block merge.

How to fix

Either:

result.set(env, object.get_prototype(env.to_js()));
if env.has_pending_exception() {
    return env.pending_exception();
}
env.ok()

…or note in the PR body that Rust-side get_prototype callers are intentionally excluded from this sweep. There are five: napi_body.rs:979, ConsoleObject.rs:3242/3972/4038, pretty_format.rs:877 — none wrap the call in from_js_host_call, and get_prototype itself does not return JsResult. Fixing the API signature to return JsResult<JSValue> would cover all five at once but is a larger change than this PR's scope.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the Rust-side sibling (napi_get_prototype in napi_body.rs): confirmed. JSValue::get_prototype returns a bare value, so a throwing getPrototypeOf trap comes back as napi_ok with an empty result and the exception pending, where Node returns napi_pending_exception. I also checked the formatter callers of the same Rust function (ConsoleObject.rs) by inspecting proxies over objects, classes and functions with a throwing trap, directly and nested, under exception scope validation; they are not reachable because the formatter unwraps proxies to their targets first. Since that napi case is a parity issue rather than a crash and the right fix is changing get_prototype to return a result (which touches all five Rust callers), I have left it out of this PR, noted the exclusion in the description, and filed it for a separate fix so this one can merge as reviewed.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: everything in this PR is covered by an open PR, one per bug.

@robobun robobun closed this Aug 17, 2026
dylan-conway pushed a commit that referenced this pull request Aug 18, 2026
…p or getPrototypeOf throws during the property walk (#29642)

### Problem

- `Bun.inspect()`, `console.log()` and `expect()` failure output crash
with `Segmentation fault at address 0x5` (debug builds: UBSan "member
call on null pointer of type 'JSC::JSCell'" in `JSCJSValueCell.h`) when
a property lookup throws while an object is being formatted. Release
repro:
  ```js
const proto = new Proxy({ a: 1 }, { getPrototypeOf() { throw new
Error("boom"); } });
  console.log(Object.create(proto));
  ```
and likewise with a Proxy whose `get` trap (or a getter reached through
a Proxy) throws for one property.
- The same happens with a lazily initialized property of the `Bun`
object whose initializer throws (fuzzer sample: `globalThis.Symbol`
replaced, then `Bun.inspect(Bun)`; the builtin behind `Bun.$` calls
`Symbol("cwd")`). Release builds print `Bun` with most of its properties
missing (72 of 115 on the shipped build); debug builds abort with
`ASSERTION FAILED: Unexpected exception observed` / `Symbol is not a
function. (In 'Symbol("cwd")' ...)` when the next lazy property is
built.
- Plain-JavaScript variant of the same leak: `console.log` of a module
namespace during an import cycle, when one export is still in its
temporal dead zone, throws `ReferenceError: Cannot access 'x' before
initialization` out of `console.log` (the stale exception is picked up
while the next export is formatted). `util.inspect` prints such an
export as `<uninitialized>`.
- Cause, in the slow path of `JSC__JSValue__forEachPropertyImpl`
(`src/jsc/bindings/bindings.cpp`):
- `object->getPropertySlot()` reports a throwing Proxy trap, a throwing
lazy initializer or a TDZ namespace export as "not found" with the
exception still pending, and the loop `continue`d before the
`CLEAR_IF_EXCEPTION` below it. The following lookups and formatting
callbacks then run with that exception pending (the dropped properties,
the rethrown `ReferenceError`, the debug assertion).
- When the walk moves to the next prototype,
`iterating->getPrototype(globalObject).getObject()` runs `getObject()`
on the empty `JSValue` that `getPrototype` returns when it threw (either
because of the stale exception above or because the `getPrototypeOf`
trap itself throws). The empty value passes `isCell()`, so this reads
the type byte of a null cell: the fault at address 5.
- `napi_get_all_property_names` (`src/jsc/bindings/napi.cpp`, descriptor
filter loop) has the same `getPrototype().getObject()` chain after an
unchecked `getOwnPropertyDescriptor`, so a Proxy trap throwing there
returned `napi_ok` with an exception pending in own-only mode and
segfaulted in include-prototypes mode.
- `defaultBunSQLObject` / `constructBunSQLObject`
(`src/jsc/bindings/BunObject.cpp`) had a debug-only block that handed a
sql module load failure to `reportUncaughtExceptionAtEventLoop` while
the exception was still pending on the VM, so `globalThis.Symbol = NaN;
Bun.sql` (and the fuzzer sample above, once the walk gets past `Bun.$`)
aborted debug builds with `ASSERTION FAILED: ... object->structure() ==
this` in `Structure::storedPrototype` instead of throwing.

### Fix

- `bindings.cpp`: clear the exception after `getPropertySlot` regardless
of its result, which is what the ordered variant
`JSC__JSValue__forEachPropertyOrdered` already does; read the next
prototype into a `JSValue`, clear the exception and stop the walk when
it is empty. (An earlier revision also held the prototype being walked
under an `EnsureStillAliveScope`; dropped, since the raw pointer is used
after every call into JS in the loop body and so is live across them
anyway.)
- Behaviour change to note: a property whose lookup throws is now left
out of the output instead of the whole `console.log` / `Bun.inspect`
call throwing or crashing. For TDZ namespace exports this differs from
`util.inspect`'s `<uninitialized>`; printing that marker would be a
formatter feature on top of this fix and is not attempted here.
- `napi.cpp`: check for an exception after `getOwnPropertyDescriptor`
and after `getPrototype` and return `napi_pending_exception`, which is
what Node returns for these cases.
- `BunObject.cpp`: drop the debug-only report. The exception is
propagated to the reader by the `RETURN_IF_EXCEPTION` right below it, so
debug builds now behave like release builds (`Bun.sql` throws).
- Why this is the right place: the formatter deliberately swallows
errors thrown by individual properties (getters, traps) and prints the
rest of the object; these two sites were the only ones in the walk that
acted on a "not found" result or a prototype value before clearing the
exception that produced it. Skipping just the property (or stopping at
just the prototype) whose lookup threw is the existing behaviour for
every other throw site in this function.
- Verification:
- `test/js/bun/util/inspect.test.js`, "Bun.inspect when a property
lookup throws" (5 spawned cases): a Proxy `get` trap, a getter behind a
Proxy, a `getPrototypeOf` trap, a throwing lazy `Bun` property, and a
two-file import cycle with a TDZ export. Without the fix (shipped
release build and an unfixed debug build) all five fail: the Proxy
children segfault / fail UBSan, the `Bun` child prints
`[false,false,...]` on release and aborts on debug, the cycle child
exits 1 with the `ReferenceError`; with it each prints everything except
the one property whose lookup threw.
- `test/js/bun/util/BunObject.test.ts`, "a lazy property whose builtin
fails to load throws from the read": `Bun.$` / `sql` / `SQL` /
`postgres` with `Symbol` broken throw a `TypeError` on two consecutive
reads. Aborts on an unfixed debug build (the `BunObject.cpp` hunk);
passes on release either way, as the removed block is debug-only. The
fixture builds `process.env` before breaking `Symbol` because the `$`
builder reads it, and building it on Windows reifies another `Bun`
property mid-lookup, which on a Windows debug build would hit the
separate `storedPrototype` assertion that #37001 fixes (verified on
Linux only).
- `test/napi/napi.test.ts`: `getOwnPropertyDescriptor` trap throwing in
own-only and include-prototypes mode (compared against Node), and a
`getPrototypeOf` trap that throws on the second call so the check after
`getPrototype` is the one that fires.
- Repros above and the tests also run clean under
`BUN_JSC_validateExceptionChecks=1`.

### Background

- `forEachPropertyImpl` is the property walk behind Bun's native
formatter. It collects the property names of the object and of up to
five prototypes, looks each one up through the original object with
`getPropertySlot`, and hands the value to a callback that formats it.
Errors thrown by individual properties are swallowed on purpose so that
one bad getter does not make `console.log` throw.
- A JSC exception is "pending" state on the VM, not C++ unwinding. A
function that throws returns a failure value (`false`, or the empty
`JSValue`) and leaves the exception on the VM; until something clears or
rethrows it, most JSC entry points return early as soon as they are
called, and debug builds assert when a function that did not throw is
observed returning with an exception pending. `CLEAR_IF_EXCEPTION` drops
the pending exception.
- The empty `JSValue` (`JSValue()`) is encoded as 0. `isCell()` is true
for it, so `getObject()` on it dereferences a null cell pointer rather
than returning null; callers have to test the value itself first.
- Lazy properties of the `Bun` object are entries in a static property
table whose value is produced by a builder the first time the property
is read (`PropertyCallback`). Some builders evaluate built-in JavaScript
modules (the shell for `Bun.$`, the sql module for `Bun.sql`), so they
can throw when that module fails to evaluate, and JSC reports that to
the reader as "property not found" plus a pending exception.

### Consolidated duplicates

Found repeatedly by the fuzzer (fingerprint `d678cafe50a2ad6e`). Earlier
round, folded in here in April: #29071 #28991 #28919 #28918 #28882
#28854 #28530 #28325. This round, closed in favour of this PR: #30099
#30245 #37160 #37175 #37213 #37256 #37428 #38700 #38921 #39363 #39365
#39380 #39412 #39413 (and #39411, closed earlier). The `BunObject.cpp`
hunk and the `BunObject.test.ts` test come from #30245 / #37428; the
same hunk is also part of #37001, which fixes the underlying
`storedPrototype` assertion in JSC.

Related fixes that are not part of this bug and stay open on their own:
#39382 (a custom inspect function when `node:util` fails to load),
#37202 (`util.isError` with a throwing `getPrototypeOf` trap), #37331
(`forEachPropertyOrdered` when the callback throws), #37001 (stale
structure in `JSObject::getPropertySlot`), #32263 (additional checks in
the same napi loop).

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 11 · 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 test/napi/napi.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: robobun <robobun@users.noreply.github.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.

2 participants