Skip to content

Fix crash and dropped properties in Bun.inspect when a property lookup or getPrototypeOf throws during the property walk - #29642

Merged
dylan-conway merged 11 commits into
mainfrom
farm/43e87898/inspect-proxy-proto-throw
Aug 18, 2026
Merged

Fix crash and dropped properties in Bun.inspect when a property lookup or getPrototypeOf throws during the property walk#29642
dylan-conway merged 11 commits into
mainfrom
farm/43e87898/inspect-proxy-proto-throw

Conversation

@robobun

@robobun robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

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:
    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 continued 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 Fix stale-structure abort when a lazy Bun property builder throws mid-lookup #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).


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

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:39 AM PT - Aug 17th, 2026

@robobun, your commit 3f713ff4e80ec670527b80d479501df2920d73a1 passed in Build #100040! 🎉


🧪   To try this PR locally:

bunx bun-pr 29642

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

bun-29642 --bun

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Prototype-chain traversal and property enumeration were hardened: prototypes are tracked as JSValue and preserved during enumeration, prototype hops are limited, Proxy Get and getPrototypeOf exceptions are cleared, null or mismatched prototypes stop traversal, and tests added to cover Proxy-related inspect regressions.

Changes

Cohort / File(s) Summary
Bindings: prototype & property enumeration
src/bun.js/bindings/bindings.cpp
Maintain current prototype as a JSValue (iteratingValue) and keep it alive with EnsureStillAliveScope; limit prototype hops (max ~5); record hasProperty from getPropertySlot(...); clear exceptions from Proxy Get and getPrototypeOf traps; stop traversal on falsy prototype or mismatched JSGlobalProxy.
N-API: safe prototype handling
src/bun.js/bindings/napi.cpp
Capture prototype into a JSValue, check for pending exceptions immediately after each getPrototype(globalObject) before converting to JSObject*, treat prototype pointer as nullable, and check for exceptions after getOwnPropertyDescriptor(...) in the direct-descriptor path.
Tests: Proxy-related inspect regressions
test/js/bun/util/inspect.test.js
Add regression tests ensuring Bun.inspect does not throw when prototype getters or getPrototypeOf traps on a Proxy throw, and that inspection prints expected output (e.g., {} or the object's own properties).
🚥 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 describes the main crash and property-loss fix in Bun.inspect for throwing property lookups and prototype traps.
Description check ✅ Passed The description explains the problem, root cause, fix, behavior change, affected APIs, and detailed verification results.

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

@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 — minimal, targeted crash fix that reorders an existing exception-clear and adds a null guard, matching the pattern already used in forEachPropertyOrdered.

Extended reasoning...

Overview

Two-file change: ~10 LOC in src/bun.js/bindings/bindings.cpp inside JSC__JSValue__forEachPropertyImpl, plus two regression tests in test/js/bun/util/inspect.test.js. The C++ change (1) moves CLEAR_IF_EXCEPTION(scope) to run before the early continue when getPropertySlot returns false, and (2) checks the result of iterating->getPrototype(globalObject) for empty/exception before calling .getObject() on it.

Security risks

None. This is the Bun.inspect / console.log property-enumeration path. The change swallows exceptions from Proxy traps during inspection, which is exactly what the surrounding code (and the sibling forEachPropertyOrdered at bindings.cpp:5472-5477) already does. No new data exposure, no auth/crypto, no user-controlled input handling beyond what already existed.

Level of scrutiny

Low. This is a fuzzer-found segfault fix with a clear root cause (pending exception leaks past a continue, then getPrototype returns empty JSValue{}, then .getObject() derefs null). The fix is the minimal correct reordering and mirrors the identical pattern already present a few hundred lines below in forEachPropertyOrdered. The added getPrototype guard is straightforward defensive null-checking.

Other factors

  • Two new regression tests cover both crash paths (throwing getter behind a Proxy prototype, and throwing getPrototypeOf trap).
  • Bug hunting system found no issues.
  • No design decisions or behavior changes — purely converts a crash into the existing "ignore exceptions during inspect" behavior.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix null deref in Bun.inspect when Proxy getPrototypeOf throws #29071 - Fixes the same null deref in forEachPropertyImpl when Proxy getPrototypeOf throws, with identical two-part fix (CLEAR_IF_EXCEPTION reorder + getPrototype guard)
  2. fix null deref in forEachProperty prototype walk #28991 - Fixes the same getPrototype().getObject() null deref in forEachPropertyImpl slow path
  3. Handle exception from getPrototype in forEachProperty #28918 - Handles exception from getPrototype in forEachPropertyImpl when Proxy trap throws
  4. Fix use-after-poison in forEachProperty prototype chain walk #28919 - Fixes use-after-poison in the same forEachPropertyImpl prototype chain walk
  5. Fix null deref in forEachProperty when Proxy prototype throws #28882 - Fixes null deref in forEachPropertyImpl when Proxy prototype throws
  6. Fix crash in forEachProperty when prototype chain contains a Proxy #28854 - Fixes crash in forEachPropertyImpl when prototype chain contains a Proxy
  7. fix: clear exception before continue in property enumeration slow path #28530 - Moves CLEAR_IF_EXCEPTION before continue in property enumeration slow path (fix Fix ?? operator  #1 of this PR)
  8. Fix null deref in forEachProperty when prototype is a Proxy #28325 - Fixes null deref in forEachPropertyImpl slow path by checking exceptions after getPrototype()
  9. getPrototype exception checks #24985 - Adds getPrototype exception checks addressing the same Bun.inspect crash

🤖 Generated with Claude Code

@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures are unrelated pre-existing flakes, not caused by this change:

  • test/js/bun/net/socket.test.ts — "should not leak memory" heap count 4 vs ≤3 on Windows (GC timing; also failing in build #47551)
  • test/js/bun/webview/webview-chrome.test.ts — "Failed to spawn Chrome" on darwin-aarch64 (agent missing Chrome; also failing in build #47551)
  • test/js/bun/http/tls-keepalive.test.ts — memory growth 75.64MB vs <75MB on darwin-aarch64 (threshold barely exceeded)

This PR only touches JSC__JSValue__forEachPropertyImpl (used by Bun.inspect); inspect.test.js passes on all platforms including debian-13-x64-asan.

@alii

alii commented Apr 23, 2026

Copy link
Copy Markdown
Member

@robobun how many of these are duplicates? Which is the best one if they are dupes?

@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

@alii — compared all 9. They all touch the same ~5 lines in JSC__JSValue__forEachPropertyImpl, but there are actually two bugs there:

  • A: getPropertySlot returns false with a pending exception → continue runs before CLEAR_IF_EXCEPTION → exception leaks to the next iteration
  • B: iterating->getPrototype(globalObject).getObject() derefs null when getPrototype returns empty (either because of a throwing getPrototypeOf trap, or because of the leaked exception from A)
PR A B Tests Notes
#29642 (this)
#29071 functionally identical to this one
#28530 partial — still crashes on direct getPrototypeOf throw
#28991
#28918
#28919 also restructures loop + EnsureStillAliveScope
#28882 also guards a similar pattern in napi.cpp
#28854 no tests
#28325
#24985 dylan-conway's; codebase-wide getPrototype exception sweep across 9 files; propagates instead of swallowing; 5 months stale

tl;dr: All 9 are dupes. Only #29642 and #29071 fix both bugs — they're byte-for-byte equivalent in the C++, so pick either (this one has the more minimal/direct regression tests in inspect.test.js). The other 7 fix only one of the two and leave a crash path reachable.

#24985 is the most principled if you want the broader getPrototype cleanup, but it needs a rebase and would still need fix A layered on top.

@alii

alii commented Apr 23, 2026

Copy link
Copy Markdown
Member

@robobun close all the others, bring the two changes into this PR

@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Comment thread src/jsc/bindings/napi.cpp
iterating = iterating->getPrototype(globalObject).getObject();
iteratingValue = iterating->getPrototype(globalObject);
// Ignore exceptions from Proxy "getPrototypeOf" trap.
CLEAR_IF_EXCEPTION(scope);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we should probably not be clearing these exceptions, but looks like it was doing it originally

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — kept the swallow to match the existing fast-path handling a few lines up and forEachPropertyOrdered (so Bun.inspect/console.log stay best-effort rather than throwing mid-format). Switching this whole path to propagate is the scope of #24985; happy to rebase that on top of this if/when it's revived.

@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 the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/bindings/bindings.cpp`:
- Line 5242: The slow-path prototype hop budget is off by one compared to the
fast path: update the conditional that checks prototypeCount in the slow-path
loop so it uses the same logic as the fast path (i.e., the same
comparison/operator used in the fast-path `prototypeCount++ < 5` check) to
ensure `forEachProperty*` visits the same number of prototype hops; locate the
`prototypeCount` check in the slow-path loop in bindings.cpp (the `if
(prototypeCount++ >= 5)` line) and change it to match the fast-path condition so
both paths allow the same five-prototype-hop traversal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c84a1ad1-c643-4464-85ba-93160cceb85a

📥 Commits

Reviewing files that changed from the base of the PR and between 8da7cbc and 93d9a9c.

📒 Files selected for processing (2)
  • src/bun.js/bindings/bindings.cpp
  • src/bun.js/bindings/napi.cpp

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment on lines +5647 to +5648
// Ignore exceptions from "Get" proxy traps and throwing lazy property
// initializers; both report the property 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 17, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up after a self-review of the consolidated diff:

  • The bindings.cpp change is back to the two-hunk shape the duplicates shared. The JSValue holder plus EnsureStillAliveScope that was folded in from Fix use-after-poison in forEachProperty prototype chain walk #28919 in April is gone: the raw iterating pointer is used after every call into JS in the loop body, so it is live across them already, and nothing in the PR depended on the extra scope. The PR body says so rather than dropping it silently.
  • Added a fifth case to the inspect tests: console.log of a module namespace during an import cycle, where one export is still in its TDZ. On the shipped build this throws ReferenceError: Cannot access 'later' before initialization out of console.log; with the fix the export is left out and the rest prints. This is the same leak reached with plain code, no Proxy or broken global needed. The throw-to-omit change is now called out in the body (util.inspect prints <uninitialized> for this; doing the same here would be a separate formatter change).
  • The BunObject.test.ts fixture now builds process.env before breaking Symbol, so the $ row does not depend on Fix stale-structure abort when a lazy Bun property builder throws mid-lookup #37001 on a Windows debug build (CI's Windows lanes are release builds, so this only matters for local debug builds there; checked on Linux only).

Fail-before / pass-after re-run for both test files on an unfixed debug build, the shipped release build, and this branch.

robobun and others added 10 commits August 17, 2026 15:25
When enumerating properties via forEachProperty, a Proxy in the
prototype chain eagerly invokes [[Get]] to fill the property slot. If
the underlying getter throws, getPropertySlot returns false with a
pending exception, and we would `continue` before clearing it. The
pending exception then caused the subsequent getPrototype() on the
Proxy to return an empty JSValue, and calling .getObject() on that is
a null-pointer member call.

Clear the exception before the early continue (matching the pattern
already used in forEachPropertyOrdered), and also guard the
getPrototype() result so a throwing getPrototypeOf trap stops
iteration instead of crashing.
…get_all_property_names

Hold the current link in the prototype chain as a JSValue under an
EnsureStillAliveScope so a Proxy trap that runs JS (and may GC) during
property enumeration can't free it out from under us.

Also guard the equivalent getPrototype().getObject() pattern in
napi_get_all_property_names, propagating Proxy trap exceptions as
napi_pending_exception.

Supersedes #29071 #28991 #28919 #28918 #28882 #28854 #28530 #28325.
The get_all_property_names fixture helper now clears a pending exception
and returns it alongside the status, so the test can assert
napi_pending_exception for both the own-only and include-prototypes
descriptor walks. Output is compared against Node.
…earing

A stateless throwing getPrototypeOf trap is caught during key collection,
before the descriptor walk. Use a trap that succeeds once and throws on
the second call so the walk's own check is what returns
napi_pending_exception. Bun-only: V8 makes a single getPrototypeOf call
here, so the case has no Node counterpart.
…e lazy property builder

The debug-only reportUncaughtExceptionAtEventLoop call in defaultBunSQLObject and
constructBunSQLObject ran with the exception still pending on the VM, so a sql
module that failed to evaluate aborted debug builds instead of throwing from the
Bun.sql read. The exception is propagated to the reader right below it anyway.
…cover a throwing lazy property

Each case now checks that only the property whose lookup threw is skipped, and
the Bun object case covers a static lazy property whose initializer throws.
…etPrototype result

The iterating pointer is used after every call into JS in the loop body, so it
is already live across them; the JSValue holder and EnsureStillAliveScope added
nothing. This leaves the fix as the two hunks: clear the exception before acting
on the getPropertySlot result, and stop the walk when getPrototype threw.
…ess.env before breaking Symbol in the Bun.$ test
@robobun
robobun force-pushed the farm/43e87898/inspect-proxy-proto-throw branch from 5309742 to fa96682 Compare August 17, 2026 15:27

@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 the Aug 17 revision and didn't find any bugs. Because this is the consolidated fix for ~20 duplicate PRs and touches JSC exception handling in the formatter's property walk plus a called-out behavior change (TDZ namespace exports now omitted rather than printed as <uninitialized>), a maintainer sign-off would still be worthwhile.

What was reviewed:

  • bindings.cpp slow-path: the CLEAR_IF_EXCEPTION reorder matches the fast path at 5580/5611 and forEachPropertyOrdered; !prototype catches only the empty JSValue from a throwing getPrototype, and jsNull() still falls through to getObject() → nullptr → outer while (iterating) as before.
  • napi.cpp: after NAPI_RETURN_IF_EXCEPTION the ternary on protoValue is defensive-only; each of the three new guards is exercised by the added tests (the stateful getPrototypeOf case pins the post-getPrototype guard specifically).
  • BunObject.cpp: the removed debug block was calling reportUncaughtExceptionAtEventLoop with the exception still pending; RETURN_IF_EXCEPTION immediately below already propagates it, so release/debug now match.
  • The comment-cop note on bindings.cpp was addressed in fa96682 (comments are one-liners now).
Extended reasoning...

Overview

This PR fixes a segfault and dropped-properties bug in JSC__JSValue__forEachPropertyImpl (the property walk behind Bun.inspect/console.log) and a matching null-deref in napi_get_all_property_names, plus removes a debug-only reportUncaughtExceptionAtEventLoop call in the Bun.sql/Bun.SQL lazy-property builders that aborted debug builds. Production changes are small (~15 lines across three C++ files); the bulk of the diff is test coverage across inspect.test.js, BunObject.test.ts, and the napi test app. The PR consolidates roughly 20 duplicate fuzzer-filed PRs and has been iterated on since April.

Security risks

None identified. The change adds exception checks and null guards; it does not introduce new user-controlled parsing, allocation sizing, or trust boundaries. The swallow-vs-propagate design keeps the formatter's existing best-effort semantics rather than changing how exceptions surface.

Level of scrutiny

High — this is JSC bindings C++ in the exception-handling path, where a missed clear or a .getObject() on an empty JSValue is a crash. The changes themselves are minimal and follow the established pattern in the same function (fast path already clears after getPrototype; forEachPropertyOrdered already clears regardless of the getPropertySlot result), and each guard has a load-bearing test. But it is the canonical fix for a large duplicate cluster, and there is a behavior change worth a maintainer's eye: TDZ module-namespace exports are now silently omitted from console.log output where util.inspect prints <uninitialized> (called out in the PR body as a follow-up formatter feature).

Other factors

  • dylan-conway's April observation ("we should probably not be clearing these exceptions, but looks like it was doing it originally") is still open as a design point; robobun's reply defers propagation to #24985. That trade-off is reasonable but is the kind of call a maintainer should confirm.
  • All three of my prior inline findings were addressed (own-only branch guard, napi test coverage, stateful getPrototypeOf fixture reaching the post-getPrototype guard).
  • The comment-cop bot's note on a long comment in bindings.cpp was addressed in the most recent commit (fa96682); the comments in the diff are one-liners.
  • The EnsureStillAliveScope that was folded in from #28919 was intentionally dropped; the PR body explains why (the raw iterating pointer is live across every JS re-entry in the loop body already), which checks out against the loop structure.

Jarred-Sumner pushed a commit that referenced this pull request Aug 17, 2026
…ls never download from GitHub (#39446)

### Problem
- The "Lint JavaScript" (lint.yml) and "Format" (format.yml) checks go
red on PRs that did not touch anything they check. Their `bun install`
step fails with:
  ```
error: failed to download
bun-tracestrings@github:oven-sh/bun.report#912ca63: HTTP 5xx
  Failed to install 1 package
  ```
Seen on #29642 at 5309742 (a C++ comment change), both attempts of
runs 32040959983 and 32040960113, while other PRs' Lint runs flapped
red/green in the same minutes.
- Cause: `package.json:13` pins `bun-tracestrings` as a `github:`
dependency, so every root `bun install` downloads a tarball from GitHub.
That install runs on a fresh runner (no cache) in lint.yml, format.yml,
rust-lints.yml (4 jobs), bun-types.yml and packages-ci.yml, and in every
build (`scripts/build/codegen.ts` `bun_install`). bun retries a tarball
5 times back to back, which does not cover an outage of a few minutes.
- The only user of the package is `scripts/runner.node.mjs`, which runs
its `ci-remap-server` bin on Buildkite test shards
(`runner.node.mjs:803` on main). Nothing in lint, format, types,
rust-lints or the build uses it. Removing it outright was tried in
#25425 and closed for that reason.

### Fix
- Move the dependency to a new `scripts/ci-remap-server/package.json` (+
`bun.lock`) and drop it from the root `package.json` / `bun.lock` (a
pure removal: 94 lockfile entries, the package and its transitive
closure; the lockfile stays `lockfileVersion: 1`).
- `runner.node.mjs` installs that directory right before starting the
server, through the same `spawnBunInstall` as root and test/ so it uses
the agent's baked install cache, and runs the bin from there. The
install is best-effort like the server start already is: on failure it
warns and the tests run without crash remapping instead of failing the
shard.
- `bootstrap.sh` warms the new directory into the image's install cache
next to root and test/. No image version bump needed: the new lockfile
carries over the exact resolutions the root lockfile had (checked entry
by entry), so the caches baked from the old root lockfile already
contain everything except `@types/bun@1.3.14` and `bun-types@1.3.14`,
which resolved to the workspace packages before and now come from npm.
- New source lint
`test/internal/source-lints/lockfile-registry-only.test.ts`: the root
and test/ lockfiles (the two that every PR's checks and every shard
install) may not contain `github:`, `git+` or tarball-URL resolutions.
`source-lints.yml` now also triggers on `bun.lock` / `test/bun.lock`.
- Why this is the right layer: the failing jobs had no use for the
package, and a retry or cache in lint.yml/format.yml would still leave
GitHub on the install path of rust-lints, bun-types, the build and every
dev's `bun install`, and would still fail during a multi-minute blip.
After this, root installs only talk to the npm registry; the one job
that needs GitHub gets it from a baked cache and degrades gracefully
when it is unavailable.
- Verified:
- `test/internal/source-lints/lockfile-registry-only.test.ts`: fails on
main's `bun.lock` (`bun-tracestrings ->
bun-tracestrings@github:oven-sh/bun.report#912ca63`), passes here, under
`bun bd test`, the system bun and bun 1.3.14 (the version the workflows
pin). Whole `test/internal/source-lints/` passes.
- Root `bun install --frozen-lockfile` (lint.yml's command) with bun
1.3.14 and the debug build: no changes. Fresh checkout with GitHub
unreachable (`GITHUB_API_URL=http://127.0.0.1:1`, empty cache): main
fails with `failed to download bun-tracestrings@github:...
ConnectionRefused`, this branch installs.
- `bun install --frozen-lockfile` in `scripts/ci-remap-server` with bun
1.3.14, canary and the debug build: no changes; `bun run --silent
ci-remap-server` from that directory prints a port and serves `/traces`.
- This PR's own Buildkite build (#100049): every non-Windows shard logs
`scripts/ci-remap-server/package.json` / `86 packages installed` in 0.4
to 1s (the baked cache, as predicted; the root install went from 102
packages to 21), followed by `crash reports parsed on port ...`. The
server came up on 17 of 40 debian+ubuntu x64 shards against 14 of 40 on
main's build #100080: the remaining shards hit the runner's pre-existing
5s startup timeout (`ci-remap server did not start: timeout`), which
this PR does not change and is worth a follow-up of its own.
- Locally, `CI=true node scripts/runner.node.mjs ...` with a cold cache
got a 429 from codeload.github.com for the tarball; the runner warned
`ci-remap server not installed (...), crash reports will not be
remapped`, ran the test and exited 0. Same with the directory removed
(`spawn error`).
- `bun lint`, prettier `--check` on the touched files, `sh -n
scripts/bootstrap.sh`, `bun bd` reconfigure after the root package.json
change.
- Overlaps with #38981 on neighbouring lines of the same block in
`runner.node.mjs` (it changes how the server is killed, this changes
where it is installed and started); either rebases trivially onto the
other.

### Background
- `bun-tracestrings` is the npm name of github.com/oven-sh/bun.report,
the service that turns the trace strings in bun's crash reports back
into stack traces. Its `ci-remap-server` bin is a local copy of that:
`runner.node.mjs` starts it, points every spawned bun at it via
`BUN_CRASH_REPORT_URL`, and prints the remapped traces of any test that
crashed. It is purely diagnostic output; tests still fail on their exit
code without it.
- A `github:` dependency has no registry tarball: bun downloads it from
GitHub on install (and caches it under the same name@resolution key as
registry packages).
- `bootstrap.sh` builds the CI agent images. It clones the repo and runs
`bun install` in root and test/ with `BUN_INSTALL_CACHE_DIR` set, so
test shards install from disk; a package only downloads when its
resolution is not in the baked cache. The `# Version:` header is only
bumped when the image itself must change, which this does not require.
- `test/internal/source-lints/` holds repo-invariant tests that run on a
bare checkout in source-lints.yml (no `bun install`), which is why the
lint lives there and why that workflow's path filter had to learn about
the lockfiles.
Jarred-Sumner pushed a commit that referenced this pull request Aug 18, 2026
… like Node (#39418)

### Problem
- `napi_get_prototype` on a Proxy runs the Proxy's `getPrototypeOf`
trap. Node does not: V8's `Object::GetPrototype`, which Node's
`napi_get_prototype` wraps, cannot run JS and returns `null` for any
Proxy (verified with Node 26.3, output in the details below).
- Running the trap also makes the failure mode wrong: when the trap
throws (or returns a non-object, or the proxy is revoked) the call
returns `napi_ok`, writes a NULL `napi_value` into `*result`, and leaves
the exception pending without telling the addon. Current canary on the
new fixture: `trap throws: status=0 pending=true result=null handle
exception=the trap's error`.
- This is an unreleased regression from #33731 (86caf6e):
`napi_get_prototype` used to call the JSC C API's
`JSObjectGetPrototype`, which is `getPrototypeDirect()`, and JSC creates
Proxy structures with a null prototype, so 1.3.14 already returned
`null` for a Proxy without running anything. #33731 replaced it with
`JSValue::get_prototype`, JSC's full `[[GetPrototypeOf]]` (traps
included), which returned a bare `JSValue` that is empty (encoded 0,
i.e. a NULL handle) when it threw, so the call site in
`src/runtime/napi/napi_body.rs` could not tell success from failure.
Affects 1.4.0 canary only; the `napi_get_prototype` line in the #36801
audit ("already avoids the trap") describes the pre-#33731 state.
- The other `getPrototype` exception-handling sites found by the same
reading (the inspect property walk, `napi_get_all_property_names`,
`util.isError`) are separate fixes in #29642, #32263 and #37202; this PR
merges cleanly in either order with them.

### Fix
- `napi_get_prototype` returns `null` for a `ProxyObject` without
touching its handler, the same result Node produces and the same result
1.3.14 produced. Every other object type's `[[GetPrototypeOf]]` in JSC
is a plain read (`JSGlobalProxy` forwards to the global object,
`ImportMetaObject` returns null, primitives get their wrapper
prototype), so the function no longer runs JS for any input, like
Node's.
- `JSValue::get_prototype` (`src/jsc/JSValue.rs`) now returns
`JsResult<JSValue>` through `host_fn::from_js_host_call`, the wrapper
`JSValue::call` and `unwrap_boxed_primitive` already use for JSC calls
that return empty exactly when they threw. This is what let the empty
value reach `*result`, and the remaining callers (`ConsoleObject.rs`,
`pretty_format.rs`, `ScopeFunctions.rs`, all already returning
`JsResult`) now propagate with `?` instead of holding a possibly-empty
value. They only ever see ordinary objects (the formatters print a
Proxy's target), so their output is unchanged. In `napi_get_prototype`
the `Err` arm maps to `napi_pending_exception` as the generic Node-API
protocol; with the Proxy case handled above it is not reachable with
today's object types.
- Test: `test/napi/napi.test.ts` (`napi_get_prototype`) runs fixture
`test_napi_get_prototype_proxy` (`napi-app/module.js`) through
`checkSameOutput`, so the output is compared byte for byte with Node,
and then pins the lines. The helper `perform_get_prototype`
(`napi-app/js_test_helpers.cpp`) pre-fills `*result` with a sentinel so
the output distinguishes a real `null` from "not written" and from a
NULL handle. Cases: proxy without traps, callable proxy, a trap that
counts its calls (must stay 0), a trap that throws, a trap returning a
number, a revoked proxy, plus plain / null-prototype objects and an
object whose prototype is a proxy (returned as-is, only the object
itself is special-cased).
- Fails under `USE_SYSTEM_BUN=1` (1.4.0-canary.1: 7 of 11 lines differ
from Node, see details), passes with `bun bd test
test/napi/napi.test.ts`.
- Also run on the debug build: the rest of `test/napi/napi.test.ts`,
Node's own `test_general` suite
(`test/napi/node-napi-tests/.../test_general/do.test.ts`, which asserts
`napi_get_prototype` matches `Object.getPrototypeOf` for ordinary
objects), `test/js/bun/util/inspect.test.js`,
`test/js/bun/test/printing/diffexample.test.ts`, `jest-each.test.ts`,
`describe.test.ts`, `pretty-format-overflow.test.ts`,
`console-table.test.ts`; plus a script formatting classes, functions,
null-prototype objects and proxies, and a `bun test` file exercising
`describe.each` / `test.each` / `skipIf` and class-instance `toEqual`
diffs, both clean under `BUN_JSC_validateExceptionChecks=1`.
- Noted while reviewing the other Proxy-receiver paths, not changed here
(different function and file): `napi_remove_wrap` on a Proxy or on
`globalThis` reports success but leaves the wrap attached, because
`src/jsc/bindings/napi.cpp` removes it with the virtual `deleteProperty`
(which `ProxyObject` refuses for private names and `JSGlobalProxy`
forwards to its target) while `napi_wrap` / `napi_unwrap` use
`putDirect` / `getDirect`; Node removes it. Probe output is in the
second details block.

### Background
- `[[GetPrototypeOf]]` is the spec operation behind
`Object.getPrototypeOf`. For ordinary objects it reads a field and
cannot fail; a Proxy implements it by calling its handler's
`getPrototypeOf` trap, so it can run arbitrary JS and throw. V8's
`Object::GetPrototype` returns a plain `Local<Value>` rather than a
`MaybeLocal`, so it cannot run a trap, and it reports `null` for a
Proxy; that is the behavior Node-API addons are written against.
- `getPrototypeDirect()` is JSC's raw read of the prototype stored in an
object's Structure, bypassing any override; `getPrototype()` is the full
operation that dispatches to `ProxyObject`'s trap. The JSC C API's
`JSObjectGetPrototype` is the former.
- JSC signals a throw from a value-returning operation by returning the
empty `JSValue` (encoding 0) and leaving the exception on the VM.
`host_fn::from_js_host_call` maps that to `Err(JsError::Thrown)` and, in
debug builds, asserts the value is empty if and only if an exception is
pending.
- A `napi_value` is an encoded `JSValue`, so storing the empty value
hands the addon a NULL handle.
- `napi_pending_exception` is the status a Node-API call returns when JS
it ran threw; the exception stays pending for
`napi_get_and_clear_last_exception` and out-params are left unwritten.

<details>
<summary>Fixture output: Node 26.3 (and this branch, identical) vs
current canary</summary>

Node v26.3.0, and this branch:

```
plain object: status=0 pending=false result=Object.prototype exception=none
null prototype: status=0 pending=false result=null exception=none
proxy without traps: status=0 pending=false result=null exception=none
callable proxy: status=0 pending=false result=null exception=none
trap returns Array.prototype: status=0 pending=false result=null exception=none
getPrototypeOf trap calls: 0
trap throws: status=0 pending=false result=null exception=none
trap returns a number: status=0 pending=false result=null exception=none
revoked proxy: status=0 pending=false result=null exception=none
object whose prototype is a proxy: status=0 pending=false result=the proxy exception=none
plain object again: status=0 pending=false result=Object.prototype exception=none
```

Bun 1.4.0-canary.1 (`USE_SYSTEM_BUN=1`):

```
plain object: status=0 pending=false result=Object.prototype exception=none
null prototype: status=0 pending=false result=null exception=none
proxy without traps: status=0 pending=false result=Array.prototype exception=none
callable proxy: status=0 pending=false result=Function.prototype exception=none
trap returns Array.prototype: status=0 pending=false result=Array.prototype exception=none
getPrototypeOf trap calls: 1
trap throws: status=0 pending=true result=null handle exception=the trap's error
trap returns a number: status=0 pending=true result=null handle exception=TypeError
revoked proxy: status=0 pending=true result=null handle exception=TypeError
object whose prototype is a proxy: status=0 pending=false result=the proxy exception=none
plain object again: status=0 pending=false result=Object.prototype exception=none
```

</details>

<details>
<summary>napi_remove_wrap follow-up: probe output (existing try_wrap /
try_remove_wrap / try_unwrap helpers from napi-app, wrapping the number
6, then trying to re-wrap with 7)</summary>

Node v26.3.0:

```
plain: wrap=true remove=6 unwrap_after=undefined rewrap=true
proxy: wrap=true remove=6 unwrap_after=undefined rewrap=true
callable proxy: wrap=true remove=6 unwrap_after=undefined rewrap=true
globalThis: wrap=true remove=6 unwrap_after=undefined rewrap=true
```

Bun (this branch, unchanged in this respect):

```
plain: wrap=true remove=6 unwrap_after=undefined rewrap=true
proxy: wrap=true remove=6 unwrap_after=6 rewrap=false
callable proxy: wrap=true remove=6 unwrap_after=6 rewrap=false
globalThis: wrap=true remove=6 unwrap_after=6 rewrap=false
```

</details>

<details>
<summary>Earlier version of this PR</summary>

The first revision kept running the trap and changed only the failure
report: `napi_pending_exception` with `*result` untouched when the trap
threw, tested on Bun alone since Node never reaches that path. Review
asked to match Node instead and not run JS, which is the current shape;
the `JsResult` change to `JSValue::get_prototype` is unchanged from that
revision.

</details>

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

---

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

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

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Note on overlap: #39436 (the process builders propagating their failure instead of storing undefined) carries the same forEachPropertyImpl hunk as this PR, byte for byte, because its own tests reach that code; this PR stays the owner of that bug (and of the BunObject.cpp report removal, which #39436 does not touch). Whichever of the two lands second just sees the hunk already applied.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

One more data point for this PR: the fuzzer has now also reached the same forEachPropertyImpl stale exception through new DecompressionStream(globalThis) right after a stack overflow. parseCompressionFormat builds the ERR_INVALID_ARG_VALUE message by single-line inspecting the argument, the walk over globalThis reaches Bun, the Bun.$ builder throws, and the next lazy property aborts in to_js_host_call on a debug build (the fuzzer's build reported it as the getObject() null member call). Applying this branch on top of current main makes that script run normally, so nothing extra is needed here; noting it so the count of independent routes into this bug is visible (expect messages, Bun.inspect, and now error code messages).

@dylan-conway
dylan-conway merged commit 13c0f04 into main Aug 18, 2026
6 checks passed
@dylan-conway
dylan-conway deleted the farm/43e87898/inspect-proxy-proto-throw branch August 18, 2026 04:31
robobun added a commit that referenced this pull request Aug 18, 2026
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.

3 participants