Skip to content

Fix exception handling in unwrapBoxedPrimitive - #37025

Merged
dylan-conway merged 5 commits into
mainfrom
farm/84e1c3c6/yaml-stringify-toprimitive
Aug 6, 2026
Merged

Fix exception handling in unwrapBoxedPrimitive#37025
dylan-conway merged 5 commits into
mainfrom
farm/84e1c3c6/yaml-stringify-toprimitive

Conversation

@robobun

@robobun robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes a fuzzer-found abort (fingerprint 2f7e21cbc0aaac7b).

JSC__JSValue__unwrapBoxedPrimitive calls toString/toNumber on String and Number wrapper objects. Those re-enter JS when the wrapper has a Symbol.toPrimitive (or overridden valueOf/toString), so they can throw, for example when Symbol.toPrimitive returns an object. The binding ignored the pending exception and returned a value anyway, violating the zero-is-throw contract checked on the Rust side and tripping releaseAssertNoException in debug builds:

const s = new String();
s[Symbol.toPrimitive] = () => String;
Bun.YAML.stringify(s); // ASSERTION FAILED: Unexpected exception observed

WebKit's unwrapBoxedPrimitive in JSONObject.cpp (which this was copied from) relies on its callers doing RETURN_IF_EXCEPTION right after; our extern C copy returns straight to Rust, so the check has to live in the binding itself. This adds a throw scope and returns empty when toNumber/toString throws, so the error surfaces as a normal JS exception from Bun.YAML.stringify, Bun.TOML.stringify, and Bun.JSON5.stringify (all reach this through unwrap_boxed_primitive).

The regression test runs in a subprocess because the unfixed binary aborts the whole process in debug builds. Verified the spawned script exits 134 on the unfixed build and exits 0 with the expected TypeError output on the fixed one; yaml, toml, and json5 suites pass.


[stamp-90s] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 21 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/yaml/yaml.test.ts
bun test v1.4.0 (31a8c450d)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [3.13ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [2.12ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [3.10ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [1.95ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [3.05ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [2.21ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [3.07ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [3.01ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [2.84ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [3.11ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [3.97ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [2.58ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [2.99ms]
(pass) Bu
... (truncated)

release without fix: 21 skipped
bun test v1.4.0-canary.1 (726a839a8)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [0.09ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [0.04ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [0.07ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [0.05ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [0.07ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [0.05ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [0.04ms]
(pass) Bun.YAML > parse > input types > parses from DataView [0.04ms]
(pass) Bun.YAML > parse > input types > parses from Blob [0.30ms]
(pass) Bun.YAML > parse > i
... (truncated)
passes on PR (with fix)
ASAN with fix: 21 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/yaml/yaml.test.ts
bun test v1.4.0 (31a8c450d)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [3.41ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [2.13ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [3.17ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [1.97ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [3.11ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [2.22ms]
(pass) Bun.YAML > parse > input types > parses from Int16Array [3.18ms]
(pass) Bun.YAML > parse > input types > parses from Int32Array [3.03ms]
(pass) Bun.YAML > parse > input types > parses from Uint32Array [2.86ms]
(pass) Bun.YAML > parse > input types > parses from Float32Array [3.08ms]
(pass) Bun.YAML > parse > input types > parses from Float64Array [3.96ms]
(pass) Bun.YAML > parse > input types > parses from BigInt64Array [2.65ms]
(pass) Bun.YAML > parse > input types > parses from BigUint64Array [3.06ms]
(pass) Bu
... (truncated)

release with fix: 21 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 683ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/src/jsc/bindings/bindings.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 3m 01s
[3/6] link bun-profile
[5/6] strip bun
[5/6] bun-profile --revision
1.4.0-canary.1+31a8c450d
[build] done
bun test v1.4.0-canary.1 (31a8c450d)

test/js/bun/yaml/yaml.test.ts:
(pass) Bun.YAML > parse > input types > parses from Buffer [0.11ms]
(pass) Bun.YAML > parse > input types > parses from Buffer with UTF-8 [0.04ms]
(pass) Bun.YAML > parse > input types > parses from ArrayBuffer [0.06ms]
(pass) Bun.YAML > parse > input types > parses from Uint8Array [0.03ms]
(pass) Bun.YAML > parse > input types > parses from Uint16Array [0.05ms]
(pass) Bun.YAML > parse > input types > parses from Int8Array [0.06ms]
(
... (truncated)
diff hotspot
src/jsc/bindings/bindings.cpp | 12 +++++++++---
 test/js/bun/yaml/yaml.test.ts | 31 +++++++++++++++++++++++++++++++
 2 files changed, 40 insertions(+), 3 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                           reads  edits  tests
src/jsc/bindings/bindings.cpp      1      1      0
test/js/bun/yaml/yaml.test.ts      3      2      0

Unwrapping a String or Number wrapper object calls toString/toNumber,
which can re-enter JS through Symbol.toPrimitive and throw. The binding
returned the partial result with the exception still pending, tripping
the exception scope assertion in debug builds when YAML.stringify,
TOML.stringify, or JSON5.stringify received such a wrapper. Check for
the exception and return empty so the error propagates as a JS throw.
@github-actions github-actions Bot added the claude label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

JSC__JSValue__unwrapBoxedPrimitive now checks conversion exceptions for boxed numbers and strings. YAML tests verify that Symbol.toPrimitive errors propagate without aborting the subprocess.

Changes

Boxed primitive error handling

Layer / File(s) Summary
Conversion exception handling
src/jsc/bindings/bindings.cpp
Boxed number and string conversions now use a throw scope and return an empty encoded value when conversion raises an exception.
YAML regression coverage
test/js/bun/yaml/yaml.test.ts
A subprocess test covers boxed string conversion, boxed number serialization, and space argument conversion errors. It checks the error output and successful process exit.

Possibly related PRs

  • oven-sh/bun#37004: Both PRs fix JavaScript exception propagation during native-to-JavaScript value conversion, but modify different code paths and functions.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the primary change: fixing exception handling in unwrapBoxedPrimitive.
Description check ✅ Passed The description explains the bug, implementation, affected APIs, regression test, and verification results, despite not using the template headings.

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

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/yaml/yaml.test.ts`:
- Around line 4280-4296: Update the child-process wait around Bun.spawn to
consume proc.stderr.text() alongside proc.stdout.text() and proc.exited in the
existing Promise.all, preserving the current stdout and exit-code handling.
🪄 Autofix

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: 5d89cc45-91a5-4868-ac16-5d580e2506cf

📥 Commits

Reviewing files that changed from the base of the PR and between f4f3844 and 85290aa.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/yaml/yaml.test.ts

Comment thread test/js/bun/yaml/yaml.test.ts Outdated
@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, the test piped stderr without reading it. Pushed b084997 to drain stderr in the Promise.all and assert it is empty.

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:40 AM PT - Aug 6th, 2026

@dylan-conway, your commit 31a8c45 is building: #89553

Comment thread test/js/bun/yaml/yaml.test.ts Outdated
@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

The stderr finding was already addressed in b084997: the Promise.all now drains proc.stderr.text() and the test asserts stderr is empty, so a regressed build surfaces the assertion dump in the failure output. The review ran against the initial commit.

@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 — the stderr-drain nit from my earlier pass is now addressed in the current diff (b084997), and the C++ change is the textbook DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION pattern.

Checked: toNumber/toString are the only fallible calls in this function (the BooleanObject/BigIntObject arm reads internalValue() directly, no JS re-entry). Returning {} on exception satisfies the zero-is-throw contract enforced by from_js_host_call on the Rust side. The subprocess test now drains stdout+stderr+exited concurrently and covers the String, Number, and space-argument paths.

Extended reasoning...

Overview

JSC__JSValue__unwrapBoxedPrimitive in src/jsc/bindings/bindings.cpp calls object->toNumber() / object->toString() on Number/String wrapper objects. Those can re-enter JS via Symbol.toPrimitive and throw. The old code returned the (garbage) result with an exception still pending; the Rust caller wraps this via host_fn::from_js_host_callcall_zero_is_throw, which asserts the return is empty iff an exception is pending, so debug builds hit releaseAssertNoException and abort. The fix declares a throw scope after the not-an-object early return and adds RETURN_IF_EXCEPTION(scope, {}) after each fallible call. The BooleanObject/BigIntObject branch uses internalValue() (a direct internal-slot read, cannot throw) and correctly needs no check.

A subprocess regression test in test/js/bun/yaml/yaml.test.ts exercises three routes into this binding: a boxed String at the top level, a boxed Number nested in an object, and a boxed String passed as the space argument (which YAMLObject.rs::Space::init also routes through unwrap_boxed_primitive). It asserts the exact TypeError messages on stdout, empty stderr, and exit 0.

Security risks

None. This tightens error propagation on an existing extern-C binding; no new user-controlled data flows are introduced.

Level of scrutiny

Low-to-medium. The C++ change is 6 lines applying the canonical JSC exception-check idiom that REVIEW.md mandates for every call that can enter JS. It matches WebKit's own unwrapBoxedPrimitive in JSONObject.cpp, whose callers do RETURN_IF_EXCEPTION immediately after — here the check just moves inside the extern-C boundary because the caller is Rust. All Rust call sites (YAMLObject.rs, TOMLObject.rs, JSON5Object.rs) already use ? on the JsResult, so the propagated error surfaces cleanly with no further changes needed.

Other factors

My previous inline comment (stderr piped but not drained) ran against the initial commit and was fixed in b084997 before that review posted; the current diff drains proc.stderr.text() in the Promise.all and asserts it empty, matching the harness template. CodeRabbit's identical finding is marked resolved. No open reviewer feedback remains. The test uses bunExe()/bunEnv/await using, asserts stdout before exitCode, and the PR description confirms it exits 134 on the unfixed build — so it fails for the right reason.

@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 — the stderr-drain nit from the earlier review is addressed in b084997.

What was reviewed:

  • DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION after toNumber/toString; empty return honors the zero-is-throw contract that from_js_host_callcall_zero_is_throw enforces on the Rust side, and every caller (YAMLObject.rs, TOMLObject.rs, JSON5Object.rs) already propagates with ?.
  • Boolean/BigInt branch reads internalValue() without re-entering JS, so no scope check needed there.
  • Subprocess test now drains stdout+stderr concurrently, asserts stderr empty and exit 0; covers String wrapper, Number wrapper nested in an object, and the space argument path.
Extended reasoning...

Overview

Two-file change: src/jsc/bindings/bindings.cpp adds a throw scope to JSC__JSValue__unwrapBoxedPrimitive and returns empty on exception from toNumber/toString (both of which can re-enter JS via Symbol.toPrimitive/valueOf/toString on wrapper objects). test/js/bun/yaml/yaml.test.ts gains a subprocess regression test exercising three routes into the binding.

Security risks

None. This tightens exception handling on an existing extern-C binding; no new attack surface, no auth/crypto/permissions.

Level of scrutiny

Low-to-medium. The change is the canonical JSC exception-check pattern (DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION) applied to a copy of WebKit's own unwrapBoxedPrimitive. I verified the Rust wrapper unwrap_boxed_primitive in src/jsc/JSValue.rs:2298 routes through host_fn::from_js_host_callcall_zero_is_throw, so an empty encoded return correctly surfaces as Err(JsError). All call sites in YAMLObject.rs, TOMLObject.rs, and JSON5Object.rs use ?, so the error propagates cleanly. The BooleanObject/BigIntObject branch uses JSWrapperObject::internalValue(), which is a direct field read and cannot throw, so leaving it uncovered by the scope is correct.

Other factors

My prior review flagged that the test piped stderr without reading it; commit b084997 fixed that — the test now drains proc.stderr.text() in the Promise.all and asserts it is empty, matching the harness template. Both the coderabbit and my prior inline comments are marked resolved. The PR includes gate evidence showing the test fails (exit 134 / assertion abort) on the unfixed debug build and passes on the fixed build across debug+ASAN and release. The test asserts exact error messages and covers the String, Number, and space-argument variants. Small, mechanical, well-tested fix following an established pattern.

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

On the red CI: the two failures in build 89525 are worker terminate tests on the debian 13 x64-asan lane (worker-transfer-terminate-stress.test.ts and test-worker-message-port-transfer-terminate.js), both hitting an assertNoException abort during worker teardown. The previous build 89499 ran the identical tree and that lane passed, and this PR's change is only reachable through YAML/TOML/JSON5 stringify, so they look like a preexisting race on main. Both are reported for triage. The other 193 jobs passed, including the YAML suites.

@dylan-conway
dylan-conway enabled auto-merge (squash) August 6, 2026 08:40

@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 — the stderr-drain nit from the earlier pass is addressed.

What was reviewed:

  • RETURN_IF_EXCEPTION placement after toNumber/toString and that returning {} matches the from_js_host_call zero-is-throw contract on the Rust side.
  • Confirmed all Rust callers (YAMLObject.rs, TOMLObject.rs, JSON5Object.rs) propagate via ?, so the empty return surfaces as a JS exception everywhere.
  • Boolean/BigInt branch uses internalValue() which cannot re-enter JS, so no scope check needed there.
  • Subprocess test now drains stdout+stderr concurrently, asserts exact error messages for String/Number/space paths, and checks exit 0.
Extended reasoning...

Overview

The PR fixes a fuzzer-found abort in JSC__JSValue__unwrapBoxedPrimitive (src/jsc/bindings/bindings.cpp). When a boxed String/Number has a Symbol.toPrimitive that throws (e.g. returns an object), toString/toNumber leave a pending exception; the old code returned a value anyway, violating the zero-is-throw contract enforced by host_fn::from_js_host_call and tripping releaseAssertNoException in debug builds. The fix adds a DECLARE_THROW_SCOPE and RETURN_IF_EXCEPTION(scope, {}) after each throwing call. A subprocess regression test in test/js/bun/yaml/yaml.test.ts covers String, Number, and the space argument path.

Security risks

None. This tightens error propagation on an existing code path; no new input parsing, no auth/crypto/permissions surface. The change turns a debug-build abort (and a silently-dropped exception in release) into a proper JS TypeError.

Level of scrutiny

Low-medium. The C++ change is 9 lines applying the canonical JSC exception-check pattern (DECLARE_THROW_SCOPE + RETURN_IF_EXCEPTION) that appears throughout bindings.cpp. I verified the Rust side: unwrap_boxed_primitive wraps the FFI call in from_js_host_call (call_zero_is_throw), so an encoded-empty return becomes Err(JsError), and every call site in YAMLObject.rs/TOMLObject.rs/JSON5Object.rs uses ?. The BooleanObject/BigIntObject branch reads internalValue() directly and cannot re-enter JS, so it correctly needs no check.

Other factors

  • The one review nit (stderr piped but not drained) from both CodeRabbit and my earlier pass was fixed in b084997 — the diff now shows Promise.all([stdout, stderr, exited]) with expect(stderr).toBe("").
  • PR evidence shows the test fails (exit 134) on the unfixed debug+ASAN build and passes on the fixed build, satisfying the fails-without-fix requirement.
  • The two red CI jobs are unrelated worker-terminate flakes on debian-13 asan that passed on the identical tree in the prior build; this change is only reachable via YAML/TOML/JSON5 stringify.

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.

2 participants