Skip to content

Fix non-object returns from native construct handlers#31955

Open
robobun wants to merge 8 commits into
mainfrom
farm/6b0428f9/fix-mock-construct
Open

Fix non-object returns from native construct handlers#31955
robobun wants to merge 8 commits into
mainfrom
farm/6b0428f9/fix-mock-construct

Conversation

@robobun

@robobun robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a fuzzer-found crash (debug assertion cell->isObjectSlow() in JSC::asObject, a type confusion in release builds) caused by native construct handlers returning non-objects.

JSC's Interpreter::executeConstruct ends with return asObject(JSValue::decode(result));, so a native [[Construct]] implementation must return an object or throw. Returning undefined or another primitive asserts in debug builds; in release builds the primitive is reinterpreted as a JSObject*, and C++ callers of JSC::construct() then operate on a garbage pointer.

Three places registered a plain call handler as the construct handler:

  1. JSMockFunction registered jsMockFunctionCall for both call and construct, so Reflect.construct(jest.fn(), []) returned whatever the mock implementation returned, including undefined. This is what the fuzzer hit, via constructing a spyOn mock. It was also a visible compat bug: new (jest.fn())() evaluated to undefined in release builds, while Jest returns a new instance.

  2. JSFFIFunction::create accepted a nativeConstructor parameter (defaulting to callHostFunctionAsConstructor) but ignored it and passed the FFI call handler instead; createForFFI did the same. Reflect.construct(dlopen(...).symbols.fn, []) hit the same assertion whenever the FFI return type was a primitive.

  3. node:buffer's isAscii, isUtf8, and resolveObjectURL passed their call handlers as construct handlers, so constructing them returned booleans/undefined. fix(buffer): use correct constructor for buffer.isAscii #22480 already suggested removing these constructors; this does that.

The fix

  • JSMockFunction gets a real jsMockFunctionConstruct implementing ordinary-function construct semantics, which is what Jest mock functions (plain JS functions) get for free: create this from newTarget.prototype (falling back to %Object.prototype%), run the mock-call logic with that this, return the implementation's result if it is an object, otherwise the new instance. Like Jest, every invocation records its this into mock.instances and mock.contexts (so a construct records the new instance, and the arrays stay index-aligned with mock.calls). The shared bookkeeping moved into jsMockFunctionCallImpl, which takes thisValue explicitly (for a native construct frame, callframe->thisValue() holds newTarget, not a usable this - the old code was also recording newTarget into mock.contexts).
  • JSFFIFunction passes nativeConstructor through (and callHostFunctionAsConstructor in createForFFI), so FFI functions throw TypeError when constructed, like every other Bun host function.
  • The node:buffer trio drops the explicit construct handler, using the callHostFunctionAsConstructor default.

The mock [[Construct]] line is the fix for the fuzzer crash; the other two are the same bug class found by auditing every InternalFunction/getHostFunction/JSFunction::create registration in the tree. All remaining construct handlers return an object or throw on every path. (One latent case left alone: the v8 shim's ObjectTemplate uses Template::DummyCallback for construct, but templates are never exposed as JS values.)

Behavior changes

  • new (jest.fn())() now returns a new object instead of undefined (matches Jest).
  • new (require("node:buffer").isAscii)(buf) now throws TypeError instead of behaving like a call. Node returns a fresh object here (V8 makes all native functions constructible, which ECMA-262 disallows for builtins not identified as constructors); Bun already throws for nearly all native functions (path.join, os.cpus, ...), so this follows the existing convention. The test added in fix(buffer): use correct constructor for buffer.isAscii #22480 asserting the old call-through behavior is updated.
  • Constructing a bun:ffi function now throws TypeError instead of being undefined behavior.
  • Because the functions are no longer constructible, Bun.inspect stops classifying them as classes: console.log(os.hostname) now prints [Function: hostname] instead of [class hostname]. Fixes OS Module os["class"] gives wrong output #32103.
  • console.log(os.hostname) now prints [Function: hostname] instead of [class hostname]: Bun's inspect classifies constructible native functions as classes, and the ignored nativeConstructor parameter made every node:os binding export constructible. Fixes OS Module os["class"] gives wrong output #32103.

Unrelated CI fix included

Fixes #31797.

test/cli/install/bunx.test.ts ("should handle package that requires node 24") ran bun x --bun @angular/cli@latest against the live registry. Angular CLI 22.0.0 now requires Node ^22.22.3 || ^24.15.0 || >=26.0.0 at runtime and exits 3 under Bun (which reports Node v24.3.0), so the test fails on every branch, including this PR's base commit. The test is now pinned to @angular/cli@21.0.0 (engines ^20.19.0 || ^22.12.0 || >=24.0.0), which keeps the node 24 coverage it exists for and stops it from tracking future publishes.

How did you verify your code works?

  • The fuzzer repro (Reflect.construct on a spy) aborts with the assertion on an unfixed debug build and exits cleanly on the fixed one.
  • New tests in test/js/bun/test/mock-fn.test.js (constructing a mock describe block), test/js/node/buffer.test.js, test/js/node/buffer-resolveObjectURL.test.ts, and test/js/bun/ffi/ffi.test.js. They fail with USE_SYSTEM_BUN=1 and pass with the fixed build.
  • The nine mock construct tests were run verbatim under real Jest 30.4.1 (all pass), so the construct semantics match Jest, including mock.instances/mock.contexts/mock.results recording for plain calls, constructs, and mixed sequences. One pre-existing difference is unchanged: mocks have no default own .prototype property (they are InternalFunctions), so without assigning fn.prototype the instance gets %Object.prototype% and instanceof fn throws, as it already did before this PR, newTarget.prototype handling via Reflect.construct, object-return-wins, and primitive-return-falls-back-to-instance.
  • Full buffer.test.js (506 pass), mock-fn.test.js (81 pass), mock-module suites, spyMatchers.test.ts, and ffi.test.js pass. Exception checks validated with BUN_JSC_validateExceptionChecks=1, plus a GC stress loop over the new construct path.

@github-actions github-actions Bot added the claude label Jun 7, 2026
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:56 PM PT - Jun 16th, 2026

@robobun, your commit 3d5152e has 1 failures in Build #62900 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31955

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

bun-31955 --bun

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

Enforce constructor/construct-call semantics: FFI/native functions become non-constructible; mock functions gain proper construct behavior and instance recording; Node buffer exports are made non-constructible; tests updated and added to validate these semantics.

Changes

Non-constructible native and FFI functions

Layer / File(s) Summary
FFI function construction
src/jsc/bindings/JSFFIFunction.cpp, test/js/bun/ffi/ffi.test.js
JSFFIFunction::create and JSFFIFunction::createForFFI now pass the constructor-call host target (e.g., nativeConstructor / callHostFunctionAsConstructor) when creating NativeExecutable, making linked FFI functions non-constructible. Tests link a JSCallback and assert new/Reflect.construct throw TypeError.

Mock function construction support

Layer / File(s) Summary
Mock function call and construct support
src/jsc/bindings/JSMockFunction.cpp, test/js/bun/test/mock-fn.test.js
Add jsMockFunctionConstruct and jsMockFunctionCallImpl; construction dispatch uses jsMockFunctionConstruct, which creates the constructed this from newTarget.prototype, records mock.instances, and returns object results or the constructed instance. Tests validate construction semantics, instance/context recording, return-value precedence, and error propagation.

Node buffer module construction

Layer / File(s) Summary
Node buffer exports non-constructible
src/jsc/modules/NodeBufferModule.h, test/js/node/buffer-resolveObjectURL.test.ts, test/js/node/buffer.test.js
Change resolveObjectURL binding creation (omit explicit function pointer) so the binding uses the constructor-call dispatcher; tests assert resolveObjectURL, isAscii, and isUtf8 throw TypeError when used with new or Reflect.construct.

Tests and misc

Layer / File(s) Summary
Tests and pin
test/js/node/os/os.test.js, test/cli/install/bunx.test.ts
Add Bun.inspect assertions for native os exports, relax os.userInfo username expectation to allow "unknown", and pin @angular/cli to @angular/cli@21.0.0 in a bunx install test.

Possibly related issues

Suggested reviewers

  • Jarred-Sumner
  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing non-object returns from native construct handlers, which is the core issue addressed throughout the PR.
Description check ✅ Passed The description comprehensively addresses both required sections: it thoroughly explains what the PR does (the crash bug, three affected components, and the fix for each) and how it was verified (fuzzer repro, new tests, existing test suites, and stress testing).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(bun:test): return an object when a mock function is constructed with new #31386 - Also fixes JSMockFunction to return an object when constructed with new, addressing the same asObject crash from native construct handlers returning non-objects. PR Fix non-object returns from native construct handlers #31955 is a superset that additionally fixes JSFFIFunction and node:buffer construct handlers.

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/JSMockFunction.cpp Outdated
Comment thread src/jsc/bindings/JSMockFunction.cpp
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the only red job is the Linux x64 ASAN shard, failing on test/regression/issue/30205.test.ts (the --isolate napi finalizer test). The spawned bun test --isolate run prints 8 pass, 0 fail, Ran 8 tests across 8 files, and then the child aborts with SIGABRT during process shutdown under BUN_JSC_collectContinuously=1.

Why this is unrelated to the PR:

  • Build 61263 ran identical native sources (the only commits after it are a test-only @angular/cli pin and an empty retrigger) and this test passed in the very same shard. It then failed in builds 61267 and 61270 after fresh recompiles, failing every retry within each build.
  • It passes repeatedly (4 of 4 runs) on a local Linux x64 ASAN debug build of this branch.
  • The changes here (mock function construct, FFI and node:buffer constructor registration) are not on the napi finalizer or VM shutdown path.

The abort-after-summary profile is the same GC timing family this test was added for in #30216, surfacing at exit rather than mid-run. It looks sensitive to the particular binary produced by each CI compile. Leaving this one for a maintainer to judge, since retriggering repeatedly will not converge.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes #32103: console.log(os.hostname) prints [class hostname] because Bun's inspect classifies constructible native functions as classes, and the ignored nativeConstructor parameter in JSFFIFunction::create made every node:os binding export constructible. Verified locally that the JSFFIFunction.cpp change here flips the output to [Function: hostname].

Branch farm/a41e3e37/fix-os-function-inspect has the same two JSFFIFunction.cpp hunks plus a display-level test asserting Bun.inspect(os[name]) is [Function: name] for the 12 natively-bound node:os exports (main...farm/a41e3e37/fix-os-function-inspect), in case that coverage is worth cherry-picking. Not opening a separate PR since the src fix is identical.

@robobun

robobun commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI status update for build 61893: the earlier napi shutdown flake (30205) did not recur. The only failure is test/js/node/test/parallel/test-net-server-close-before-ipc-response.js in the Linux x64 ASAN shard, an upstream Node net/IPC timing test where the mustNotCall('listening') listener fired while the server was closing. It is unrelated to this PR (nothing here touches net, IPC, or event emission), it passed in every prior ASAN run of this branch with identical native code (builds 61263, 61267, 61270), and it passes 5 of 5 locally. Each red build on this branch has been a different unrelated timing flake in that shard; the PR's own tests are green across all platforms. Leaving retries to maintainers rather than pushing more empty commits.

robobun added 8 commits June 16, 2026 21:51
A native construct handler must return an object or throw:
Interpreter::executeConstruct calls asObject() on the result, which is
an assertion failure in debug builds and a type confusion in release
builds when the handler returns undefined or another primitive.

Three places registered a plain call handler as the construct handler:

- JSMockFunction used jsMockFunctionCall for both call and construct,
  so Reflect.construct(jest.fn(), []) returned whatever the mock
  implementation returned, including undefined. Mock functions now
  implement ordinary-function construct semantics like Jest's JS mock
  functions: create this from newTarget.prototype, run the mock with
  that this, return the result if it is an object, otherwise the new
  instance. The constructed instance is recorded in mock.instances.

- JSFFIFunction::create ignored its nativeConstructor parameter
  (which already defaults to callHostFunctionAsConstructor) and passed
  the FFI call handler instead; createForFFI did the same. FFI
  functions now throw when constructed.

- node:buffer isAscii, isUtf8, and resolveObjectURL passed their call
  handlers as construct handlers. They now throw when constructed,
  like other native functions (the follow-up suggested in #22480).

Found by fuzzing: constructing a jest spy tripped
ASSERTION FAILED: cell->isObjectSlow() in JSC::asObject.
linkSymbols routes through createForFFI; runtime functions like the
node:os natives route through JSFFIFunction::create. Assert that
constructing those throws too.
Jest pushes this into mock.instances on every call, exactly like
mock.contexts, so instances stays index-aligned with calls. Push
thisValue unconditionally instead of only on the construct path, and
drop the now-unused constructedObject parameter.
The test asserted the behavior of @angular/cli@latest from the live
registry. Angular 22.0.0 now requires Node ^22.22.3 || ^24.15.0 ||
>=26.0.0 at runtime, so it prints a version error and exits 3 under
bun x --bun, which reports Node v24.3.0. This fails on every branch,
including the PR base commit.

Pin to @angular/cli@21.0.0, whose engines are
^20.19.0 || ^22.12.0 || >=24.0.0, keeping the node 24 coverage the
test exists for while making it independent of future publishes.
Constructible native functions are classified as classes by
Bun.inspect, so the ignored nativeConstructor parameter in
JSFFIFunction::create made console.log(os.hostname) print
[class hostname]. The JSFFIFunction fix in this branch flips that to
[Function: hostname]; add coverage for the node:os exports and the
bun:ffi path, and let the userInfo test run where USER is unset,
matching the existing SHELL fallback.
@robobun robobun force-pushed the farm/6b0428f9/fix-mock-construct branch from 4bee6da to 3d5152e Compare June 16, 2026 21:55
@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Build 62900 (the rebased sha 3d5152e) failed at CI agent provisioning: Error: [robobun] Image not found: linux-aarch64-2023-amazonlinux-with-docker-v37. No build or test job ran; the pipeline was canceled in 46 seconds. Same failure on main (build 62897) and adjacent branches (62898, 62899) in the same window, so this is an infrastructure outage, not a signal about the code. The rebase itself is verified locally: the fuzzer repro and all touched test files pass on a fresh debug build of 3d5152e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant