fix(inspect): don't keep enumerating properties with an exception pending - #37330
fix(inspect): don't keep enumerating properties with an exception pending#37330robobun wants to merge 1 commit into
Conversation
…perty JSC__JSValue__forEachPropertyImpl left an exception pending when getPropertySlot() returned false because a Proxy trap or a lazy property initializer threw, then kept looking up the remaining properties with it still pending. The getPrototype() call at the end of the loop also dereferenced the empty value returned when a Proxy getPrototypeOf trap throws (or when an exception is already pending), which segfaults in release builds. Clear the exception before checking the getPropertySlot() result and stop walking the prototype chain when getPrototype() throws. Also drop the debug-only reportUncaughtExceptionAtEventLoop() calls in the Bun.sql / Bun.SQL initializers: they ran while the exception was still pending, which trips a structure assertion in debug builds as soon as the uncaught exception handler touches a static property, and the exception is propagated to the caller right after anyway.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
💤 Files with no reviewable changes (1)
WalkthroughThe changes remove debug-only SQL exception reporting and make property inspection clear exceptions from failing property and prototype lookups. A regression test covers proxy traps, lazy properties, child-process completion, output, and stderr. ChangesException safety
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Closing as a duplicate. #37202 already contains the same two One data point from this investigation for whoever consolidates these: the const proto = new Proxy({}, { getPrototypeOf() { throw new Error("nope"); } });
console.log(Object.create(proto));A prototype Proxy whose |
There was a problem hiding this comment.
LGTM. The inline nit about napi.cpp:2072 is pre-existing N-API code and non-blocking — fine to land as-is or fold in as a follow-up.
Extended reasoning...
Overview
This PR fixes two defects in JSC__JSValue__forEachPropertyImpl (bindings.cpp), the slow-path property enumerator behind Bun.inspect / console.log: (1) CLEAR_IF_EXCEPTION now runs before the continue when getPropertySlot returns false, so a throwing Proxy get trap or lazy-property initializer no longer leaves an exception pending into the next iteration; (2) getPrototype() is captured into a local, the exception cleared, and an empty result breaks the walk instead of segfaulting on .getObject(). It also removes two #if BUN_DEBUG blocks in the Bun.sql/Bun.SQL initializers that called reportUncaughtExceptionAtEventLoop with the exception still pending — the immediately-following RETURN_IF_EXCEPTION already handles it, so these only made debug builds diverge (and assert). A subprocess test in inspect.test.js covers all three scenarios.
Security risks
None. This is the inspect/formatting path; no auth, crypto, or trust-boundary parsing is touched. The change adds exception clearing and an empty-value guard where they were missing.
Level of scrutiny
Low-to-medium. The two bindings.cpp hunks are ~10 net lines and mirror patterns already present in sibling code: the fast path at lines 5554–5564 already guards getPrototype() with if (JSValue proto = ...) and clears the exception, and forEachPropertyOrdered (line 5768) already does bool hasProperty = ...; clear; if (!hasProperty) continue;. The BunObject.cpp change is a debug-only deletion with no release-build effect. The changes are strictly defensive — they turn a crash/assert into the same "skip and keep going" behavior the surrounding code already uses for getter exceptions.
Other factors
The test follows harness conventions (subprocess isolation so a regression fails the test rather than killing the runner, Promise.all draining of stdout/stderr/exited, single combined {stdout, stderr, exitCode} assertion with exact expected output). The PR description documents USE_SYSTEM_BUN=1 failing (segfault 139) and the debug build passing. The one inline nit — the same getPrototype(globalObject).getObject() shape at napi.cpp:2072 — is pre-existing, only reachable through a native addon calling napi_get_all_property_names on an adversarial Proxy chain, and does not block this fix.
| JSValue prototype = iterating->getPrototype(globalObject); | ||
| // Ignore exceptions from Proxy "getPrototypeOf" traps. | ||
| CLEAR_IF_EXCEPTION(scope); | ||
| if (!prototype) [[unlikely]] | ||
| break; | ||
| iterating = prototype.getObject(); |
There was a problem hiding this comment.
🟡 nit: The same ->getPrototype(globalObject).getObject() pattern remains at src/jsc/bindings/napi.cpp:2072 (in napi_get_all_property_names's filter loop, with no exception check after getOwnPropertyDescriptor) — a Proxy prototype whose trap throws produces the same segfault-at-0x5. It's the sole greppable sibling of this pattern in src/; pre-existing N-API code so non-blocking, but per REVIEW.md's "grep for every sibling site" it may be worth including the one-line fix here or noting it's intentionally out of scope.
Extended reasoning...
What this is
This PR fixes iterating->getPrototype(globalObject).getObject() in JSC__JSValue__forEachPropertyImpl (bindings.cpp:5675-5680) — when ProxyObject::getPrototype throws, it returns an empty JSValue, and calling .getObject() on an empty JSValue dereferences a null cell pointer and segfaults at address 0x5. A grep for this exact pattern across src/ finds one remaining instance at src/jsc/bindings/napi.cpp:2072, inside napi_get_all_property_names:
while (!owner->getOwnPropertyDescriptor(globalObject, propKey, desc)) {
JSObject* proto = owner->getPrototype(globalObject).getObject();
if (!proto) break;
owner = proto;
}Code path that triggers it
Reachable via napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable | ..., ...) on an object with a Proxy in its prototype chain. The filter loop at napi.cpp:2061-2080 runs when key_filter includes any of napi_key_enumerable/writable/configurable and key_mode == napi_key_include_prototypes. Inside that loop, getOwnPropertyDescriptor (line 2071) invokes the Proxy's getOwnPropertyDescriptor trap; if the trap throws, there is no exception check before line 2072, so owner->getPrototype(globalObject) on the same Proxy hits RETURN_IF_EXCEPTION inside ProxyObject::performGetPrototype and returns {}. A stateful getPrototypeOf trap that succeeds during getPropertyNames (line 1999) but throws when re-called here produces the same empty return.
Why the existing code doesn't prevent it
The if (!proto) break; on line 2073 checks the result of .getObject(), but .getObject() itself is what crashes: on JSVALUE64 an empty JSValue encodes as 0, which satisfies isCell(), so asCell() returns nullptr and JSCell::getObject() reads m_type at offset ~5 → SIGSEGV at 0x5. This is the identical mechanism the PR description documents for the release-build segfault in forEachPropertyImpl.
Step-by-step proof
- Native addon calls
napi_get_all_property_names(env, obj, napi_key_include_prototypes, napi_key_enumerable, napi_key_numbers_to_strings, &result)whereobj's prototype isnew Proxy({}, { ownKeys: () => ['x'], getOwnPropertyDescriptor() { throw new Error('boom'); } }). getPropertyNamesat line 1999 collects'x'via theownKeystrap (usesjsc_key_mode = Include, so it does not callgetOwnPropertyDescriptor);NAPI_RETURN_IF_EXCEPTIONat 2057 passes.- The filter loop reaches
propKey = 'x';owner = object;object->getOwnPropertyDescriptor(...)returns false (not own), so thewhilebody runs andownerbecomes the Proxy. - Next iteration:
owner->getOwnPropertyDescriptor(globalObject, 'x', desc)invokes the Proxy trap, which throws. Return value is false → loop body runs again with the exception still pending. owner->getPrototype(globalObject)on the Proxy entersProxyObject::performGetPrototype, hitsRETURN_IF_EXCEPTION, returnsJSValue().JSValue().getObject()→asCell()=nullptr→nullptr->isObject()reads offset 5 → segfault.
Impact and fix
This is pre-existing N-API code the PR does not touch, only reachable through a native addon calling napi_get_all_property_names on an object with an adversarial Proxy in its chain, so it should not block this PR — merging is strictly an improvement over main. Flagging per REVIEW.md's "Fix the whole class in the same PR — grep for every sibling site sharing the pattern... If a site is intentionally excluded, say so in the PR" since this is the sole greppable sibling of the exact pattern being fixed. The fix is the same shape as this PR's hunk: capture getPrototype(globalObject) into a JSValue, check/clear the exception, break if empty, then call .getObject() — plus an exception check after getOwnPropertyDescriptor.
What does this PR do?
Fixes a fuzzer-found debug assertion in
Bun.inspect/console.log(Unexpected exception observed ... releaseAssertNoException) and, while tracking it down, a release-build segfault in the same loop.JSC__JSValue__forEachPropertyImpl(src/jsc/bindings/bindings.cpp, the slow path used for objects with static tables, Proxies in the prototype chain, etc.) had two problems in its per-property loop:When
getPropertySlot()returnedfalsebecause the lookup threw, it didcontinuebefore theCLEAR_IF_EXCEPTION, so the exception stayed pending while the next property was looked up. Lookups can throw through a Proxygettrap or through a lazily initialized static property whose initializer throws (the fuzzer's case: it had clobberedglobalThis.Symbol, so theBun.$initializer threw, and the next property,Bun.Archive, is a Rust lazy property callback whose host-call exception check aborts when it is entered with an exception pending). The fix is to move theCLEAR_IF_EXCEPTIONabove thecontinue, which is whatforEachPropertyOrderedalready does.At the bottom of the loop,
iterating->getPrototype(globalObject).getObject()dereferenced the emptyJSValuethatProxyObject::getPrototypereturns when it throws (either its owngetPrototypeOftrap, or because of the stale exception from 1). In release builds this isSegmentation fault at address 0x5:Same with a
getPrototypeOftrap that throws. The loop now clears the exception and stops walking the chain whengetPrototype()comes back empty.The
BunObject.cpphunk removes two#if BUN_DEBUGblocks in theBun.sql/Bun.SQLinitializers that calledreportUncaughtExceptionAtEventLoop()while the exception was still pending on the VM (every other caller clears first). Once the walk above no longer aborts,Bun.inspect(Bun)with a broken global reaches these and tripsStructure::storedPrototype(object->structure() == this) inside the uncaught exception handler, as does plainBun.sqlwhen the module fails to load; release builds just throw to the caller, which theRETURN_IF_EXCEPTIONon the next line already does, so the blocks only made debug builds diverge.JSC__JSValue__forEachPropertyOrdered({ sorted: true }) has a related but different issue (it does not propagate exceptions thrown by the callback, see the TODO there); that is tracked separately and not changed here.How did you verify your code works?
Added
Bun.inspect ignores exceptions thrown while enumerating propertiestotest/js/bun/util/inspect.test.js. It runs the three scenarios in a child process: Proxy prototype with a throwinggettrap, Proxy prototype with a throwinggetPrototypeOftrap, andBun.inspect(Bun)withglobalThis.Symbolremoved. On an unfixed build the first two segfault in release (exitCode: 139, verified withUSE_SYSTEM_BUN=1) and fail under the debug/sanitizer build (null member call inJSValue::getObject), and the third aborts with the assertion from the fuzzer report on the unfixed debug build (and with thestoredPrototypeassertion once only thebindings.cppchange is applied); with this PR the child prints the expected output and exits 0.bun bd test test/js/bun/util/inspect.test.jspasses; the fuzzer's script (with the global state it depended on) runs to completion on the debug build.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/inspect.test.js