Skip to content

Fix null pointer crash in lazy property reification during deepEquals#28550

Open
robobun wants to merge 5 commits intomainfrom
farm/d8266e41/fix-null-deref-lazy-property
Open

Fix null pointer crash in lazy property reification during deepEquals#28550
robobun wants to merge 5 commits intomainfrom
farm/d8266e41/fix-null-deref-lazy-property

Conversation

@robobun
Copy link
Collaborator

@robobun robobun commented Mar 25, 2026

After a caught stack overflow, calling Bun.deepEquals() with the Bun namespace object as an argument could crash with a null pointer dereference in isGetterSetter().

Root cause

When deepEquals iterates the properties of the Bun object, it triggers lazy property reification via getIfPropertyExistssetUpStaticFunctionSlotreifyStaticProperty. The lazy property callbacks (both Zig-backed and C++) can return an empty JSValue (.zero / {}) when they encounter an exception (like a stack overflow propagating through). This empty value was passed directly to putDirect(), which called isGetterSetter() on a null cell pointer.

Fix

  • Zig callback wrapper (BunObject+exports.h): Return jsUndefined() instead of an empty JSValue when the callback fails. The pending exception is still on the VM and will be caught by RETURN_IF_EXCEPTION in the caller.
  • C++ callbacks (BunObject.cpp): Change constructBunShell, defaultBunSQLObject, and constructBunSQLObject to return jsUndefined() instead of {} on exception.

Repro

function F4() {
    try { new F4(); } catch (e) {}
    Bun.deepEquals(Uint8Array, Bun);
}
new F4();

Crash: JSCJSValueCellInlines.h:67:34: member call on null pointer of type 'JSC::JSCell'

@robobun
Copy link
Collaborator Author

robobun commented Mar 25, 2026

Updated 5:05 PM PT - Mar 25th, 2026

@robobun, your commit 1893414 is building: #42068

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2481a93a-9037-47be-afeb-8824a21ed754

📥 Commits

Reviewing files that changed from the base of the PR and between bbe638c and 60a0594.

📒 Files selected for processing (3)
  • src/bun.js/bindings/BunObject+exports.h
  • src/bun.js/bindings/BunObject.cpp
  • test/js/bun/deep-equals-lazy-property-crash.test.ts

Walkthrough

Changed lazy property getter wrappers to guard falsy decoded values and return jsUndefined(). Updated several internal constructor/initializer functions to return jsUndefined() on exception/failure paths instead of empty objects. Added a regression test ensuring Bun.deepEquals does not crash after a lazy-property stack-overflow-like failure.

Changes

Cohort / File(s) Summary
Bindings: lazy getter and exports
src/bun.js/bindings/BunObject+exports.h
Wrapped decoded JSC::JSValue into a local result, check for falsy/failed decoding, and return JSC::jsUndefined() on failure instead of returning the raw decoded value.
Internal constructors / exception paths
src/bun.js/bindings/BunObject.cpp
Changed early-return values on RETURN_IF_EXCEPTION and other failure branches in defaultBunSQLObject, constructBunSQLObject, and constructBunShell from an empty object ({}) to jsUndefined().
Tests: regression
test/js/bun/deep-equals-lazy-property-crash.test.ts
Added a test that spawns a child Bun process exercising a recursive lazy-property callback and then calls Bun.deepEquals to verify no crash occurs; asserts child prints ok and exits with code 0.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main fix: preventing a null pointer crash during lazy property reification in the deepEquals function.
Description check ✅ Passed The description provides a clear problem statement, root cause analysis, detailed fixes, and a repro case. However, it lacks the template sections 'What does this PR do?' and 'How did you verify your code works?'

✏️ 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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/deep-equals-lazy-property-crash.test.ts`:
- Around line 9-17: The test currently asserts the absence of a crash string
(e.g., not.toContain("null pointer") ) which is forbidden; instead modify the
child code (the snippet that defines function F4 and calls
Bun.deepEquals(Uint8Array, Bun)) to emit a deterministic success marker (e.g.,
console.log("TEST_OK")) when it finishes, then update the assertions to first
expect stdout to contain that marker and only after that assert exitCode === 0;
remove any negative assertions checking for crash text and apply the same change
to the other occurrences referenced (lines 24-27).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ceb2fbe6-dc7a-468a-bf89-befe1ddea287

📥 Commits

Reviewing files that changed from the base of the PR and between 0bcb402 and dd16669.

📒 Files selected for processing (3)
  • src/bun.js/bindings/BunObject+exports.h
  • src/bun.js/bindings/BunObject.cpp
  • test/js/bun/deep-equals-lazy-property-crash.test.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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/webview/WebViewEventTarget.h`:
- Line 45: Add the missing WebCore::BunWebViewEventTargetInterfaceType enum
entry to EventTargetInterfaces.h and add a matching case in
EventTargetFactory.cpp inside the toJS(WebCore::EventTargetInterface) switch so
the eventTargetInterface() implementation in WebViewEventTarget.h (which returns
WebCore::BunWebViewEventTargetInterfaceType) compiles; ensure the new enum value
is appended or numbered consistently with existing values (after
XMLHttpRequestUploadEventTargetInterfaceType) and implement the toJS branch to
construct/return the appropriate JS wrapper for
BunWebViewEventTargetInterfaceType so the mapping between enum and factory is
complete.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1fb13b20-0db1-4341-abf3-c268f62c3e0e

📥 Commits

Reviewing files that changed from the base of the PR and between dd16669 and 53671ea.

📒 Files selected for processing (2)
  • src/bun.js/webview/WebViewEventTarget.h
  • test/js/bun/deep-equals-lazy-property-crash.test.ts

{
}

WebCore::EventTargetInterface eventTargetInterface() const final { return WebCore::BunWebViewEventTargetInterfaceType; }
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if BunWebViewEventTargetInterfaceType exists in the enum definition
echo "=== Searching for BunWebViewEventTargetInterfaceType in EventTargetInterfaces.h ==="
rg -n 'BunWebViewEventTargetInterfaceType' --type=cpp --type=h

echo ""
echo "=== Checking EventTargetFactory.cpp for dispatch handling ==="
rg -n -A2 'BunWebViewEventTargetInterfaceType' src/bun.js/bindings/webcore/EventTargetFactory.cpp

echo ""
echo "=== Full EventTargetInterface enum ==="
rg -n -A 100 'enum EventTargetInterface' src/bun.js/bindings/webcore/EventTargetInterfaces.h | head -150

Repository: oven-sh/bun

Length of output: 4534


🏁 Script executed:

#!/bin/bash

echo "=== Full search for BunWebViewEventTargetInterfaceType in codebase ==="
rg 'BunWebViewEventTargetInterfaceType' -t cpp -t h

echo ""
echo "=== Get complete EventTargetInterface enum ==="
rg -n 'enum EventTargetInterface' -A 200 src/bun.js/bindings/webcore/EventTargetInterfaces.h | tail -100

echo ""
echo "=== Check if enum continues beyond visible output ==="
wc -l src/bun.js/bindings/webcore/EventTargetInterfaces.h

echo ""
echo "=== Look for BUN-specific enum extensions or overrides ==="
fd -e h -e cpp . src/bun.js/webview/ | xargs rg -l 'EventTargetInterface'

echo ""
echo "=== Check EventTargetFactory.cpp for total number of cases ==="
rg -c 'case.*EventTargetInterfaceType' src/bun.js/bindings/webcore/EventTargetFactory.cpp

Repository: oven-sh/bun

Length of output: 4867


BunWebViewEventTargetInterfaceType enum value is not defined—code will not compile.

The eventTargetInterface() method returns WebCore::BunWebViewEventTargetInterfaceType, but this enum value does not exist in EventTargetInterfaces.h. The enum ends at XMLHttpRequestUploadEventTargetInterfaceType = 74 with no entry for BunWebViewEventTargetInterfaceType. Additionally, EventTargetFactory.cpp has no dispatch case for this interface type (74 cases total, none matching this type).

This is a compilation error. Add BunWebViewEventTargetInterfaceType to the enum in EventTargetInterfaces.h and add a corresponding case in EventTargetFactory.cpp's toJS() switch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/webview/WebViewEventTarget.h` at line 45, Add the missing
WebCore::BunWebViewEventTargetInterfaceType enum entry to
EventTargetInterfaces.h and add a matching case in EventTargetFactory.cpp inside
the toJS(WebCore::EventTargetInterface) switch so the eventTargetInterface()
implementation in WebViewEventTarget.h (which returns
WebCore::BunWebViewEventTargetInterfaceType) compiles; ensure the new enum value
is appended or numbered consistently with existing values (after
XMLHttpRequestUploadEventTargetInterfaceType) and implement the toJS branch to
construct/return the appropriate JS wrapper for
BunWebViewEventTargetInterfaceType so the mapping between enum and factory is
complete.

Comment on lines 93 to 99

// definition of the C++ wrapper to call the Zig function
#define DEFINE_ZIG_BUN_OBJECT_GETTER_WRAPPER(name) static JSC::JSValue BunObject_lazyPropCb_wrap_##name(JSC::VM &vm, JSC::JSObject *object) { \
return JSC::JSValue::decode(BunObject_lazyPropCb_##name(object->globalObject(), object)); \
JSC::JSValue result = JSC::JSValue::decode(BunObject_lazyPropCb_##name(object->globalObject(), object)); \
if (!result) [[unlikely]] return JSC::jsUndefined(); \
return result; \
} \
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 The fix converts a hard crash into silent, permanent corruption of the Bun global object: JSC's PropertyCallback machinery unconditionally calls putDirect(vm, propName, result) with the callback return value before any exception check, so returning jsUndefined() on failure causes JSC to permanently store undefined for properties like Bun.$, Bun.sql, Bun.SQL, and Bun.postgres. Since these properties have DontDelete, they cannot be reset — the Bun namespace is irrecoverably broken for the rest of that process. Consider checking for a pending exception inside the callbacks before returning, or skipping putDirect when a VM exception is already pending.

Extended reasoning...

Root cause: putDirect is called unconditionally before the exception is checked

JSC's PropertyCallback handling in Lookup.h (WebKit commit fc9f2fa) is:

if (value.attributes() & PropertyAttribute::PropertyCallback) {
    JSValue result = value.lazyPropertyCallback()(vm, &thisObj);
    thisObj.putDirect(vm, propertyName, result, attributesForStructure(value.attributes()));
    return;
}

There is no exception guard between the callback invocation and putDirect. The PR description itself confirms this: "This empty value was passed directly to putDirect()". Before the fix, passing JSValue{} (null cell) to putDirect called isGetterSetter() on a null pointer, causing a hard crash. After the fix, passing jsUndefined() to putDirect stores undefined permanently and strips the PropertyCallback attribute — no crash, but no more lazy reification either.

Step-by-step proof of the corruption scenario

  1. Deep JS recursion (new F4()) causes a first JSC stack-overflow exception; a surrounding try/catch clears it.
  2. The stack is still at depth N-1 (near the overflow threshold) when the catch block runs.
  3. The catch block calls Bun.deepEquals(Uint8Array, Bun), which iterates Bun's enumerable properties via getIfPropertyExists (bindings.cpp:1008).
  4. Uninitialized lazy properties (sql, postgres, $, SQL) trigger their callbacks: defaultBunSQLObject calls requireId (module loading), constructBunShell calls into JS via JSC::call.
  5. At depth N-1 these re-entrant calls overflow the stack again; the callbacks return jsUndefined() (the new behavior).
  6. JSC calls putDirect(vm, "$", jsUndefined(), DontDelete|...) — permanently storing undefined and clearing PropertyCallback.
  7. RETURN_IF_EXCEPTION at bindings.cpp:1009 fires after putDirect has already committed the bad value.
  8. The second exception is caught; execution resumes, but Bun.$, Bun.sql, Bun.SQL, Bun.postgres are now permanently undefined.

Why DontDelete makes this unrecoverable

The properties are registered with DontDelete | PropertyCallback. After step 6, the slot holds jsUndefined() and PropertyCallback has been stripped by attributesForStructure. DontDelete prevents delete Bun.$ from succeeding in userland, and the now-resolved slot will never re-invoke the lazy callback. There is no recovery path short of restarting the process.

Why the test does not detect this corruption

The added regression test limits recursion with if (depth++ > 100) throw new Error("too deep"), capping the call stack at ~100 frames — far below JSC's actual stack-overflow threshold. No real VM pending-exception is ever set, so all lazy callbacks execute normally and return valid JSValues. The !result guard and jsUndefined() fallback paths added by this PR are never reached. The test passes on both fixed and unfixed code, providing false confidence.

How to fix

The correct fix must prevent putDirect from being called with a poisoned value when a VM exception is pending. Viable approaches: (1) At the JSC PropertyCallback call site, add an exception check after the callback returns and before calling putDirect — skip putDirect if an exception is pending, leaving the slot unreified for a future access. (2) Inside each callback, check scope.exception() before the operation that can overflow and avoid returning at all if already in an exception state, propagating the existing exception instead of masking it with jsUndefined().

robobun and others added 4 commits March 25, 2026 16:42
Lazy property callbacks on the Bun namespace object can return an empty
JSValue when they encounter an exception (e.g. after a caught stack
overflow). The empty JSValue was passed directly to JSC's putDirect,
which called isGetterSetter() on a null cell pointer, causing a crash.

Fix the Zig callback wrapper to return jsUndefined() instead of an
empty JSValue on failure, and fix the C++ callbacks (constructBunShell,
defaultBunSQLObject, constructBunSQLObject) to return jsUndefined()
instead of {} on exception.
@robobun robobun force-pushed the farm/d8266e41/fix-null-deref-lazy-property branch from bbe638c to 60a0594 Compare March 25, 2026 16:43
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