Fix crash and dropped properties in Bun.inspect when a property lookup or getPrototypeOf throws during the property walk - #29642
Conversation
|
Updated 9:39 AM PT - Aug 17th, 2026
✅ @robobun, your commit 3f713ff4e80ec670527b80d479501df2920d73a1 passed in 🧪 To try this PR locally: bunx bun-pr 29642That installs a local version of the PR into your bun-29642 --bun |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughPrototype-chain traversal and property enumeration were hardened: prototypes are tracked as JSValue and preserved during enumeration, prototype hops are limited, Proxy Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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
getPrototypeOftrap). - Bug hunting system found no issues.
- No design decisions or behavior changes — purely converts a crash into the existing "ignore exceptions during inspect" behavior.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
CI failures are unrelated pre-existing flakes, not caused by this change:
This PR only touches |
|
@alii — compared all 9. They all touch the same ~5 lines in
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 #24985 is the most principled if you want the broader |
|
@robobun close all the others, bring the two changes into this PR |
9aa1181 to
be84d29
Compare
| iterating = iterating->getPrototype(globalObject).getObject(); | ||
| iteratingValue = iterating->getPrototype(globalObject); | ||
| // Ignore exceptions from Proxy "getPrototypeOf" trap. | ||
| CLEAR_IF_EXCEPTION(scope); |
There was a problem hiding this comment.
we should probably not be clearing these exceptions, but looks like it was doing it originally
There was a problem hiding this comment.
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.
8da7cbc to
93d9a9c
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/bun.js/bindings/bindings.cppsrc/bun.js/bindings/napi.cpp
6fead81 to
a463275
Compare
| // Ignore exceptions from "Get" proxy traps and throwing lazy property | ||
| // initializers; both report the property as not found. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
|
Follow-up after a self-review of the consolidated diff:
Fail-before / pass-after re-run for both test files on an unfixed debug build, the shipped release build, and this branch. |
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
5309742 to
fa96682
Compare
There was a problem hiding this comment.
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.cppslow-path: theCLEAR_IF_EXCEPTIONreorder matches the fast path at 5580/5611 andforEachPropertyOrdered;!prototypecatches only the empty JSValue from a throwinggetPrototype, andjsNull()still falls through togetObject()→ nullptr → outerwhile (iterating)as before.napi.cpp: afterNAPI_RETURN_IF_EXCEPTIONthe ternary onprotoValueis defensive-only; each of the three new guards is exercised by the added tests (the statefulgetPrototypeOfcase pins the post-getPrototypeguard specifically).BunObject.cpp: the removed debug block was callingreportUncaughtExceptionAtEventLoopwith the exception still pending;RETURN_IF_EXCEPTIONimmediately below already propagates it, so release/debug now match.- The comment-cop note on
bindings.cppwas 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
getPrototypeOffixture reaching the post-getPrototypeguard). - The comment-cop bot's note on a long comment in
bindings.cppwas addressed in the most recent commit (fa96682); the comments in the diff are one-liners. - The
EnsureStillAliveScopethat was folded in from #28919 was intentionally dropped; the PR body explains why (the rawiteratingpointer is live across every JS re-entry in the loop body already), which checks out against the loop structure.
…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.
… 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 -->
|
Note on overlap: #39436 (the |
|
One more data point for this PR: the fuzzer has now also reached the same |
Problem
Bun.inspect(),console.log()andexpect()failure output crash withSegmentation fault at address 0x5(debug builds: UBSan "member call on null pointer of type 'JSC::JSCell'" inJSCJSValueCell.h) when a property lookup throws while an object is being formatted. Release repro:gettrap (or a getter reached through a Proxy) throws for one property.Bunobject whose initializer throws (fuzzer sample:globalThis.Symbolreplaced, thenBun.inspect(Bun); the builtin behindBun.$callsSymbol("cwd")). Release builds printBunwith most of its properties missing (72 of 115 on the shipped build); debug builds abort withASSERTION FAILED: Unexpected exception observed/Symbol is not a function. (In 'Symbol("cwd")' ...)when the next lazy property is built.console.logof a module namespace during an import cycle, when one export is still in its temporal dead zone, throwsReferenceError: Cannot access 'x' before initializationout ofconsole.log(the stale exception is picked up while the next export is formatted).util.inspectprints such an export as<uninitialized>.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 loopcontinued before theCLEAR_IF_EXCEPTIONbelow it. The following lookups and formatting callbacks then run with that exception pending (the dropped properties, the rethrownReferenceError, the debug assertion).iterating->getPrototype(globalObject).getObject()runsgetObject()on the emptyJSValuethatgetPrototypereturns when it threw (either because of the stale exception above or because thegetPrototypeOftrap itself throws). The empty value passesisCell(), 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 samegetPrototype().getObject()chain after an uncheckedgetOwnPropertyDescriptor, so a Proxy trap throwing there returnednapi_okwith 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 toreportUncaughtExceptionAtEventLoopwhile the exception was still pending on the VM, soglobalThis.Symbol = NaN; Bun.sql(and the fuzzer sample above, once the walk gets pastBun.$) aborted debug builds withASSERTION FAILED: ... object->structure() == thisinStructure::storedPrototypeinstead of throwing.Fix
bindings.cpp: clear the exception aftergetPropertySlotregardless of its result, which is what the ordered variantJSC__JSValue__forEachPropertyOrderedalready does; read the next prototype into aJSValue, clear the exception and stop the walk when it is empty. (An earlier revision also held the prototype being walked under anEnsureStillAliveScope; dropped, since the raw pointer is used after every call into JS in the loop body and so is live across them anyway.)console.log/Bun.inspectcall throwing or crashing. For TDZ namespace exports this differs fromutil.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 aftergetOwnPropertyDescriptorand aftergetPrototypeand returnnapi_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 theRETURN_IF_EXCEPTIONright below it, so debug builds now behave like release builds (Bun.sqlthrows).test/js/bun/util/inspect.test.js, "Bun.inspect when a property lookup throws" (5 spawned cases): a Proxygettrap, a getter behind a Proxy, agetPrototypeOftrap, a throwing lazyBunproperty, 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, theBunchild prints[false,false,...]on release and aborts on debug, the cycle child exits 1 with theReferenceError; 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/postgreswithSymbolbroken throw aTypeErroron two consecutive reads. Aborts on an unfixed debug build (theBunObject.cpphunk); passes on release either way, as the removed block is debug-only. The fixture buildsprocess.envbefore breakingSymbolbecause the$builder reads it, and building it on Windows reifies anotherBunproperty mid-lookup, which on a Windows debug build would hit the separatestoredPrototypeassertion 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:getOwnPropertyDescriptortrap throwing in own-only and include-prototypes mode (compared against Node), and agetPrototypeOftrap that throws on the second call so the check aftergetPrototypeis the one that fires.BUN_JSC_validateExceptionChecks=1.Background
forEachPropertyImplis 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 withgetPropertySlot, 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 makeconsole.logthrow.false, or the emptyJSValue) 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_EXCEPTIONdrops the pending exception.JSValue(JSValue()) is encoded as 0.isCell()is true for it, sogetObject()on it dereferences a null cell pointer rather than returning null; callers have to test the value itself first.Bunobject 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 forBun.$, the sql module forBun.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). TheBunObject.cpphunk and theBunObject.test.tstest come from #30245 / #37428; the same hunk is also part of #37001, which fixes the underlyingstoredPrototypeassertion in JSC.Related fixes that are not part of this bug and stay open on their own: #39382 (a custom inspect function when
node:utilfails to load), #37202 (util.isErrorwith a throwinggetPrototypeOftrap), #37331 (forEachPropertyOrderedwhen the callback throws), #37001 (stale structure inJSObject::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