Skip to content

Always create the error when a native error message exceeds the string length limit - #39481

Open
robobun wants to merge 1 commit into
mainfrom
farm/17c6b3de/error-message-too-long
Open

robobun wants to merge 1 commit into
mainfrom
farm/17c6b3de/error-message-too-long

Conversation

@robobun

@robobun robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A native error whose formatted message is longer than the string length limit is never created. In a release build a failing expect() whose failure message is over the limit reports as (pass); a debug build panics with assertion failed: self.has_exception() in JSGlobalObject::throw (src/jsc/JSGlobalObject.rs:815 on main), reached from Expect::throw_rendered.
  • Same cause, different callers: Bun.listen({ unix }) with a path whose "Failed to listen at ..." message is over the limit segfaults in release (Segmentation fault at address 0x5) and trips UBSan in debug (bindings.cpp: member call on null pointer of type 'JSC::JSCell'), because src/runtime/socket/Listener.rs:504 creates the error and then puts syscall/errno/address on it. Bun.JSONC.parse of a ~1 MiB token throws the empty value itself (debug aborts on ASSERT(!value.isEmpty()) in JSC__VM__throwError).
  • Cause: BunString__toErrorInstance (src/jsc/bindings/BunString.cpp:144 on main), the one C++ entry behind create_error_instance, EncodedSlice::to_*_error_instance and String::to_*_error_instance for all four error kinds, converts the message with Zig::toStringCopy / toWTFString, which return a null WTF::String when the text is longer than Bun__stringSyntheticAllocationLimit / WTF::String::MaxLength (or the copy fails to allocate). It then returned an empty JSValue. Nothing on that path throws, so JSGlobalObject::throw turned it into JsError::Thrown with no exception pending, and native code unwound as if it had thrown while JS observed nothing. The other callers (dozens of sites: promise rejections, callbacks, put on the result) assume they got an object.
  • Reachable in practice through the 1 MiB floor of setSyntheticAllocationLimitForTesting (tests using it could silently pass) and, without it, through any message over 2^31 characters (expect(<happy-dom Element>).toBeNull() takes ~40s+ on a large tree and then SILENTLY PASSES (throw is lost) #37310 hit this with a rendered happy-dom tree).

Fix

  • BunString__toErrorInstance always creates the error. When the message conversion came back null for a non-empty (or Dead, i.e. already failed to allocate) source, the error carries "The error message exceeds the maximum string length" instead. Name, type and the properties callers add afterwards (code, errno, syscall, ...) are unaffected; a message under the limit is byte-for-byte unchanged.
  • Why a stand-in message rather than throwing or truncating: this is a constructor, not a throwing entry point, and its callers unconditionally throw, reject with, or decorate the result, so the only contract that makes every caller correct is "an error object always comes back" (the reasoning ErrorCodeCache::createError already documents for the ErrorCode path). Truncating here would mean materializing a message of up to 2 GiB; bounding rendered values belongs to the producer (test runner: stop printing shared references without bound in assertion diffs and snapshots #34179 and Cap expect() failure message rendering so huge values cannot swallow the assertion error #37311 do that for expect() and remain useful independently; with this change the throw()-site fallback in Cap expect() failure message rendering so huge values cannot swallow the assertion error #37311 becomes unreachable). For comparison, Node surfaces the analogous case (an assert message that overflows V8's string limit) as RangeError: Invalid string length, so the failure is still reported there as well; here the original error type and properties are kept.
  • JSGlobalObject.rs: the is_empty() / debug_assert!(has_exception()) branches in throw, throw_sys_error and throw_todo are deleted; the constructor can no longer return an empty value. JSC__VM__throwError still asserts a non-empty value in debug builds if that ever regresses.
  • Intentionally not changed: systemErrorToErrorInstance, Bun__createErrorWithCode and the two createAggregateError helpers already always return an object, and their messages are WTF-backed strings built by BunString::create_format, so a missing message there needs a Dead string, i.e. a message over 2^31 characters, which the testing limit cannot reach. EncodedSlice__toDOMExceptionInstance is also left alone: createDOMException gives an empty message its own meaning per exception code (default texts; InvalidURLError stores the message as .input), so a stand-in string would be wrong there.
  • Test: test/js/bun/util/native-error-message-too-long.test.ts, one subprocess per case since the limit is process-wide and the unfixed cases crash:
    • expect(): a failure message of exactly the limit is kept intact, one character past it gets the fallback, a rendered received value past the limit gets the fallback, and an uncaught one is reported by bun test as (fail) with error: The error message exceeds the maximum string length (exit code 1).
    • Bun.listen throws the Error instead of crashing.
    • Bun.JSONC.parse throws a SyntaxError with the fallback; a normal-sized parse error message is unchanged.
  • Verified: every case fails on main in a debug build without the src/ changes (UBSan null member call, abort, panic) and on the last release canary, and all pass with them. Also ran expect-label, expect-unreaachable, plugins.test.ts, socketaddress.spec.ts (covers throw_sys_error), jsonc, error-name-preservation: all pass.

Background

  • BunString / bun_core::String: the tagged string shared between Rust and C++. A WTFStringImpl tag shares an existing JSC string, EncodedSlice borrows Rust bytes (with encoding bits in the pointer) and is copied on the way in, Dead means a creation already failed. create_error_instance and friends format a Rust message into a buffer and hand it over as a UTF-8 EncodedSlice.
  • String length limit: WTF::String::MaxLength is 2^31 - 1 code units. Bun__stringSyntheticAllocationLimit is a process-wide lower cap that bun:internal-for-testing's setSyntheticAllocationLimitForTesting can set down to 1 MiB so tests can exercise the too-long paths without allocating gigabytes; Zig::toStringCopy honours both for EncodedSlice input.
  • JsError::Thrown: the Rust-side marker meaning "a JS exception is now pending in the VM"; host functions return it and the thunk hands control back to JSC expecting vm.exception() to be set. Returning it with nothing pending is how the throw was lost.
  • JSValue::empty() (Rust JSValue::ZERO): JSC's "no value" sentinel, not a JS value; using it as one (put, throwing it, awaiting it) dereferences null.
Notes

The first revision of this PR predates the consolidation of the error constructors into BunString__toErrorInstance (the ZigString rename and the removal of Zig::get*ErrorInstance). It fixed the same defect in helpers.h for the four per-type helpers plus createAggregateError, whose message then arrived as a ZigString and was reachable through the testing limit; on current main that message is a WTF-backed string and the site is covered by the exclusion above. The branch was rebuilt on top of main rather than rebased, so the diff is the one described here.


[review] gate passed · iteration 2 · 3 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/native-error-message-too-long.test.ts
bun test v1.4.1 (861e9ae04)

test/js/bun/util/native-error-message-too-long.test.ts:
106 |         `,
107 |       },
108 |       "listen.ts",
109 |     );
110 | 
111 |     expect(report).toEqual({ name: "Error", message: FALLBACK });
                         ^
error: expect(received).toEqual(expected)

  {
-   "message": "The error message exceeds the maximum string length",
-   "name": "Error",
+   "exitCode": 1,
+   "stderr": 
+ "../../src/jsc/bindings/bindings.cpp:4169:70: runtime error: member call on null pointer of type 'JSC::JSCell'
+ SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ../../src/jsc/bindings/bindings.cpp:4169:70 
+ "
+ ,
+   "stdout": "",
  }

- Expected  - 2
+ Received  + 7

      at <anonymous> (/workspace/bun/test/js/bun/util/native-error-message-too-long.test.ts:111:20)
(fail) native error whose message exceeds the string length limit > Bun.listen, which sets properties on the error before throwing it, throws instead of crashing [2227.49ms]
122 |    
... (truncated)

release without fix: 3 FAILED
bun test v1.4.1-canary.1 (861e9ae04)

test/js/bun/util/native-error-message-too-long.test.ts:
122 |         `,
123 |       },
124 |       "jsonc.ts",
125 |     );
126 | 
127 |     expect(report).toEqual([
                         ^
error: expect(received).toEqual(expected)

  [
    {
-     "message": "The error message exceeds the maximum string length",
+     "message": "",
      "name": "SyntaxError",
    },
    {
      "message": "JSONC Parse error: Unexpected bbb",
      "name": "SyntaxError",
    },
  ]

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/util/native-error-message-too-long.test.ts:127:20)
(fail) native error whose message exceeds the string length limit > SyntaxError from Bun.JSONC.parse keeps its type and gets the fallback message [45.31ms]
106 |         `,
107 |       },
108 |       "listen.ts",
109 |     );
110 | 
111 |     expect(report).toEqual({ name: "Error", message: FALLBACK });
                         ^
error: expect(received).toEqual(expected)

  {
-   "message": "The error message exceeds the maximum string length",
-   "name": "Error",
+   "exitCode": 139,
+   "stderr": 
+ "===========================
... (truncated)
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/mechgate.xml" test/js/bun/util/native-error-message-too-long.test.ts
bun test v1.4.1 (861e9ae04)

test/js/bun/util/native-error-message-too-long.test.ts:
(pass) native error whose message exceeds the string length limit > Bun.listen, which sets properties on the error before throwing it, throws instead of crashing [1783.80ms]
(pass) native error whose message exceeds the string length limit > SyntaxError from Bun.JSONC.parse keeps its type and gets the fallback message [1890.52ms]
(pass) native error whose message exceeds the string length limit > failing expect() throws an Error and bun test reports the failure [3153.53ms]

 3 pass
 0 fail
 10 expect() calls
Ran 3 tests across 1 file. [6.27s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     732b669b83
  features     baseline

23 deps, 129 codegen, 1172 objects in 968ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (861e9ae04)

Checked 26 installs across 63 packages (no changes) [6.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] gen bindgenv2
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (861e9ae04)

Checked 1 install across 2 packages (no changes) [3.00ms]
[5/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (861e9ae04)

Checked 111 installs across 104 packages (no changes) [9.00ms]
[6/1244] gen .bind.ts → GeneratedBindings.cpp
[7/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1217] gen node-fallbacks/react-refresh.js
Bundled 1 module in 16ms

  react-refresh.js  4.81 KB  (entry point)

[9/1217] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindin
... (truncated)
diff hotspot
src/jsc/JSGlobalObject.rs                          |  15 +--
 src/jsc/bindings/BunString.cpp                     |   6 +-
 .../bun/util/native-error-message-too-long.test.ts | 133 +++++++++++++++++++++
 3 files changed, 136 insertions(+), 18 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                    reads  edits  tests
src/jsc/JSGlobalObject.rs                                   7      8      0
src/jsc/bindings/BunString.cpp                              3      1      0
test/js/bun/util/native-error-message-too-long.test.ts      4      8      0

@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

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: d8521304-6f99-4c47-a6f1-3197f69a5b79

📥 Commits

Reviewing files that changed from the base of the PR and between ff5fc1f and fe5857a.

📒 Files selected for processing (4)
  • src/jsc/JSGlobalObject.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/helpers.h
  • test/js/bun/util/native-error-message-too-long.test.ts

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

Changes

Native error fallback handling

Layer / File(s) Summary
Centralize typed error construction
src/jsc/bindings/helpers.h, src/jsc/bindings/bindings.cpp, src/jsc/JSGlobalObject.rs
Error constructors use typed, fallback-aware message creation. AggregateError and throw paths no longer retain empty-error checks.
Validate length-limit behavior
test/js/bun/util/native-error-message-too-long.test.ts
Subprocess tests cover exact-limit and over-limit messages, error metadata, reporting, exit codes, and crash prevention.

Suggested reviewers: jarred-sumner

🚥 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 describes the main change: native errors are always created when messages exceed the string length limit.
Description check ✅ Passed The description provides detailed problem, fix, scope, testing, and verification information. It does not use the exact template headings, but it covers the required content and is complete.

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

@robobun

robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebuilt on current main (732b669), waiting on CI.

Main consolidated the error constructors into BunString__toErrorInstance while this PR was open, so the branch was recreated on top of main instead of rebased: the fix now lives in that single entry point (the helpers.h and createAggregateError hunks of the first revision no longer exist on main; createAggregateError now gets a WTF-backed message that the testing limit cannot affect, so that case is gone from the test). The behaviour is the same: the error is always created, with The error message exceeds the maximum string length when the real message cannot become a string.

Reproduced on main with setSyntheticAllocationLimitForTesting(1 MiB):

  • expect(() => big).toThrow() with a ~1 MiB big: release reports (pass); debug panics with assertion failed: self.has_exception() in JSGlobalObject::throw.
  • Bun.listen({ unix: <~1 MiB path> }): release segfaults, debug trips UBSan (property put on the empty value).
  • Bun.JSONC.parse(<~1 MiB token>): the empty value is thrown; debug aborts in JSC__VM__throwError.

test/js/bun/util/native-error-message-too-long.test.ts fails on main for each case (debug build without the src/ changes) and passes with them.

@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 fix that guarantees the four get*ErrorInstance constructors always return an object, replacing an over-limit message with a fallback string.

What was reviewed:

  • errorMessageOrFallback only affects the null-return edge case; normal-size messages pass through unchanged, so the hot path is byte-identical.
  • Verified create_error_instance's other path (JSC__createError via BunString::to_error_instance) already always returns an object, so the deleted is_empty() guards in throw/throw_sys_error/throw_todo are dead; JSC__VM__throwError still ASSERT(!value.isEmpty()) if that ever regresses.
  • Test covers the boundary (at-limit kept intact vs one-past → fallback), the crash case (Bun.listen puts properties on the result), and the sibling constructor (SyntaxError from Bun.JSONC.parse), each in its own subprocess since the limit is process-wide.
Extended reasoning...

Overview

Two production files. src/jsc/bindings/helpers.h gains errorMessageOrFallback(message, source), which returns "The error message exceeds the maximum string length" when toString/toStringCopy returned null for a non-empty source, and passes message through otherwise. All four get*ErrorInstance functions now route their message through it, so they always call JSC::create*Error with a valid WTF::String and always return an object. src/jsc/JSGlobalObject.rs deletes the three if err.is_empty() { debug_assert!(has_exception()); return JsError::Thrown } branches from throw, throw_sys_error, and throw_todo, which were the only Rust-side callers that special-cased the empty return (and whose debug_assert was wrong — nothing on that path threw). One new test file with three subprocess-isolated cases.

Security risks

None. This is error-construction plumbing; the only externally observable change is that an error whose message would exceed the string length limit now carries a stand-in message instead of coming back as an empty JSValue (crash / silent pass) or with no message property. No new user input is parsed and no security check is loosened.

Level of scrutiny

Medium. helpers.h is included widely and the four constructors have dozens of callers, but the change is additive on the [[unlikely]] null-string path only — for any message under the limit errorMessageOrFallback is an identity function, so normal error creation is behaviorally identical. The Rust deletions are dead code once the C++ invariant holds; I verified the other path into create_error_instance (BunString::static_str(...).to_error_instance → JSC__createError) unconditionally calls JSC::createError and never returned empty, and that JSC__VM__throwError retains ASSERT(!value.isEmpty()) as a debug backstop.

Other factors

The PR description enumerates the sibling constructors it intentionally left alone (JSC__createError & co., systemErrorToErrorInstance, Bun__createErrorWithCode, AggregateError, DOMException) with a reason for each, which matches what I found. The test file follows harness conventions (tempDir, bunEnv, Buffer.alloc(n, fill).toString(), describe.concurrent for independent subprocesses, drains stdout/stderr/exited concurrently, asserts stderr/stdout before exit code) and exercises the exact boundary. The author verified the test fails on main (release: silent pass / segfault 139; debug: UBSan + panic) and re-ran the affected suites (expect.test.js, socketaddress.spec.ts, jsonc.test.ts, etc.). No prior reviews on the timeline beyond a CodeRabbit rate-limit notice.

Comment thread src/jsc/bindings/helpers.h Outdated
Comment thread src/jsc/bindings/helpers.h Outdated

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

I reviewed this PR and didn't find any bugs. Because it changes the shared error-construction path in the JSC bindings (used by dozens of callers) and picks a specific fallback strategy, a human look would still be worthwhile.

What was reviewed:

  • Confirmed JSC::createError(JSGlobalObject*, ErrorType, const String&) is an existing overload already used at bindings.cpp:2586 and BunAnalyzeTranspiledModule.cpp:221, so the four consolidated helpers dispatch to the same constructors as before.
  • Traced create_error_instance's two paths (JSC__createError for BunString, ZigString__toErrorInstance → getErrorInstance) — both now always return an object, so the removed is_empty() guards in throw/throw_sys_error/throw_todo are dead.
  • Checked the source.len == 0 case: errorMessageOrFallback passes the null string through unchanged, matching the old behavior for empty messages.
  • Verified the "always return an object" contract matches the reasoning already documented in ErrorCodeCache::createError (ErrorCode.cpp:210-213).
Extended reasoning...

Overview

The PR fixes a crash / silent-failure when a natively formatted error message exceeds the string length limit (WTF::String::MaxLength or the lowered Bun__stringSyntheticAllocationLimit). Zig::toString/toStringCopy return a null WTF::String in that case; the old getErrorInstance turned that into an empty JSValue, which callers then threw (nothing pending → lost failure), put() on (null deref → segfault), or wrapped in an AggregateError with no message. The fix introduces errorMessageOrFallback, which substitutes a fixed message when a non-empty source produced a null conversion, and folds the four get*ErrorInstance helpers into one createErrorInstance(globalObject, ErrorType, message, source). createAggregateError gets the same fallback. The now-unreachable is_empty() guards in JSGlobalObject.rs's throw, throw_sys_error, and throw_todo are removed.

Security risks

None. This is error-message construction; no untrusted input is parsed differently, no allocation size is derived from external data, and the change strictly narrows the set of states callers can observe (from "object or empty JSValue" to "object").

Level of scrutiny

Moderate-to-high. The four get*ErrorInstance helpers back ZigString__to{,Type,Syntax,Range}ErrorInstance, which are called from create_*_error_instance and dozens of sites across the runtime. The refactor is a pure dedup — JSC::createError(JSGlobalObject*, ErrorType, const String&) is an existing WebKit overload already used in bindings.cpp:2586 and BunAnalyzeTranspiledModule.cpp:221 — and each wrapper keeps its original conversion (toString vs toStringCopy), so the only semantic change is in the previously-broken null-message case. The removed Rust guards were checking the wrong invariant (they debug_assert!(has_exception()) when nothing on the path throws). Still, this is the shared error path in the JSC FFI layer, and the choice of fallback strategy (fixed message vs throwing vs bounded truncation) is a design call a maintainer should ratify.

Other factors

The test file is thorough: one subprocess per case (limit is process-wide; unfixed Bun.listen crashes), covers at-limit / one-past-limit boundaries for both expect() and AggregateError, exercises Error, Error+put, SyntaxError, and AggregateError paths, and asserts bun test reports (fail) with exit 1 for the uncaught case. The PR description enumerates what was intentionally left unchanged (BunString constructors, DOMException) with reasons. The comment-cop feedback about the long code comment was addressed in c43e894. The description also lists a broad set of existing tests that were re-run.

@robobun
robobun force-pushed the farm/17c6b3de/error-message-too-long branch from c43e894 to a079c43 Compare August 24, 2026 05:00

@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 fix for a real crash / silent-pass bug, with the fallback applied at the shared constructor so every caller is covered.

What was reviewed:

  • errorMessageOrFallback gates on source.len > 0, so a legitimately empty ZigString still yields an empty-message error (unchanged from before); only the null-from-oversize case gets the fallback.
  • The four get*ErrorInstance wrappers keep their original conversions (toString vs toStringCopy), and EnsureStillAliveScope is preserved in the shared createErrorInstance.
  • The removed is_empty() branches in throw / throw_sys_error / throw_todo are dead now that both create_error_instance paths (JSC__createError and ZigString__toErrorInstance → getErrorInstance) always return an object.
  • Tests hit the exact-limit / one-past boundary and each constructor family (Error, SyntaxError, AggregateError, plus the put-on-result crash via Bun.listen), each in its own subprocess.
Extended reasoning...

Overview

The PR fixes native error construction when the formatted message exceeds Bun__stringSyntheticAllocationLimit / WTF::String::MaxLength. Previously Zig::getErrorInstance returned an empty JSValue in that case (comment claimed a pending exception, but nothing threw), and the sibling constructors passed a null WTF::String through, producing errors with no message. Downstream, JSGlobalObject::throw turned the empty value into JsError::Thrown with nothing pending (silent (pass) in release, debug panic), and callers that decorate the result (Bun.listen setting syscall/errno/address) segfaulted on the null cell.

The fix adds errorMessageOrFallback (substitutes a fixed message when a non-empty ZigString converted to a null WTF::String) and a shared createErrorInstance(globalObject, ErrorType, message, source) that the four get*ErrorInstance helpers now delegate to. JSC__JSGlobalObject__createAggregateError applies the same fallback to its message. The now-dead is_empty() / debug_assert!(has_exception()) branches in throw, throw_sys_error, and throw_todo are removed.

Security risks

None. This is error-message construction; the only user-controlled input is the length of a message that was already being formatted, and the change makes the oversize case more robust (an object with a short constant message instead of a null deref or a lost throw).

Level of scrutiny

Medium. helpers.h is included across the C++ bindings and these constructors sit under every Rust-side create_*_error_instance call, so the contract change ("always returns an object") is widely relied on. But the change is small and strictly widens the set of inputs that produce a valid error: the common path (message under the limit) is byte-for-byte unchanged, the empty-ZigString path is unchanged (source.len > 0 guard), and only the previously-broken oversize path gains behaviour. The consolidation into createErrorInstance preserves each wrapper's original conversion (toString for Error, toStringCopy for the typed errors) and keeps the EnsureStillAliveScope. JSC::createError(JSGlobalObject*, ErrorType, const String&) is the standard JSC dispatcher to the same per-type factories the old bodies called; CI (debug+ASAN and release) confirms it compiles and behaves.

Other factors

  • Tests are thorough: four subprocess cases (process-wide limit, and the unfixed Bun.listen case crashes) covering Error via expect() (at-limit kept intact, one-past gets fallback, uncaught reported as (fail) with exit 1), the put-on-result crash via Bun.listen, SyntaxError via Bun.JSONC.parse (plus a normal-sized message unchanged), and AggregateError via a data: import with two build errors (at-limit intact, one-past fallback, errors preserved either way). Each drains stdout/stderr/exited concurrently and asserts exit code last.
  • The describe.concurrent keeps the file under ~4.5s despite four subprocess spawns.
  • The github-actions comment-cop feedback about the long comment on errorMessageOrFallback was addressed (removed in c43e894); the fallback text itself states the condition.
  • CI is green on the head commit (Buildkite build 100497), and the PR body shows the test failing on main (release: message: "" for JSONC/AggregateError, segfault for Bun.listen/expect; debug: UBSan null member call / panic) and passing with the fix.

…g length limit

BunString__toErrorInstance returned an empty JSValue, without throwing, when
the message could not be converted to a WTF::String (longer than the synthetic
allocation limit or WTF::String::MaxLength). JSGlobalObject::throw then
returned JsError::Thrown with nothing pending, so a failing bun:test assertion
whose message was over the limit reported as passing, and callers that set
properties on the result before throwing it (Bun.listen) crashed.

The error is now always created; when the message cannot be materialized it
is replaced with "The error message exceeds the maximum string length". The
now-unreachable empty-instance checks in throw, throw_sys_error and
throw_todo are removed.
@robobun
robobun force-pushed the farm/17c6b3de/error-message-too-long branch from a079c43 to 732b669 Compare August 25, 2026 03:11

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

I reviewed this PR and didn't find any bugs. Because it changes the central native error-creation path (BunString__toErrorInstance) and removes the empty-value guards in throw/throw_sys_error/throw_todo based on the new invariant, a human look would still be worthwhile.

What was reviewed:

  • Traced every Rust caller whose guard is removed (EncodedSlice::to_error_instance, JSGlobalObject::error_instance) back to BunString__toErrorInstance, which now always returns a non-null object.
  • Checked the Dead / Empty tag handling in the new fallback condition — Empty still yields an error with no message (unchanged), Dead now gets the fallback instead of a null message.
  • Note: the PR description references helpers.h/bindings.cpp and an AggregateError test; the current diff (post-rebase onto the BunString__toErrorInstance consolidation) has 3 files / 3 tests, and JSC__JSGlobalObject__createAggregateError is unchanged. Worth confirming the AggregateError drop was intentional.
Extended reasoning...

Overview

This PR fixes a crash/silent-pass bug where a natively formatted error message exceeding the string-length limit caused BunString__toErrorInstance to return an empty JSValue. Callers that then put properties on it segfaulted (release) or hit UBSan (debug), and JSGlobalObject::throw returned JsError::Thrown with no exception pending, so a failing expect() reported as (pass). The fix substitutes a fallback message ("The error message exceeds the maximum string length") instead of returning {}, and removes the now-dead is_empty() guards in throw, throw_sys_error, and throw_todo. A new subprocess-based test exercises expect(), Bun.listen, and Bun.JSONC.parse at and past the limit.

Security risks

None identified. This is error-message construction; no untrusted-input parsing, auth, or crypto is touched. The change closes a null-deref crash reachable from user input (a very long string flowing into an error message), which is a robustness improvement.

Level of scrutiny

Medium-high. The source change is small (~6 lines in BunString.cpp, ~15 lines removed in JSGlobalObject.rs), but BunString__toErrorInstance is the single funnel for all four Rust create_*_error_instance helpers and EncodedSlice::to_*_error_instance, so every native throw goes through it. Removing defensive guards based on a newly-established invariant is exactly the kind of change REVIEW.md flags for careful auditing ("delete defensive code only when you can show the condition cannot occur"). I traced the callers and the invariant holds — all four JSC::create*Error overloads return non-null — but a maintainer should confirm.

Other factors

  • The evidence gate shows the new tests fail on main (segfault / UBSan / message: "") and pass with the fix, in both debug+ASAN and release.
  • The PR description is stale relative to the current diff: it describes changes to helpers.h (errorMessageOrFallback, createErrorInstance), a one-line bindings.cpp change for createAggregateError, and a fourth AggregateError test. After rebase onto main (where the get*ErrorInstance helpers were consolidated into BunString__toErrorInstance), the diff now only touches BunString.cpp and the test file has three tests. JSC__JSGlobalObject__createAggregateError still calls arg3->toWTFString() directly and would produce an empty-message AggregateError on overflow — not a crash, but the description claims it was fixed. A human should confirm whether dropping that piece was intentional.
  • The comment-cop bot's feedback about a paragraph-long comment in helpers.h is moot since helpers.h is no longer in the diff.

@robobun

robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

On the review note about the description: the body was rewritten for the rebuilt branch at the same time that review ran, so it now describes the current diff (BunString.cpp plus the three removed guards in JSGlobalObject.rs).

Dropping the AggregateError case was intentional. On current main create_aggregate_error builds its message with BunString::create_format, which produces a WTF-backed string that the testing limit does not apply to, so the only way its message can go missing is a Dead string from a message over 2^31 characters. That puts it in the same group as systemErrorToErrorInstance and Bun__createErrorWithCode, listed under "Intentionally not changed": those always return an error object and are not reachable through the testing limit. The details are in the Notes block at the end of the description.

Jarred-Sumner pushed a commit that referenced this pull request Sep 14, 2026
…ng length limit (#42202)

### Problem

- An error message that embeds a string from JS has a user-controlled
length. Building one past the string length limit aborts the process,
also inside `try`/`catch`: `panic(main thread): abort() called`, exit
code 134. Example: `bun -e 'try { Buffer.from("x", "q".repeat(2**31-10))
} catch {}'`.
- The cause: `WTF::makeString` and a default-constructed
`WTF::StringBuilder` call `CRASH()` when the result passes
`String::MaxLength`. Every message builder in
`src/jsc/bindings/ErrorCode.cpp` is one of the two.

### Fix

- `Bun::MessageBuilder` (`ErrorCode.h`) records the overflow. Every
builder in `ErrorCode.cpp` uses it, and `determineSpecificType` and
`JSValueToStringSafe` accept only that type. `createError` and
`throwError` report a message that did not fit as `RangeError: Out of
memory`.
- Five sinks outside `ErrorCode.cpp` build with `tryMakeString`: the
`performance.measure` mark, the `ReadableStream` source `type`, three
`MessageEvent` constructor messages.
- Correct because JSC reports `RangeError: Out of memory` for a string
it cannot create. Node v26.3.0 reports the same inputs as `RangeError:
Invalid string length`. A message under the limit is unchanged.
- Verified:
`test/js/bun/util/error-message-string-length-limit.test.ts`, 8 cases in
one child, which aborts on main. Also the buffer, streams, webcrypto and
MessageEvent suites. Self-reviewed: 7 concerns raised, 6 addressed, 1 is
a question for a maintainer (Notes).

### Background

- `String::MaxLength` is 2^31 - 1 code units. A JS string can be that
long.
- `StringBuilder` takes an `OverflowPolicy`. The default crashes.
`RecordOverflow` sets a flag that `hasOverflowed()` reports, and later
appends do nothing.
- `determineSpecificType` renders the `Received ...` part of
`ERR_INVALID_ARG_TYPE`. It appends `constructor.name` with no bound.
- JSC treats its own messages this way in `ExceptionHelpers.cpp`.

<details><summary>Notes</summary>

**The type guards its own reads.** `StringBuilder::toString()` and
`length()` assert that the builder did not overflow, so on
`MessageBuilder` they are private. `tryToString()` returns a null string
on overflow, and `finish(globalObject, scope)` throws. No
`hasOverflowed()` check is written by hand in `ErrorCode.cpp`.

**Question for a maintainer.** This PR reports an over-long message as
`RangeError: Out of memory` with no `.code`, like JSC and like Node.
#39481 (open) takes the other policy for the Rust-side error
constructor: it keeps the error type and properties and uses a stand-in
message. If that policy is preferred here too, the change is in two
functions: `createError(ErrorCode, MessageBuilder&)` and
`MessageBuilder::finish`.

**Moved out of this PR.** The first revision also changed
`process.execve` and `Error.stack`. Neither uses `MessageBuilder`, and
each has its own question, so each has its own PR:
- `process.execve`: #42308
- `Error.stack` and the default `Error.prepareStackTrace`: #42314

**Cases in the test.** All abort on main and on 1.4.2 with exit code
134. With this branch each one throws `RangeError: Out of memory`.

```js
const long = "q".repeat(2 ** 31 - 10);
class C {}
Object.defineProperty(C, "name", { value: long });

Buffer.from("x", long);                                   // UNKNOWN_ENCODING
Buffer.from(new C());                                     // determineSpecificType
Buffer.alloc(1).indexOf(new C());                         // JSBuffer.cpp message
new SocketAddress({ address: "1.2.3.4", port: new C() }); // Bun__ErrorCode__determineSpecificType, the entry Rust validators call
performance.measure("m", long);                           // PerformanceUserTiming.cpp
crypto.subtle.importKey(long, new Uint8Array(8), "AES-GCM", false, ["encrypt"]); // JSSubtleCrypto.cpp
new ReadableStream({ type: long });                       // JSReadableStream.cpp
ReadableStream.from(Symbol(long));                        // throwNotIterable
```

**Verified by hand, not in the test.**
- The three `MessageEvent` messages: `new MessageEvent("m", { ports:
long })`, `{ ports: [long] }`, `{ source: long }`. Each aborts on main
and throws with this branch. They render the value through the inspector
first, which takes about 20 s in a release build and about 9 minutes in
a debug ASAN build for a 2 GiB string, so they cannot be a test.
- The quoted `ERR_INVALID_ARG_VALUE` path (`Buffer.alloc(4).fill(long,
"hex")`), `ERR_OUT_OF_RANGE` with a long name, and
`NodeError.prototype.toString` with a long message throw with this
branch. The quoted path appends one character at a time, about 8 minutes
in a debug build.
- The doors reported in the comments below (`Buffer.byteLength`,
`fs.mkdirSync` mode, `process.kill` signal name, `child_process.spawn`)
all go through `MessageBuilder`.
- The whole fixture also passes under
`BUN_JSC_validateExceptionChecks=1`.

**Scope.** This does not cover every `makeString` call that can reach an
error sink. A census at `4ff91937` counted 276 `makeString(` calls and
110 default-constructed `StringBuilder` locals under `src/jsc`. The
`makeString` calls left in `ErrorCode.cpp` take an `ASCIILiteral` or an
internal string, so the binary bounds their length. A different
mechanism is also out of scope: `String::utf8()` aborts for a Latin-1
string of more than about 2^30 characters, at many call sites.

**Cost of the test.** The length is what is under test, so the input is
about 2 GiB. One child runs all 8 cases, so the string is allocated
once. Peak RSS is 4.4 GB. The file takes 2 to 4 s in a debug ASAN build.
It skips below 10 GiB of total memory, the gate
`test/js/bun/transpiler/source-too-large.test.ts` uses. The one test
keeps a 30 s ceiling, because the default 5 s is too close on a loaded
machine.

**Pre-existing failures** seen in the surrounding suites, which also
fail with `src/` at `origin/main`: `error-gc-test.test.js` (timeouts in
a debug build), `socket.test.ts > should not call drain before
handshake`, and `process.test.js > process` (this container has no
`USER` in the environment).

</details>
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…ng length limit (oven-sh#42202)

### Problem

- An error message that embeds a string from JS has a user-controlled
length. Building one past the string length limit aborts the process,
also inside `try`/`catch`: `panic(main thread): abort() called`, exit
code 134. Example: `bun -e 'try { Buffer.from("x", "q".repeat(2**31-10))
} catch {}'`.
- The cause: `WTF::makeString` and a default-constructed
`WTF::StringBuilder` call `CRASH()` when the result passes
`String::MaxLength`. Every message builder in
`src/jsc/bindings/ErrorCode.cpp` is one of the two.

### Fix

- `Bun::MessageBuilder` (`ErrorCode.h`) records the overflow. Every
builder in `ErrorCode.cpp` uses it, and `determineSpecificType` and
`JSValueToStringSafe` accept only that type. `createError` and
`throwError` report a message that did not fit as `RangeError: Out of
memory`.
- Five sinks outside `ErrorCode.cpp` build with `tryMakeString`: the
`performance.measure` mark, the `ReadableStream` source `type`, three
`MessageEvent` constructor messages.
- Correct because JSC reports `RangeError: Out of memory` for a string
it cannot create. Node v26.3.0 reports the same inputs as `RangeError:
Invalid string length`. A message under the limit is unchanged.
- Verified:
`test/js/bun/util/error-message-string-length-limit.test.ts`, 8 cases in
one child, which aborts on main. Also the buffer, streams, webcrypto and
MessageEvent suites. Self-reviewed: 7 concerns raised, 6 addressed, 1 is
a question for a maintainer (Notes).

### Background

- `String::MaxLength` is 2^31 - 1 code units. A JS string can be that
long.
- `StringBuilder` takes an `OverflowPolicy`. The default crashes.
`RecordOverflow` sets a flag that `hasOverflowed()` reports, and later
appends do nothing.
- `determineSpecificType` renders the `Received ...` part of
`ERR_INVALID_ARG_TYPE`. It appends `constructor.name` with no bound.
- JSC treats its own messages this way in `ExceptionHelpers.cpp`.

<details><summary>Notes</summary>

**The type guards its own reads.** `StringBuilder::toString()` and
`length()` assert that the builder did not overflow, so on
`MessageBuilder` they are private. `tryToString()` returns a null string
on overflow, and `finish(globalObject, scope)` throws. No
`hasOverflowed()` check is written by hand in `ErrorCode.cpp`.

**Question for a maintainer.** This PR reports an over-long message as
`RangeError: Out of memory` with no `.code`, like JSC and like Node.
oven-sh#39481 (open) takes the other policy for the Rust-side error
constructor: it keeps the error type and properties and uses a stand-in
message. If that policy is preferred here too, the change is in two
functions: `createError(ErrorCode, MessageBuilder&)` and
`MessageBuilder::finish`.

**Moved out of this PR.** The first revision also changed
`process.execve` and `Error.stack`. Neither uses `MessageBuilder`, and
each has its own question, so each has its own PR:
- `process.execve`: oven-sh#42308
- `Error.stack` and the default `Error.prepareStackTrace`: oven-sh#42314

**Cases in the test.** All abort on main and on 1.4.2 with exit code
134. With this branch each one throws `RangeError: Out of memory`.

```js
const long = "q".repeat(2 ** 31 - 10);
class C {}
Object.defineProperty(C, "name", { value: long });

Buffer.from("x", long);                                   // UNKNOWN_ENCODING
Buffer.from(new C());                                     // determineSpecificType
Buffer.alloc(1).indexOf(new C());                         // JSBuffer.cpp message
new SocketAddress({ address: "1.2.3.4", port: new C() }); // Bun__ErrorCode__determineSpecificType, the entry Rust validators call
performance.measure("m", long);                           // PerformanceUserTiming.cpp
crypto.subtle.importKey(long, new Uint8Array(8), "AES-GCM", false, ["encrypt"]); // JSSubtleCrypto.cpp
new ReadableStream({ type: long });                       // JSReadableStream.cpp
ReadableStream.from(Symbol(long));                        // throwNotIterable
```

**Verified by hand, not in the test.**
- The three `MessageEvent` messages: `new MessageEvent("m", { ports:
long })`, `{ ports: [long] }`, `{ source: long }`. Each aborts on main
and throws with this branch. They render the value through the inspector
first, which takes about 20 s in a release build and about 9 minutes in
a debug ASAN build for a 2 GiB string, so they cannot be a test.
- The quoted `ERR_INVALID_ARG_VALUE` path (`Buffer.alloc(4).fill(long,
"hex")`), `ERR_OUT_OF_RANGE` with a long name, and
`NodeError.prototype.toString` with a long message throw with this
branch. The quoted path appends one character at a time, about 8 minutes
in a debug build.
- The doors reported in the comments below (`Buffer.byteLength`,
`fs.mkdirSync` mode, `process.kill` signal name, `child_process.spawn`)
all go through `MessageBuilder`.
- The whole fixture also passes under
`BUN_JSC_validateExceptionChecks=1`.

**Scope.** This does not cover every `makeString` call that can reach an
error sink. A census at `4ff91937` counted 276 `makeString(` calls and
110 default-constructed `StringBuilder` locals under `src/jsc`. The
`makeString` calls left in `ErrorCode.cpp` take an `ASCIILiteral` or an
internal string, so the binary bounds their length. A different
mechanism is also out of scope: `String::utf8()` aborts for a Latin-1
string of more than about 2^30 characters, at many call sites.

**Cost of the test.** The length is what is under test, so the input is
about 2 GiB. One child runs all 8 cases, so the string is allocated
once. Peak RSS is 4.4 GB. The file takes 2 to 4 s in a debug ASAN build.
It skips below 10 GiB of total memory, the gate
`test/js/bun/transpiler/source-too-large.test.ts` uses. The one test
keeps a 30 s ceiling, because the default 5 s is too close on a loaded
machine.

**Pre-existing failures** seen in the surrounding suites, which also
fail with `src/` at `origin/main`: `error-gc-test.test.js` (timeouts in
a debug build), `socket.test.ts > should not call drain before
handshake`, and `process.test.js > process` (this container has no
`USER` in the environment).

</details>

This branch has not been deployed

No deployments
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