Skip to content

Fix debug assertion when a BunString error message is empty - #40556

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/dbc0999e/empty-error-message
Sep 29, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/dbc0999e/empty-error-message

Conversation

@robobun

@robobun robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a debug assertion failure in BunString__toErrorInstance (src/jsc/bindings/BunString.cpp). The fuzzer found it with expect.unreachable("").

ASSERTION FAILED: !message.isEmpty()
vendor/WebKit/Source/JavaScriptCore/runtime/Error.cpp(41) : JSObject *JSC::createError(JSGlobalObject *, const String &, ErrorInstance::SourceAppender)

BunString__toErrorInstance is the shared path for String::to_error_instance and its TypeError, SyntaxError, and RangeError siblings. It called JSC::createError and friends. Those JSC helpers assert that the message is not empty. An empty BunString converts to a null WTF::String, so any caller that forwards an empty string aborts a debug build. expect.unreachable(reason) forwards the user's string as is, so expect.unreachable("") reached the assertion.

The fix constructs the ErrorInstance directly, with the error structure for the requested type. JSC::createError(globalObject, message) is exactly ErrorInstance::create(vm, globalObject->errorStructure(type), message, JSValue(), nullptr, TypeNothing, type, true) plus the assertion, so the result is the same object for a non-empty message. The napi bindings already use this pattern for the same reason (createErrorWithCode in src/jsc/bindings/napi.cpp).

Release builds do not compile the assertion, so their behavior does not change. expect.unreachable("") throws an UnreachableError with an empty message there before and after this change.

How did you verify your code works?

  • Reproduced the abort with the debug fuzz build: Bun.jest().expect.unreachable("").
  • With the fix, the same script throws UnreachableError and exits with code 1. The debug build now matches the release build for an empty, a custom, and an omitted message.
  • Added a test to test/js/bun/test/expect-unreaachable.test.ts. On the unfixed debug build the test process aborts with the assertion. With the fix it passes (bun bd test). The release build has no assertions, so the test also passes with USE_SYSTEM_BUN=1. The failure is only visible in a debug build.
  • bun bd test test/js/bun/test/expect.test.js: 415 pass, 0 fail.
  • Ran the regression test under BUN_JSC_validateExceptionChecks=1: pass.
  • Ran a Worker whose script has a parse error, on the release build and on the fixed debug build. The error event is the same on both: message is SyntaxError: Unexpected ; and error instanceof SyntaxError is false. I did not confirm that this run reaches to_syntax_error_instance, so it is not coverage of the SyntaxError kind. Only the Error kind is exercised, through expect.unreachable.

[auto-merge] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/expect-unreaachable.test.ts
bun test v1.4.3 (367d939d9)

test/js/bun/test/expect-unreaachable.test.ts:
(pass) expect.unreachable() [33.32ms]
26 |     stderr: "pipe",
27 |   });
28 | 
29 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
30 | 
31 |   expect(stdout).toBe('{"name":"UnreachableError","message":"","isError":true}\n');
                      ^
error: expect(received).toBe(expected)

- "{"name":"UnreachableError","message":"","isError":true}
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-unreaachable.test.ts:31:18)
(fail) expect.unreachable('') throws an UnreachableError with an empty message [631.99ms]

 1 pass
 1 fail
 5 expect() calls
Ran 2 tests across 1 file. [3.70s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.3-canary.1 (367d939d9)

test/js/bun/test/expect-unreaachable.test.ts:
(pass) expect.unreachable() [0.12ms]
(pass) expect.unreachable('') throws an UnreachableError with an empty message [8.73ms]

 2 pass
 0 fail
 7 expect() calls
Ran 2 tests across 1 file. [69.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/expect-unreaachable.test.ts
bun test v1.4.3 (367d939d9)

test/js/bun/test/expect-unreaachable.test.ts:
(pass) expect.unreachable() [49.99ms]
(pass) expect.unreachable('') throws an UnreachableError with an empty message [430.10ms]

 2 pass
 0 fail
 7 expect() calls
Ran 2 tests across 1 file. [3.00s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1064ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/25] cxx obj/src/jsc/bindings/BunObject.cpp.o
[2/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[3/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[4/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[5/25] gen cpp.rs (cppbind)
[6/24] rustc bun_install 
[7/24] rustc bun_jsc 
[8/24] rustc bun_ast_jsc 
[9/24] rustc bun_sys_jsc 
[10/24] rustc bun_bundler_jsc 
[11/24] rustc bun_patch_jsc 
[12/24] rustc bun_semver_jsc 
[13/24] rustc bun_css_jsc 
[14/24] rustc bun_js_parser_jsc 
[15/24] rustc bun_sourcemap_jsc 
[16/24] rustc bun_install_jsc 
[17/24] rustc bun_http_jsc 
[18/24] rustc bun_sql_jsc 
[19/24] rustc bun_runtime 
[20/24] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-564ac2a6cad8da6a-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102508190)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Li
... (truncated)
diff hotspot
src/jsc/bindings/BunString.cpp               | 12 +++++++-----
 test/js/bun/test/expect-unreaachable.test.ts | 25 +++++++++++++++++++++++++
 2 files changed, 32 insertions(+), 5 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                                          reads  edits  tests
src/jsc/bindings/BunString.cpp                    1      1     12
test/js/bun/test/expect-unreaachable.test.ts      2      2     12

…assertion

`BunString__toErrorInstance` called `JSC::createError` and its siblings.
Those helpers assert that the message is not empty. An empty `BunString`
converts to a null `WTF::String`, so `expect.unreachable("")` hit the
assertion in debug builds.

Construct the `ErrorInstance` directly with the structure for the error
type, as the napi bindings already do. An empty message now produces the
same error object that release builds already produce.
@robobun

robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:16 AM PT - Sep 22nd, 2026

✅ @robobun, your commit c9e280c3ead8c2a7b3fad7986c851665b03528f7 passed in Build #119646! 🎉


🧪   To try this PR locally:

bunx bun-pr 40556

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

bun-40556 --bun

@coderabbitai

coderabbitai Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a2364cda-e529-4548-b540-d0dc88c42299

📥 Commits

Reviewing files that changed from the base of the PR and between 368be82 and c9e280c.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunString.cpp
  • test/js/bun/test/expect-unreaachable.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

BunString__toErrorInstance now maps error kinds to JSC error types and creates errors with ErrorInstance::create. A subprocess test verifies expect.unreachable("") preserves the error name and empty message without stderr output.

Changes

Bun error construction

Layer / File(s) Summary
Error type mapping and validation
src/jsc/bindings/BunString.cpp, test/js/bun/test/expect-unreaachable.test.ts
BunString__toErrorInstance maps each BunErrorKind to a JSC::ErrorType and uses ErrorInstance::create. The subprocess test verifies the empty message, error identity, empty stderr, and zero exit code.

Suggested reviewers: dylan-conway, alii

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to c9e28

The fix preserves error types and empty messages without the debug assertion, and the regression test confirms successful behavior.

🚥 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 identifies the primary change: fixing a debug assertion caused by an empty BunString error message.
Description check ✅ Passed The description includes the required sections, explains the cause and fix, and documents verification results across debug and release builds, including the added regression test.

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

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review reported no actionable findings, so the diff stays as is. Buildkite build #106275 for 368be82 is still running. The find-issues workflow check is red because its setup step failed. That is not related to this change.

Comment thread test/js/bun/test/expect-unreaachable.test.ts Outdated
@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review note on the test. The empty-message case now runs as a spawned fixture in 4986cb8: the test spawns bun -e with Bun.jest(import.meta.path).expect.unreachable("") and checks the child's stdout and exit code.

Checked both directions with the debug build:

  • On the unfixed debug binary, the child aborts with the assertion. The test fails with an empty stdout, and the sibling test in the file still runs and passes.
  • With the fix, the child prints {"name":"UnreachableError","message":"","isError":true} and exits 0.

The release build has no assertions, so the test passes there before and after the fix.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

No new review findings on 4986cb8. The only review thread (the spawned fixture note) is addressed and now shows as outdated. Buildkite build #106277 for this commit is in progress. I will follow up if it reports a failure.

@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 — thanks for moving the repro into a spawned subprocess.

What was reviewed:

  • BunString__toErrorInstance now maps BunErrorKind → JSC::ErrorType and calls ErrorInstance::create directly; matches the existing createErrorWithCode pattern in napi.cpp for the same reason.
  • Test spawns bunExe() -e, drains stdout/stderr/exited concurrently, and asserts output before exit code — a regressed debug assertion aborts the child, not the runner.
  • No CODEOWNERS entries cover the changed paths.
Extended reasoning...

Overview

The PR changes one function in src/jsc/bindings/BunString.cpp: BunString__toErrorInstance previously dispatched to JSC::createError / createTypeError / createSyntaxError / createRangeError, all of which ASSERT(!message.isEmpty()) in debug builds. It now maps the BunErrorKind enum to a JSC::ErrorType and constructs the error via JSC::ErrorInstance::create(vm, globalObject->errorStructure(type), message, ...). A regression test was appended to test/js/bun/test/expect-unreaachable.test.ts.

Security risks

None. This is a debug-assertion fix in the error-instance construction path. No untrusted input parsing, no auth/crypto, no allocation-size arithmetic changed. Release-build behavior is byte-identical (the removed assertion compiles out in release).

Level of scrutiny

Low-to-moderate. The C++ change is ~10 lines, mechanical, and mirrors an existing in-tree precedent: createErrorWithCode in src/jsc/bindings/napi.cpp already calls ErrorInstance::create directly with the comment "we don't call JSC::createError() as it asserts the message is not an empty string". All four BunErrorKind cases are handled; the default initializer ErrorType::Error is dead but harmless.

Other factors

My earlier inline comment asked for the crashing input to run in a spawned fixture rather than in-process (per REVIEW.md: "Crash fixes need the crashing input as a spawned fixture"). Commit 4986cb8 addressed this: the test now spawns [bunExe(), "-e", ...] with bunEnv, drains all three pipes via Promise.all, and asserts stdout/stderr before exitCode. If the assertion ever regresses, the child aborts and the test fails on the empty stdout / non-zero exit code without taking down the test runner. The PR author acknowledged that release builds pass this test regardless (no assertions compiled in) — that's inherent to a debug-only crash fix and the test still guards the debug CI lane.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. CI for 4986cb8 is still in progress on #106277: 28 jobs passed, none failed so far. One job shows a retry. test/cli/install/bun-add.test.ts timed out on a git-over-SSH case on debian 13 aarch64 and passed on the retry. That test clones from GitHub over SSH and is not related to this change.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

The fuzzer reported this assertion again on a newer main (cf71211). It is the same call, expect.unreachable(""), and the same path through BunString__toErrorInstance. Main does not have this fix yet.

I merged main into the branch (7a0f00c) so that CI runs against the current tree. The merge had no conflicts, and the diff against main is still the same two files. Results on the merged tree with a debug build:

  • The new fuzzer script throws UnreachableError and exits with code 1. It no longer aborts.
  • bun bd test test/js/bun/test/expect-unreaachable.test.ts: 2 pass.
  • On an unfixed debug build of current main, the spawned test fails and the sibling test passes.

The previous CI run was green. This PR is ready for a maintainer review.

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

break;
}
// Not JSC::createError(): it asserts the message is not empty, and `new Error("")` is valid.
JSC::JSObject* result = JSC::ErrorInstance::create(globalObject->vm(), globalObject->errorStructure(type), message, JSValue(), nullptr, JSC::TypeNothing, type, true);

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.

🟣 pre-existing, not blocking: pre-existing: V8 addons passing an empty message still abort a debug build, since only this one caller was moved off JSC::createError. src/jsc/bindings/v8/V8Exception.cpp:15 and :25 forward a user-supplied v8::String straight into JSC::createError / JSC::createTypeError, which carry the same ASSERT(!message.isEmpty()). Fix: route every user-string-to-error site through one shared helper that calls JSC::ErrorInstance::create directly (as BunString.cpp:190 now does), which covers the 3 sites listed. Same pattern at 3 sites (src/jsc/bindings/BunString.cpp:190, src/jsc/bindings/v8/V8Exception.cpp:15, src/jsc/bindings/v8/V8Exception.cpp:25).
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A V8-API native addon calls v8::Exception::Error or v8::Exception::TypeError with an empty v8::String (e.g. v8::String::NewFromUtf8(isolate, "")). src/jsc/bindings/v8/V8Exception.cpp:14 converts it to an empty WTF::String and src/jsc/bindings/v8/V8Exception.cpp:15 (and :25 for TypeError) passes it to JSC::createError / JSC::createTypeError, which assert !message.isEmpty() in debug builds. A debug or fuzz build aborts with the same ASSERTION FAILED: !message.isEmpty() the PR fixes for BunString__toErrorInstance; release builds are unaffected. The base branch behaves the same at these sites; this PR fixes only the BunString.cpp:190 caller and leaves the sibling sites that share the identical pattern unfixed, so the bug class remains reachable from user-controlled addon input.

Verification: pre-existing (debug/fuzz builds only; the base branch already aborts by the same route in untouched code). Triggering condition: a V8-API native addon calls v8::Exception::Error or v8::Exception::TypeError with an empty v8::String. Mechanism verified: /home/claude/bun/src/jsc/bindings/v8/V8Exception.cpp:14-15 does `WTF::String wtfMessage = message->localToJSString()->value(globalObject);…

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the V8 note: I read src/jsc/bindings/v8/V8Exception.cpp, and lines 15 and 25 do forward the addon's string to JSC::createError and JSC::createTypeError. I did not reproduce it, because that needs a native addon on a debug build. This PR does not change those sites. The fuzzer cannot reach them from JavaScript, so I passed them on as a separate task.

I also corrected the verification list in the description. It said that a Worker parse error run checked the to_syntax_error_instance path. That run gave the same output on the release build and the fixed debug build, but I did not confirm that it reaches that function. The description now says so. The test in this PR exercises the Error kind only.

@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Build #119614 has one failed test: test/js/bun/spawn/spawn.test.ts ("an idle reader stopped at the highwater") on debian 13 x64-asan. This PR does not touch spawn code. The same test fails on the same lane in the final builds of two recently merged PRs: #119546 for #43755 and #119541 for #43752. I reported it as a failure on main.

The other annotations in the build passed on a retry.

The regression test ran on the x64-asan lane, which builds with assertions on (scripts/build/config.ts). On 7a0f00c it reports 2 pass, 0 fail (job log, [3/300] test/js/bun/test/expect-unreaachable.test.ts). I did not read the logs of the other lanes. The file is not in the failure annotations of the build.

Edit: an earlier version of this comment said the test passed on every lane that ran it. I had checked only the failure list at that point, so I replaced that sentence with what I verified.

@Jarred-Sumner
Jarred-Sumner merged commit c393d2e into main Sep 29, 2026
7 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/dbc0999e/empty-error-message branch September 29, 2026 22:05
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