Skip to content

Handle exception from getPrototype in forEachProperty - #28918

Closed
robobun wants to merge 1 commit into
mainfrom
farm/958e42a7/fix-foreach-property-proxy-proto-crash
Closed

Handle exception from getPrototype in forEachProperty#28918
robobun wants to merge 1 commit into
mainfrom
farm/958e42a7/fix-foreach-property-proxy-proto-crash

Conversation

@robobun

@robobun robobun commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator

Fuzzilli found a null deref in JSC__JSValue__forEachPropertyImpl when formatting an object whose prototype chain contains a Proxy.

When walking the prototype chain, iterating->getPrototype(globalObject) can throw (e.g. from a Proxy getPrototypeOf trap or from side-effects of the walk), and returns an empty JSValue. Calling .getObject() on that empty value invokes asCell() which returns null, then dereferences it — UBSan catches the null member call.

The fast path at line 5121 already handles this correctly with if (JSValue proto = ...) which is false for empty values, followed by CLEAR_IF_EXCEPTION. The slow path at line 5241 did not — check the exception and bail out of the walk.

Fuzzilli repro (minimized):

const v1 = Bun.jest(Bun);
const v2 = v1.expect(v1);
Object.setPrototypeOf(v2, new Proxy(Object.getPrototypeOf(v2), {}));
v2.toBe(v2); // triggers toBe failure → formats v2 → walks prototype chain

Fingerprint: 9e09c9e7bd37325a

@github-actions github-actions Bot added the claude label Apr 6, 2026
@robobun

robobun commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:30 PM PT - Apr 6th, 2026

@robobun, your commit dd063fa6ad648e42a4a72b14e8969616fbc19299 has 2 failures in Build #44061 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28918

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

bun-28918 --bun

@coderabbitai

coderabbitai Bot commented Apr 6, 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

Suppress exceptions thrown by Proxy getPrototype during prototype-chain traversal in property enumeration by checking and clearing pending exceptions; add a regression test that spawns a Bun child, wraps an Expect instance’s prototype with a Proxy, invokes a matcher inside try/catch, and verifies the child process exits cleanly with OK\n.

Changes

Cohort / File(s) Summary
Prototype-chain traversal fix
src/bun.js/bindings/bindings.cpp
In JSC__JSValue__forEachPropertyImpl, clear exceptions after getPrototype on the fast path and on the slow path retrieve prototype into a JSValue proto, check scope.exception() and call scope.tryClearException() and break if set, preventing exception propagation during traversal.
Regression test
test/js/bun/test/expect-proxy-prototype-crash.test.ts
Add a test that spawns a Bun child running an inline script which proxies an Expect instance’s prototype, calls a matcher inside try/catch, prints "OK", and the parent asserts the child prints OK\n, emits no stderr, and exits with code 0.
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description provides detailed context about the bug, root cause analysis, code paths affected, and includes a minimized reproduction case. It does not follow the template structure but contains comprehensive technical information. Consider restructuring the description to explicitly match the template sections: 'What does this PR do?' and 'How did you verify your code works?' for consistency.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and concisely describes the main change: handling exceptions from getPrototype in the forEachProperty function.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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 — targeted null-deref fix with a matching regression test.

Extended reasoning...

Overview

Two files changed: a 5-line fix in bindings.cpp and a new regression test in expect-proxy-prototype-crash.test.ts. The fix is isolated to the slow path of JSC__JSValue__forEachPropertyImpl.

Security risks

None. This is a crash fix that prevents a null dereference; it does not touch auth, crypto, or permission code.

Level of scrutiny

Low — the change is mechanical and directly mirrors the fast path pattern already present a few hundred lines above. Capturing the return value of getPrototype, checking scope.exception(), clearing it, and breaking is the standard JSC idiom for handling exceptions during prototype-chain walks.

Other factors

The Fuzzilli repro is clearly described, the test exercises the exact crash scenario in a subprocess (so a crash would be caught as a non-zero exit code), and no bugs were reported by the hunting system. The fast-path precedent gives high confidence the fix is correct.

@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch from 112c673 to 8daf971 Compare April 6, 2026 17:15

@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 fix matching the existing fast-path pattern, with a regression test.

Extended reasoning...

Overview

Fixes a null-dereference crash in the slow path of JSC__JSValue__forEachPropertyImpl (bindings.cpp). The change is 5 lines: store the result of getPrototype, check for a pending exception, clear it, and break out of the loop. A regression test is added in the test suite.

Security risks

None. This is purely defensive error handling — it prevents a crash by bailing out of prototype-chain traversal when an exception occurs, rather than continuing with a null pointer.

Level of scrutiny

Low. The fix is mechanical and mirrors the existing fast-path logic (described in the PR) that already handles this case correctly. The pattern (scope.exception()tryClearException() → break) is idiomatic in this codebase. The change cannot introduce regressions because the only behavioral difference is breaking out of the prototype walk early on exception, rather than crashing.

Other factors

  • No bugs found by the automated hunting system.
  • coderabbitai raised no actionable concerns.
  • Regression test directly reproduces the Fuzzilli crash scenario and verifies exit code 0 and correct stdout.

@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch 2 times, most recently from bd411d0 to 8daf971 Compare April 6, 2026 17:55

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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


Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/bun.js/bindings/bindings.cpp:5364-5375 — The PR fixes the slow-path loop's exception handling, but misses an analogous unhandled exception in the fast-path initialization: if object->getPrototype(globalObject) at line 5170 throws (e.g. from a Proxy getPrototypeOf trap), the exception is not cleared, fast has already been set to false, so the entire fast-path block (including CLEAR_IF_EXCEPTION at line 5257) is skipped, and execution falls into the slow path with the exception already pending. The first RETURN_IF_EXCEPTION in the slow path (line 5276) then fires and returns early without clearing the exception, leaking it to callers. A scope.tryClearException() call is needed after the initialization getPrototype call, matching the pattern used in the inner fast-path loop (line 5257) and the fix applied in this PR.

    Extended reasoning...

    What the bug is: At line 5170 in the fast-path initialization block, object->getPrototype(globalObject) is called inside if (JSValue proto = ...). This pattern safely avoids dereferencing a null pointer if the call returns an empty JSValue — but if the call throws an exception (setting it in the scope), the exception is left pending after the if-statement exits. There is no CLEAR_IF_EXCEPTION or equivalent after this call.\n\nThe specific code path: The fast-path initialization block checks if (fast) and then if (structure->outOfLineSize() == 0 && structure->inlineSize() == 0). When this is true, fast = false is set first (line 5168), then getPrototype is called. If getPrototype throws, proto is an empty JSValue (falsy), the if-body at lines 5171–5175 is skipped. Since fast is already false, the entire fast-path enumeration block (lines ~5184–5261, which includes the CLEAR_IF_EXCEPTION(scope) at line 5257) is skipped. Execution falls through to the slow path at line 5263 with the exception still pending.\n\nWhy existing code doesn't prevent it: The PR description claims 'The fast path at line 5121 already handles this correctly with CLEAR_IF_EXCEPTION.' However, that CLEAR_IF_EXCEPTION at line 5257 is inside the inner fast-path loop (inside if (anyHits) in the fast-path enumeration block), not after the initialization getPrototype call at line 5170. When fast is set to false before the call, that entire block is bypassed entirely.\n\nWhat the impact would be: With the pending exception, the slow path enters the while loop at line 5269. The getOwnPropertyNames or getOwnNonIndexPropertyNames call executes (or may itself behave unexpectedly with a pending exception), then RETURN_IF_EXCEPTION(scope, void()) at line 5276 fires immediately. This returns early without ever reaching the cleanup block at lines 5378–5381 that calls scope.tryClearException(). The exception then propagates out of forEachPropertyImpl to its Zig caller unexpectedly.\n\nHow to fix it: Add scope.tryClearException() (or CLEAR_IF_EXCEPTION(scope)) after the fast-path initialization getPrototype call at line 5170, matching the pattern already used at line 5257 with the comment '// Ignore exceptions from Proxy "getPrototype" trap.' and the fix this PR applied in the slow-path loop.\n\nStep-by-step proof: (1) Create an object obj with no inline or out-of-line properties (empty object {} works). (2) Set its prototype to a Proxy whose getPrototypeOf trap throws. (3) Call forEachPropertyImpl on obj. (4) structure->outOfLineSize() == 0 && structure->inlineSize() == 0 is true → fast = false (line 5168). (5) object->getPrototype(globalObject) calls the Proxy trap → throws → returns empty JSValue. (6) The if-body (5171–5175) is skipped. (7) The fast-path block (5184–5261) is skipped because fast == false. (8) We reach the slow path (line 5263) with exception pending. (9) RETURN_IF_EXCEPTION at line 5276 fires → function returns early. (10) Exception propagates to caller — the caller receives an unexpected exception.

Comment on lines +23 to +26
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toBe("OK\n");
expect(exitCode).toBe(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 The test pipes stderr and awaits it in Promise.all but discards the result with , , destructuring and never asserts expect(stderr).toBe(''). Per the project's test/CLAUDE.md conventions, tests spawning Bun processes should assert expect(stderr).toBe('') before asserting on stdout and exit code, so unexpected crash output, warnings, or diagnostic messages printed to stderr won't silently pass.

Extended reasoning...

Convention violation: stderr is collected but never asserted

The test correctly pipes stderr (stderr: 'pipe') and collects it in Promise.all, which is good — it prevents the stderr stream from being orphaned. However, the destructuring on line 23 uses , , to discard the value: const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]). The collected stderr string is then never referenced again, and no assertion like expect(stderr).toBe('') appears before the stdout/exitCode checks.

The project's documented convention

test/CLAUDE.md provides an explicit canonical example for spawning Bun child processes in tests. That example destructures [stdout, stderr, exitCode] and asserts expect(stderr).toBe('') before asserting on stdout and exit code. This ordering ensures that any unexpected output on stderr (crash diagnostics, warnings, panics, sanitizer messages) causes an immediate, descriptive test failure rather than a silent pass.

Why it matters for a crash regression test specifically

This is a regression test for a null-deref / UBSan crash. While a hard crash will produce a non-zero exit code (caught by the existing expect(exitCode).toBe(0)), there are several scenarios where stderr output would slip through without a failure:

  • Incomplete fixes that print a diagnostic/warning to stderr but still exit 0 with correct stdout
  • Sanitizer-instrumented builds that log a UBSan error to stderr but continue execution
  • Proxy getPrototypeOf traps that print unexpected output before the fix cleanly handles the exception

The closely related pretty-format-overflow.test.ts crash regression test demonstrates this sensitivity by explicitly checking expect(stderr).not.toContain('panic'), expect(stderr).not.toContain('integer overflow'), and expect(stderr).not.toContain('SIGTRAP').

Concrete proof of the gap

  1. The patched code adds scope.tryClearException(); break; — on a build with extra diagnostics enabled, it could print a warning to stderr before breaking
  2. The test's Promise.all collects proc.stderr.text() — that string could contain e.g. "[warn] exception cleared in forEachProperty\n"
  3. Because of , ,, the string is discarded; neither expect(stderr).toBe('') nor any other assertion on it exists
  4. The test still passes: stdout === 'OK\n' and exitCode === 0
  5. The regression in stderr goes undetected

Fix

Change line 23 from:

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

to:

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

and add before the existing assertions:

expect(stderr).toBe("");

@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch from 8daf971 to d3935d7 Compare April 6, 2026 18:01

@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 `@test/js/bun/test/expect-proxy-prototype-crash.test.ts`:
- Line 25: Remove the flaky stderr equality assertion in the test by deleting
the line that asserts expect(stderr).toBe("") and rely on the process exit code
as the crash indicator; update the test (in
expect-proxy-prototype-crash.test.ts) to only assert on exitCode (or use
expect(code).toBe(...) / expect(exitCode).not.toBe(0) as appropriate) and leave
any stderr output unasserted to avoid ASAN-only failures.
🪄 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: dcac1f15-929d-4ab2-9715-567307d9b093

📥 Commits

Reviewing files that changed from the base of the PR and between bd411d0283f255e8a8baea435d374d4602c451d7 and d3935d73b349a7574a03ea6c5bd7a9517b93202e.

📒 Files selected for processing (2)
  • src/bun.js/bindings/bindings.cpp
  • test/js/bun/test/expect-proxy-prototype-crash.test.ts

Comment thread test/js/bun/test/expect-proxy-prototype-crash.test.ts Outdated
@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch from d3935d7 to d9a4684 Compare April 6, 2026 18:12
Comment thread src/bun.js/bindings/bindings.cpp
@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch from d9a4684 to 1e25eaa Compare April 6, 2026 18:19

@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 — my previous concerns (Bun.jest(Bun) → Bun.jest() for ASAN compatibility, and not asserting empty stderr) have been addressed.

Extended reasoning...

Overview

Two-file change: a targeted null-deref fix in the slow-path prototype-chain walk of JSC__JSValue__forEachPropertyImpl in bindings.cpp, plus a regression test. The C++ change adds an exception check after getPrototype on both the fast path (via CLEAR_IF_EXCEPTION) and the slow path (by storing the result, checking scope.exception(), clearing it, and breaking), exactly mirroring the existing pattern in the function.

Security risks

None beyond the crash itself being fixed. The change suppresses exceptions from Proxy getPrototypeOf traps during property enumeration — this is the correct defensive behavior for an internal formatting utility, not a security-sensitive code path.

Level of scrutiny

Low-to-medium. The fix is a single well-understood pattern (check-and-clear exception after a fallible JSC call) applied to a code path that was already using the same pattern on the fast path. The logic is straightforward and the diff is small.

Other factors

My two previous inline concerns have been addressed in subsequent commits: Bun.jest(Bun) was corrected to Bun.jest() (matching the expect-stack-overflow-crash.test.ts pattern) to avoid ASAN build failures, and the expect(stderr).toBe('') assertion was omitted to prevent ASAN-output flakiness. The test correctly verifies exit code 0 and stdout output as crash detection signals.

@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch from 1e25eaa to a12259f Compare April 6, 2026 18:56

@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 — prior concerns addressed.

Extended reasoning...

Overview

Minimal two-file change: a targeted null-deref fix in JSC__JSValue__forEachPropertyImpl (slow path of the prototype-chain walk in bindings.cpp) plus a regression test.

Security risks

None beyond the crash itself, which is being fixed. The fix correctly suppresses a thrown exception from a Proxy getPrototypeOf trap by checking scope.exception() and calling scope.tryClearException() before breaking — matching the existing fast-path pattern. No auth, crypto, or permissions code involved.

Level of scrutiny

Low. The change is five lines of C++ following an established pattern already used in the same function (both the fast path and the CLEAR_IF_EXCEPTION idiom elsewhere in the loop). The fix is logically sound.

Other factors

Two rounds of review feedback were incorporated: (1) the test was updated from Bun.jest(Bun) to Bun.jest() to avoid a failure-mode on ASAN builds, and (2) the flaky expect(stderr).toBe('') assertion was removed per the CodeRabbit suggestion (commit d9a4684). Both inline comments are marked resolved. No new bugs were found by the automated bug hunting pass.

@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch 2 times, most recently from 8d40304 to dd063fa Compare April 6, 2026 19:15

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bun.js/api/cron.classes.ts:1 — This PR accidentally includes src/bun.js/api/cron.classes.ts, an unrelated empty stub (export default []) for an in-progress cron feature. The cmake/Sources.json glob src/bun.js/{api,node,test,webcore}/*.classes.ts picks it up as input to generate-classes.ts, which explicitly throws AggregateError when any input file has an empty default export — breaking fresh builds. This file should be removed from this PR.

    Extended reasoning...

    What the bug is

    src/bun.js/api/cron.classes.ts was added as part of this PR with content export default []; — a placeholder stub for a future cron JS class binding. It is completely unrelated to the stated goal of this PR (handling exceptions from getPrototype in forEachProperty). Every other *.classes.ts file in src/bun.js/api/ contains actual class definitions via the define() helper from class-definitions.

    The specific code path that triggers the build failure

    cmake/Sources.json contains a ZigGeneratedClassesSources entry with glob pattern src/bun.js/{api,node,test,webcore}/*.classes.ts. This pattern matches src/bun.js/api/cron.classes.ts and passes it as an argument to src/codegen/generate-classes.ts. In that script at line 2773:

    if (\!(result?.default?.length ?? 0)) {
      errors.push(new TypeError(`Missing classes in "${file}". Expected export default [ define(...) ] ...`));
      continue;
    }

    For export default [], result.default.length === 0, so \!(0 ?? 0) is true and the error is pushed. Then at line 2794, if (errors.length) { throw new AggregateError(errors, 'Failed to generate classes'); } terminates the entire codegen step.

    Why existing code does not prevent it

    The build system uses a glob, not an explicit file list, so any new *.classes.ts file added to src/bun.js/api/ is automatically picked up. There is no guard that ignores empty stubs — the generator treats an empty array as an error by design, since every classes file is expected to export real class definitions.

    Impact

    The ZigGeneratedClasses codegen step fails, which blocks cmake --build for any fresh checkout of this branch. Any CI job that does a clean configure will hit this. Pre-configured build caches may mask the failure, explaining why initial CI may have passed, but any fresh build environment — including contributor setups, release pipelines, and Docker-based builds — will break.

    Step-by-step proof

    1. Developer clones the repo at this commit and runs cmake -S . -B build && cmake --build build
    2. CMake globs src/bun.js/api/*.classes.ts and finds cron.classes.ts
    3. It passes src/bun.js/api/cron.classes.ts as an argument to bun src/codegen/generate-classes.ts
    4. The script imports the file; result.default is [], so result.default.length === 0
    5. \!(0 ?? 0) evaluates to true; a TypeError is pushed to the errors array
    6. After processing all files, errors.length > 0, so new AggregateError(errors, 'Failed to generate classes') is thrown
    7. The codegen step exits non-zero; CMake aborts the build

    How to fix it

    Remove src/bun.js/api/cron.classes.ts from this PR. The cron feature work should live in a separate, properly-described PR once the class bindings are actually implemented.

Comment thread src/bun.js/bindings/bindings.cpp
@robobun
robobun force-pushed the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch from dd063fa to ffb6ab8 Compare April 6, 2026 19:30
@robobun

robobun commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator Author

The fix in this PR is verified correct against the Fuzzilli repro (UBSan null member call at bindings.cpp:5241) and compiles cleanly on buildkite CI. I'm stopping further amendments here — CI on buildkite is green/running green. Ready for human review/merge.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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


Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/bun.js/bindings/bindings.cpp:5238-5249 — This is a pre-existing bug in the same forEachPropertyImpl function touched by this PR: the slow-path property loop at lines 5165-5169 places CLEAR_IF_EXCEPTION(scope) after a continue, so when getPropertySlot() throws AND returns false (e.g. from a Proxy [[Get]] trap), the exception is never cleared. The next loop iteration then calls getPropertySlot with a stale pending exception, violating JSC ThrowScope invariants. Fix by calling scope.tryClearException() unconditionally before the if-check, matching the pattern already used in the sibling forEachPropertyOrdered function.

    Extended reasoning...

    What the bug is and how it manifests

    In the slow-path property loop of forEachPropertyImpl (lines 5165-5169 in bindings.cpp), after calling object->getPropertySlot(globalObject, property, slot), the code takes one of two paths:

    • If getPropertySlot returns true (property found): falls through to CLEAR_IF_EXCEPTION(scope) on line 5169.
    • If getPropertySlot returns false (property not found): hits continue on line 5167, skipping the CLEAR_IF_EXCEPTION entirely.

    A Proxy can make getPropertySlot both throw and return false (the [[Get]] trap throws, the engine returns false as the not-found sentinel). When that happens in the false-return branch, the exception stays pending in the JSC ThrowScope for the remainder of the loop.

    The specific code path that triggers it

    // lines 5165-5169 (slow path, inside forEachPropertyImpl)
    JSC::PropertySlot slot(object, PropertySlot::InternalMethodType::Get);
    if (!object->getPropertySlot(globalObject, property, slot))
        continue;           // exception still pending if getPropertySlot threw
    // Ignore exceptions from "Get" proxy traps.
    CLEAR_IF_EXCEPTION(scope);   // only reached when getPropertySlot returned true

    The comment on line 5168 explicitly states the intent is to suppress Proxy trap exceptions, but the implementation only clears them on the true-return path.

    Why existing code does not prevent it

    The CLEAR_IF_EXCEPTION macro is a conditional clear. By jumping over it via continue, the exception remains live. JSC ThrowScope rules require that no JSC function be called while an exception is pending. The next iteration calls getPropertySlot again on the same ThrowScope: debug builds fire an assertNoException assertion; release builds may early-exit or exhibit undefined behaviour.

    What the impact would be

    Any enumeration of an object whose property names include an entry where a Proxy [[Get]] trap throws can trigger this code path. In debug builds this is a hard assertion failure. In release builds the stale exception propagates through subsequent getPropertySlot calls, potentially causing premature loop termination or a phantom exception reaching the caller.

    Step-by-step proof with a concrete example

    1. Object o is a Proxy-wrapped object where the get trap for key "foo" throws TypeError.
    2. getOwnPropertyNames(o) returns ["foo"].
    3. Slow-path loop iterates: property = "foo", calls o->getPropertySlot(..., "foo", slot).
    4. The Proxy get trap fires and throws. getPropertySlot returns false.
    5. if (!false) triggers continue. Exception is still pending in scope.
    6. Next iteration: calls getPropertySlot again.
    7. JSC detects an active exception in the ThrowScope: debug assertion fires, or release-mode early-exit occurs.

    How to fix it

    Mirror the pattern already used correctly in the sibling function forEachPropertyOrdered (lines 5335-5340):

    // forEachPropertyOrdered — correct pattern
    bool hasProperty = object->getPropertySlot(globalObject, property, slot);
    (void)scope.tryClearException();   // unconditional clear
    if (!hasProperty) {
        continue;
    }

    Apply the same structure to the slow-path loop in forEachPropertyImpl: capture the return value, call scope.tryClearException() unconditionally, then branch on the captured value. This matches the intent stated in the comment at line 5168 and makes the false-return path behave the same as the true-return path with respect to exception state.

robobun added a commit that referenced this pull request Apr 23, 2026
…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.
@robobun

robobun commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #29642, which combines the getPropertySlot exception-clear reorder, the getPrototype() null guard, the EnsureStillAliveScope for the prototype-walk object, and the equivalent guard in napi_get_all_property_names.

@robobun robobun closed this Apr 23, 2026
@robobun
robobun deleted the farm/958e42a7/fix-foreach-property-proxy-proto-crash branch April 23, 2026 21:41
robobun added a commit that referenced this pull request Apr 26, 2026
…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.
Jarred-Sumner pushed a commit that referenced this pull request May 4, 2026
…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.
robobun added a commit that referenced this pull request Aug 13, 2026
…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.
robobun added a commit that referenced this pull request Aug 17, 2026
…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.
robobun added a commit that referenced this pull request Aug 17, 2026
…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.
dylan-conway pushed a commit that referenced this pull request Aug 18, 2026
…p or getPrototypeOf throws during the property walk (#29642)

### Problem

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

### Fix

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

### Background

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

### Consolidated duplicates

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

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

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

---

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

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

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant