Skip to content

node errors: render %s codes like util.format and keep the sign of a received -0 - #43087

Open
robobun wants to merge 6 commits into
mainfrom
robobun/4b70bff1/node-error-negative-zero
Open

robobun wants to merge 6 commits into
mainfrom
robobun/4b70bff1/node-error-negative-zero

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Several Node error messages print a received -0 as 0. Node prints -0 (issue Node error messages print a received -0 as 0 at several sites (Node prints -0) #43070). Buffer.alloc(1).toString(-0), new StringDecoder(-0), Readable#setEncoding(-0), crypto.randomInt(-0), http.validateHeaderName(-0), http2.getUnpackedSettings(-0) and spawnSync with killSignal: -0 all lose the sign.
  • Each site formats the number on its own path. JSBuffer.cpp and JSStringDecoder.cpp pass a WTF::String to ERR::UNKNOWN_ENCODING. The fixed-template table in ErrorCode.cpp (makeSimpleErrorMessage) uses toWTFString, so it also prints a plain object as [object Object] where Node prints { a: 1 }. child_process.ts builds ERR_UNKNOWN_SIGNAL from a template string. node_crypto_binding.rs truncates max to i64 before it formats. http2.ts has a JS copy of determineSpecificType.

Fix

  • New JSValueToStringLikeFormatS in ErrorCode.cpp renders a value like the %s of util.format. A function or an object without a built-in toString goes through String(). Any other object goes through util.inspect. hasBuiltInToString is a port of Node's, with Node's list of built-in constructor names. JSValueToStringSafe keeps the sign of -0. This PR replaces node errors: render -0 with its sign in JSValueToStringSafe #43069, which had the same hunk.
  • The 40 template codes, ERR_UNKNOWN_ENCODING, ERR_ARG_NOT_ITERABLE and the C++ UNKNOWN_ENCODING helper use that renderer. UNKNOWN_ENCODING now takes only the JSValue, and the four call sites pass the original argument. ERR_UNKNOWN_SIGNAL joins the table, so child_process.ts throws $ERR_UNKNOWN_SIGNAL.
  • randomInt formats max before truncation and adds numerical separators beyond 2^32, as its max - min branch already did. The http2.ts copy renders -0 and a bigint.
  • Verified: test/js/node/errors/error-code-messages.test.ts (four new blocks, stock bun fails all four). Also buffer.test.js, string_decoder, streams.test.js, node-http.test.ts, node-http2.test.js, crypto.test.ts, child_process/ and the Node parallel tests for spawnSync signals, process.kill, Buffer#toString, StringDecoder and header validation.

Background

  • Node renders a value in an error message with util.inspect, or with the %s of util.format. Both keep the sign of -0. JS String(-0) is "0".
  • $ERR_* codes that JS builtins throw go through jsFunctionMakeErrorWithCode in ErrorCode.cpp. Codes whose message is fixed text around one or two %s arguments come from the simpleErrorMessages table.
  • Node's %s (hasBuiltInToString in lib/internal/util/inspect.js) walks to the prototype that owns toString or Symbol.toPrimitive and checks whether its constructor is one of the ECMAScript globals. Buffer, URL and Uint8Array are not in that list, so they print through String().
Notes

[human-review] gate passed · iteration 0 · 10 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/errors/error-code-messages.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/node/errors/error-code-messages.test.ts:
(pass) table-driven ERR_* codes keep their exact messages [110.04ms]
54 |     // %s codes.
55 |     setDefaultEncoding: capture(() => new Writable().setDefaultEncoding(-0 as any)),
56 |     readableStreamFrom: capture(() => ReadableStream.from(-0 as any)),
57 |     // Positive zero has no sign.
58 |     positiveZero: capture(() => Buffer.alloc(8).readUIntBE(0, 0)),
59 |   }).toEqual({
          ^
error: expect(received).toEqual(expected)

  {
-   "figures": "ERR_OUT_OF_RANGE | RangeError | The value of "options.figures" is out of range. It must be >= 1 && <= 5. Received -0",
-   "paramEncoding": "ERR_INVALID_ARG_VALUE | TypeError | The property 'options.paramEncoding' is invalid. Received -0",
-   "percentile": "ERR_OUT_OF_RANGE | RangeError | The value of "percentile" is out of range. It must be > 0 && <= 100. Received -0",
+   "figures": "ERR_OUT_OF_RANGE | RangeError | The value of "options.figures" is out of 
... (truncated)

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

test/js/node/errors/error-code-messages.test.ts:
(pass) table-driven ERR_* codes keep their exact messages [1.22ms]
(pass) a received -0 keeps its sign at sites that use the shared value renderer [0.38ms]
(pass) a received -0 keeps its sign at sites that format the number themselves [0.83ms]
(pass) %s codes render an object like util.format [0.48ms]
(pass) randomInt renders a large received max with numerical separators like node [0.07ms]

 5 pass
 0 fail
 10 expect() calls
Ran 5 tests across 1 file. [89.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/node/errors/error-code-messages.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/node/errors/error-code-messages.test.ts:
(pass) table-driven ERR_* codes keep their exact messages [104.28ms]
(pass) a received -0 keeps its sign at sites that use the shared value renderer [26.80ms]
(pass) a received -0 keeps its sign at sites that format the number themselves [65.13ms]
(pass) %s codes render an object like util.format [37.90ms]
(pass) randomInt renders a large received max with numerical separators like node [7.04ms]

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 638ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/135] gen ErrorCode+*.h
[2/135] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[3/133] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/133] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[5/133] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[6/133] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[7/133] gen cpp.rs (cppbind)
[8/133] gen BunObject.lut.h
Generating /workspace/bun/build/release/codegen/BunObject.lut.h from /workspace/bun/src/jsc/bindings/BunObject.cpp
[9/133] gen JS modules (bundle-modules)
Preprocess modules (7161ms)
Bundle modules (58ms)
Postprocesss modules (172ms)
Bundle Functions (5
... (truncated)
diff hotspot
src/js/builtins.d.ts                               |   1 +
 src/js/node/child_process.ts                       |   8 +-
 src/js/node/http2.ts                               |   3 +-
 src/jsc/bindings/ErrorCode.cpp                     | 149 ++++++++++++++++++---
 src/jsc/bindings/ErrorCode.h                       |   5 +-
 src/jsc/bindings/JSBuffer.cpp                      |  10 +-
 src/jsc/bindings/JSStringDecoder.cpp               |   6 +-
 .../webcore/streams/ReadableStreamOperations.cpp   |   2 +-
 src/runtime/node/node_crypto_binding.rs            |  50 ++++---
 test/js/node/errors/error-code-messages.test.ts    | 129 +++++++++++++++++-
 10 files changed, 303 insertions(+), 60 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/js/builtins.d.ts                                          0      0     13
src/js/node/child_process.ts                                  0      0     13
src/js/node/http2.ts                                          1      3     14
src/jsc/bindings/ErrorCode.cpp                                3      5     14
src/jsc/bindings/ErrorCode.h                                  0      0     14
src/jsc/bindings/JSBuffer.cpp                                 0      0     13
src/jsc/bindings/JSStringDecoder.cpp                          0      0     13
…c/bindings/webcore/streams/ReadableStreamOperations.cpp      0      0     14
src/runtime/node/node_crypto_binding.rs                       0      0     14
test/js/node/errors/error-code-messages.test.ts               1      2     13

root cause · written by the author bot

Several Bun error sites stringified a received numeric argument before it reached the message formatter, so a -0 lost its sign through toString, an i64 conversion, or a plain template string and printed as 0 where Node's util.inspect semantics print -0. The fix routes those sites through a shared Node-style %s value formatter in ErrorCode.cpp that preserves negative zero, BigInt and large-integer rendering, passes the original JSValue for unknown encodings instead of a pre-converted string, moves the unknown-signal error in child_process.ts onto the $ERR_UNKNOWN_SIGNAL…

…he value themselves

ERR_UNKNOWN_ENCODING, the fixed-template codes (ERR_INVALID_HTTP_TOKEN and
others), ERR_UNKNOWN_SIGNAL from child_process, crypto.randomInt and the
http2 copy of determineSpecificType printed -0 as 0. Node prints -0.

Route the C++ sites through a %s-style renderer that keeps the sign and,
like util.format, prints an object with a user toString through String().
Format randomInt's max from the double instead of the truncated i64.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: 596f1d88-abb8-4b26-9309-14e130c61205

📥 Commits

Reviewing files that changed from the base of the PR and between b52d513 and 4e69367.

📒 Files selected for processing (10)
  • src/js/builtins.d.ts
  • src/js/node/child_process.ts
  • src/js/node/http2.ts
  • src/jsc/bindings/ErrorCode.cpp
  • src/jsc/bindings/ErrorCode.h
  • src/jsc/bindings/JSBuffer.cpp
  • src/jsc/bindings/JSStringDecoder.cpp
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • src/runtime/node/node_crypto_binding.rs
  • test/js/node/errors/error-code-messages.test.ts

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


Walkthrough

The PR updates Node-compatible error formatting. It adds shared %s-style JavaScript value conversion, preserves negative zero, formats BigInt and large integers, routes unknown signals through a builtin error, preserves original encoding values, and expands error-message tests.

Changes

Error formatting compatibility

Layer / File(s) Summary
Shared error formatting
src/jsc/bindings/ErrorCode.*
Adds Node-style %s value formatting, preserves -0, updates table-driven errors, and removes the StringView encoding overload.
Error constructor and encoding call sites
src/js/builtins.d.ts, src/js/node/child_process.ts, src/jsc/bindings/JSBuffer.cpp, src/jsc/bindings/JSStringDecoder.cpp, src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
Routes unknown signals through $ERR_UNKNOWN_SIGNAL, passes original encoding values to error handling, and uses string-like formatting for iterable errors.
Numeric and BigInt rendering
src/js/node/http2.ts, src/runtime/node/node_crypto_binding.rs
Preserves negative zero, adds explicit BigInt formatting, and inserts separators in large integer error messages.
Error message validation
test/js/node/errors/error-code-messages.test.ts
Adds coverage for signed zero, %s object conversion, signal and encoding errors, numeric separators, and BigInt rendering.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 4e693

The formatting changes have no remaining supported compatibility issue and are ready to merge.

🚥 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 summarizes the main changes: Node-style %s error formatting and preservation of negative zero.
Description check ✅ Passed The description explains the problem, implementation, affected areas, background, and verification results. It does not use the template headings exactly, but it provides the required information in e…

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

A scope that ends inside the object branch simulates a throw that the
tail call into JSValueToStringSafe never checks. Hold one scope for the
whole function and release it on the tail call.
Comment thread src/jsc/bindings/ErrorCode.cpp Outdated
Comment thread src/jsc/bindings/ErrorCode.cpp Outdated
Comment thread src/jsc/bindings/ErrorCode.cpp
Comment thread src/jsc/bindings/ErrorCode.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.

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

Beyond the inline findings, I also checked: the exception handling in hasUserToString/JSValueToStringLikeFormatS (every object->get and toWTFString is followed by RETURN_IF_EXCEPTION, and the tail call goes through RELEASE_AND_RETURN); the deleted StringView overload of UNKNOWN_ENCODING has no remaining callers (all five sites in JSBuffer.cpp, JSStringDecoder.cpp and CryptoUtil.cpp pass a JSValue); and the 124-byte stack buffer in randomInt matches the &mut [u8; 124] contract of FormatDouble::dtoa_with_negative_zero in src/bun_core/fmt.rs.

Extended reasoning...

Findings were reported inline, so this body only records what else was examined and ruled out. Read the ErrorCode.cpp/.h and node_crypto_binding.rs hunks directly: the new C++ helpers carry a ThrowScope with checks after each JS-entering call, no non-throwing accessor is used on an unvalidated value, and the Proxy walk bails on revoked proxies. Grepped the tree for remaining UNKNOWN_ENCODING( callers and confirmed all pass the original JSValue. Confirmed the dtoa buffer size against the Rust FFI signature. None of these produced an additional issue; the inline findings (function rendering, bound-function/callable-Proxy detection, inspect depth) remain the substantive concerns a human should weigh.

4 verified lower-impact observations (convention, logging or cleanup points) were not posted.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp — pre-existing: ReadableStream.from(x) with an x whose toString or Symbol.toPrimitive is user code still reports { toString: [Function: toString] } must be iterable, where Node's ERR_ARG_NOT_ITERABLE ('%s must be iterable') prints x must be iterable. throwNotIterable at ReadableStreamOperations.cpp:812 keeps calling JSValueToStringSafe although its own comment says it renders the argument the way %s does, and this PR adds the %s renderer without switching this sibling. Fix: every Node %s code rendered in C++ goes through JSValueToStringLikeFormatS; replace the JSValueToStringSafe call here (the C++ ERR_OPERATION_FAILED case at ErrorCode.cpp:2091 is the other %s site, though its callers pass strings).

    Extended reasoning...

    The PR introduces JSValueToStringLikeFormatS (ErrorCode.cpp:449) as the %s renderer and applies it to the simpleErrorMessages table, the JS ERR_UNKNOWN_ENCODING case and the C++ UNKNOWN_ENCODING overload.
    ReadableStreamOperations.cpp:806-816 is a %s site by its own comment ("renders the argument the way %s does") and Node defines ERR_ARG_NOT_ITERABLE as '%s must be iterable'.
    It calls Bun::JSValueToStringSafe(globalObject, builder, iterable, false), which for any object goes to Bun__inspect_singleline (ErrorCode.cpp:421) regardless of a user toString.
    Trigger: ReadableStream.from({ toString() { return 'x'; } }) or ReadableStream.from(new Proxy(objWithUserToString, {})).
    Node: hasBuiltInToString is false, %s uses String(obj), message 'x must be iterable'.
    Bun before and after this PR: '{ toString: [Function: toString] } must be iterable'.
    The base already behaves this way; the PR interacts with this line by changing JSValueToStringSafe (the -0 hunk) and by creating the helper that should cover it, so it is the remaining member of the class the PR fixes.
    ERR_OPERATION_FAILED at…

    Verification: pre-existing — triggered whenever ReadableStream.from(x) is called with a non-iterable object whose toString / Symbol.toPrimitive is user code. Mechanism verified: src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp:812 (untouched by this PR) still calls Bun::JSValueToStringSafe(globalObject, builder, iterable, false), and JSValueToStringSafe (ErrorCode.cpp:353-421) has no…

  • 🟣 src/jsc/bindings/JSBuffer.cpp — Callers of Buffer#toString or Buffer#write with an encoding object whose valueOf returns a valid name get ERR_UNKNOWN_ENCODING, where Node decodes successfully. Bun's parseEncoding coerces with arg.toString() at src/jsc/bindings/JSBuffer.cpp:336 (hint string, toString first), while Node's getEncodingOps does encoding += '' (hint default, valueOf first). The PR rewires this exact error site to render the object like Node but leaves the coercion that decides whether the error fires at all. Fix: coerce with ToPrimitive default hint (JSValue::toPrimitive then toString) at parseEncoding for the validateUnknown=false callers, so {valueOf(){return 'utf8'}} resolves to utf8 as in Node.

    Extended reasoning...

    User calls Buffer.from('hi').toString({ valueOf() { return 'utf8'; } }). jsBufferPrototypeFunction_toString reaches parseEncoding(scope, global, arg1, false) at JSBuffer.cpp:2303. Line 336 calls arg.toString(), which runs ToPrimitive with hint string: Object.prototype.toString wins and returns '[object Object]'; valueOf is never consulted. parseEnumerationFromView fails, validateUnknown is false, so line 327 throws UNKNOWN_ENCODING(arg). After this PR the message is 'Unknown encoding: { valueOf: [Function: valueOf] }'. Node's Buffer.prototype.toString calls getEncodingOps(encoding) which does encoding += '': ToPrimitive with hint default calls valueOf first, gets 'utf8', and the call returns 'hi'. Same for Buffer#write at JSBuffer.cpp:2557 and 2595. Base behaves the same way (I checked line 336 is untouched), so this is pre-existing, but the dismissing finder only noted the message text changed; the real divergence is throw versus success. The PR's test block asserts object-argument parity for Buffer.toString and would have caught this with a valueOf-only fixture. Remedy: in the JSValue…

    Verification: pre-existing — triggered when a caller passes Buffer#toString / Buffer#write an object encoding whose valueOf (not toString) yields a valid name, e.g. Buffer.from('hi').toString({ valueOf() { return 'utf8' } }). Mechanism verified: the JSValue overload of parseEncoding at /home/claude/bun/src/jsc/bindings/JSBuffer.cpp:336 does auto arg_ = arg.toString(lexicalGlobalObject); (ECMAScript…

Comment thread src/jsc/bindings/ErrorCode.cpp
Comment thread src/jsc/bindings/ErrorCode.cpp Outdated
Comment thread src/jsc/bindings/ErrorCode.cpp
Comment thread src/runtime/node/node_crypto_binding.rs
Comment thread src/js/node/http2.ts

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

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (5):

  • Unresolved: 5 minor or pre-existing.

…ions as source

util.format's %s sends a function and an object without a built-in
toString through String(). Only the ECMAScript globals count as built-in
constructors, so Buffer, TypedArray and URL print through String() too.
ReadableStream.from's ERR_ARG_NOT_ITERABLE uses the same renderer.

randomInt adds numerical separators to a received max beyond 2^32, as
its max - min branch already did. The http2 copy of determineSpecificType
renders a bigint.
Comment thread src/jsc/bindings/ErrorCode.cpp Outdated
Comment thread src/jsc/bindings/ErrorCode.cpp Outdated
Comment thread src/jsc/bindings/ErrorCode.h
Comment thread src/runtime/node/node_crypto_binding.rs Outdated
@robobun robobun changed the title node errors: keep the sign of a received -0 in ERR_UNKNOWN_ENCODING, the template codes, randomInt and killSignal node errors: render %s codes like util.format and keep the sign of a received -0 Sep 17, 2026
@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:36 PM PT - Sep 17th, 2026

✅ @robobun, your commit 4e69367bb5ff243cf4c921d734d3738887102434 passed in Build #117191! 🎉


🧪   To try this PR locally:

bunx bun-pr 43087

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

bun-43087 --bun

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

Code review found no issues

No high-confidence issues detected in this change.

2 verified lower-impact observations (convention, logging or cleanup points) were not posted.

These rows come from #43069, which carries the same JSValueToStringSafe
change. They cover ERR_OUT_OF_RANGE from C++ and from JS,
ERR_INVALID_ARG_VALUE, ERR_UNKNOWN_ENCODING from a JS builtin and
ReadableStream.from.
@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

I moved the seven test rows of #43069 into this PR (commit 4e69367, test file only) and closed #43069. That PR carried the same JSValueToStringSafe change as this one.

The rows are in a new block, "a received -0 keeps its sign at sites that use the shared value renderer". They cover ERR_OUT_OF_RANGE from C++ and from JS, ERR_INVALID_ARG_VALUE, ERR_UNKNOWN_ENCODING from a JS builtin, and ReadableStream.from. All seven pass with the debug build of this branch. The released bun fails six of them.

I updated the PR body to match. It says that this PR replaces #43069, and it counts four new test blocks.

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

Code review found no issues

No high-confidence issues detected in this change.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

dylan-conway pushed a commit that referenced this pull request Sep 19, 2026
### Problem

- Seven Node error codes have a broken `.message`. `http.request({
headers: { Trailer: "X-T" } }).end()` throws `ERR_HTTP_TRAILER_INVALID`
with the message `undefined`. `url.fileURLToPath(httpUrl, { windows:
true })` throws `ERR_INVALID_URL_SCHEME` with the message `file`. Notes
list all seven.
- The cause is the tail of `jsFunctionMakeErrorWithCode`
(`src/jsc/bindings/ErrorCode.cpp:2478`). A code with no message template
uses its first argument as the whole message. These call sites pass
Node's template arguments, or nothing.
- The reverse also happens. A sentence passed to a templated code gives
`Cannot call Stream is destroyed after a stream was destroyed`.

### Fix

- Four codes get Node's template: `ERR_HTTP_TRAILER_INVALID`,
`ERR_SCRIPT_EXECUTION_INTERRUPTED`, `ERR_HTTP_CONTENT_LENGTH_MISMATCH`,
`ERR_INVALID_URL_SCHEME`. The server `strictContentLength` check throws
from Rust (`NodeHTTPResponse.rs`). It now formats Node's sentence with
both byte counts.
- The call sites of three codes now pass Node's argument:
`ERR_STREAM_DESTROYED("write")`, `ERR_OPERATION_FAILED("write failed
after retries")`, `ERR_METHOD_NOT_IMPLEMENTED("FileHandle with fs")`.
Dead arguments are gone.
- Verified: `test/js/node/errors/error-code-messages.test.ts`. Bun
1.4.3-canary.1+367d939d9 fails 7 of its 8 tests. The expected messages
are node v26.3.0's.
- Self-reviewed: 12 concerns raised, 8 addressed, 4 rejected (see
Notes).

### Background

- Built-in JS writes `$ERR_FOO(a, b)`. The build turns it into a call of
`jsFunctionMakeErrorWithCode`, which builds the message in C++.
- A message template is a `case` in that function, or a row in its
`simpleErrorMessages` table: fixed text around one or two arguments.
- `strictContentLength` makes `node:http` throw when the body size
differs from `Content-Length`. The client checks in JS. The server
checks in native code, which never reaches the C++ template.

<details><summary>Notes</summary>

Before and after, per code. The "after" text is identical to node
v26.3.0.

| code | call | before | after |
| --- | --- | --- | --- |
| `ERR_HTTP_TRAILER_INVALID` | client request with a `Trailer` header
and no chunked body | `undefined` | `Trailers are invalid with this
transfer encoding` |
| `ERR_HTTP_CONTENT_LENGTH_MISMATCH` | client `req.end("abc")`,
`Content-Length: 5` | `3` | `Response body's content-length of 3 byte(s)
does not match the content-length of 5 byte(s) set in header` |
| `ERR_HTTP_CONTENT_LENGTH_MISMATCH` | server `res.end("abc")`,
`Content-Length: 5` | `Content-Length mismatch` | the same sentence |
| `ERR_INVALID_URL_SCHEME` | `fileURLToPath(httpUrl, { windows })` |
`file` | `The URL must be of scheme file` |
| `ERR_STREAM_DESTROYED` | `res.destroy(); res.write("x", cb)` | `Cannot
call Stream is destroyed after a stream was destroyed` | `Cannot call
write after a stream was destroyed` |
| `ERR_SCRIPT_EXECUTION_INTERRUPTED` | REPL, Ctrl+C during `await` |
`undefined` | ``Script execution was interrupted by `SIGINT` `` |
| `ERR_OPERATION_FAILED` | `FileHandle` writer, every write returns 0
bytes | `Operation failed: Operation failed: write failed after retries`
| `Operation failed: write failed after retries` |
| `ERR_METHOD_NOT_IMPLEMENTED` | `createReadStream(null, { fd:
fileHandle, fs })` | `The fs.FileHandle with custom fs operations method
is not implemented` | `The FileHandle with fs method is not implemented`
|

- How the list was made: a sweep over `src/js` for every `$ERR_X(` call,
split by whether `X` has a `case` or a table row in `ErrorCode.cpp`. 57
codes have no template. All of them pass a full sentence, except the
four above and `ERR_HTTP2_UNSUPPORTED_PROTOCOL`. The reverse direction
(a sentence passed into a template) gave `ERR_STREAM_DESTROYED` and
`ERR_OPERATION_FAILED`. `ERR_METHOD_NOT_IMPLEMENTED` passes a fragment,
but not the one Node passes.
- The ported `test-fs-read-stream-file-handle.js` gets its upstream
`message:` assertion back. It was commented out because of the
`ERR_METHOD_NOT_IMPLEMENTED` text.
- Deleted arguments: the sentence in two
`$ERR_HTTP_TRAILER_INVALID(...)` calls in `_http_server.ts`, and the
`...args` of the REPL wrapper for `ERR_SCRIPT_EXECUTION_INTERRUPTED`.
The constant-message `case` ignores them.
- `fileURLToPathBuffer` (`url.ts:1320`) passed the whole sentence and
was correct. With the new table row it passes `"file"`, like its sibling
and like Node. Without that edit the message would read `The URL must be
of scheme The URL must be of scheme file`.
- `src/js/builtins.d.ts` declares the argument shapes of the four codes
that got a template. Without a declaration the code generator emits
`(message: string)`.
- Not in this PR: `ERR_HTTP2_UNSUPPORTED_PROTOCOL`
(`http2.connect("ftp://...")` prints `ftp:`) has the same cause. A
separate change owns it (branch
`robobun/d80742c6/http2-unsupported-protocol`), so this PR does not add
its row. #43087 also edits `makeSimpleErrorMessage` and adds a row at
the end of the table. The new rows here are in the middle of the table
to keep the merges clean.
- Not in this PR: `fetch()` also throws
`ERR_HTTP_CONTENT_LENGTH_MISMATCH` (`FetchTasklet.rs`) with its own
sentence about the request body. That is a Bun `fetch` error, not a
`node:http` one, so its text stays.
- Not in this PR: `fs.readFileSync(new URL("http://example.com"))`
throws `ERR_INVALID_URL_SCHEME` from `src/runtime/node/types.rs` with
the text `URL must be a non-empty "file:" path`.
`ERR_INVALID_FILE_URL_PATH` and `ERR_INVALID_FILE_URL_HOST` share that
same sentence there. A separate change fixes the three together, because
the path and host texts need more than a new literal.
- Three of the five `ERR_OPERATION_FAILED` call sites doubled the
prefix, all in the `FileHandle` writer. The other two already pass
Node's argument. The test reaches the synchronous writer site: it
replaces `fs.writeSync` with a function that returns 0, and the writer
looks `writeSync` up on the public module at call time. The two async
sites bind `write` and `writev` at module load, so a test cannot make
them return 0. They get the same change. Node's own writer calls its
binding directly, so this expected string comes from Node's source
(`'Operation failed: %s'` with `'write failed after retries'`) and not
from a Node run. Every other expected string is Node's output for the
same call.
- The REPL test asserts that the output contains the message, not the
whole line. Bun prints `Uncaught Error: <message>` where Node prints
`Uncaught:` and the inspected error with its `[ERR_...]` bracket. That
difference is about the stack header, not the message.
- Node does not check the first `res.write()` against `Content-Length`
(its `_contentLength` is still null at that point). Bun's native check
does. The tests use a second write, which both runtimes reject with `6
byte(s)`.
- Found on the way and not changed here (#43520). Four templates in
`ErrorCode.cpp` differ from Node's own text: `ERR_HTTP_SOCKET_ASSIGNED`
(`Socket already assigned`, Node: `ServerResponse has an already
assigned socket`), `ERR_TLS_INVALID_PROTOCOL_VERSION` and
`ERR_TLS_PROTOCOL_VERSION_CONFLICT` (Node formats the values with `%j`,
so it prints `"TLSv9" is not a valid minimum TLS protocol version`), and
`ERR_IPC_CHANNEL_CLOSED` (`Channel closed.`, Node has no period). Those
are wrong templates, not call sites that miss a template. A script
compared the 118 constant and table messages with the literal templates
in Node's `lib/internal/errors.js`. 108 are identical, and these four
differ.
- Found on the way and not changed here (#43519). The server
`strictContentLength` check differs from Node in behavior:
`Content-Length: 0` is never checked, the first `res.write()` is checked
(Node checks from the second write on), and a string header is parsed
with `parseInt` where Node uses `+value`.
- Self-review, the four concerns I did not act on. (1) Fix the
`types.rs` arm of `ERR_INVALID_URL_SCHEME` here: a separate change fixes
the three arms together. (2) Add the `ERR_HTTP2_UNSUPPORTED_PROTOCOL`
row here: a separate change owns it. (3) Fix `ERR_HTTP_SOCKET_ASSIGNED`
and the `Content-Length` parsing here: they are a different class,
tracked in #43520 and #43519. (4) The REPL test passes a 20 s timeout,
and `test/CLAUDE.md` says not to set one: `node:repl` takes 5 to 9 s to
load on a debug build with ASAN, and `test/js/bun/repl/repl.test.ts`
uses the same value for the same reason.
- Earlier work: #35791 covered five of these codes and was closed as
stale with conflicts, not on its merits. #35777 covered two. This PR
follows the review threads of #35791: the server path, the REPL code,
and the dead arguments.
- Suites run on the debug build: `error-code-messages.test.ts`,
`test/js/node/url/url-fileurltopath*.test.*`,
`node-http-transfer-encoding.test.ts`, `node-http.test.ts`,
`test/js/node/fs/promises.test.js`, and the ported
`test-http-content-length-mismatch.js`,
`test-http-server-de-chunked-trailer.js`, `test-http-set-trailers.js`,
`test-url-fileurltopath.js`, `test-fs-whatwg-url.js`,
`test-fs-read-stream-file-handle.js`, `test-repl-sigint.js`,
`test-repl-sigint-nested-eval.js`, `test-worker-unsupported-path.js`. In
`node-http.test.ts`, `should propagate exception in sync data handler`
timed out once in the full run and passes alone in 3 s.

</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