Skip to content

node errors: render -0 with its sign in JSValueToStringSafe - #43069

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/e13710c0/err-received-negative-zero
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/e13710c0/err-received-negative-zero

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Buffer.alloc(8).readUIntBE(0, -0) throws The value of "byteLength" is out of range. It must be >= 1 and <= 6. Received 0. Node v26.3.0 says Received -0. A differential run against Node found it. No user reported it.
  • The cause is JSValueToStringSafe (src/jsc/bindings/ErrorCode.cpp:350). It renders a number with toWTFStringForConsole, which is JS ToString. ToString(-0) is "0".

Fix

  • JSValueToStringSafe appends -0 when the value is negative zero. Nothing else changes.
  • Node renders the value in these messages with util.inspect, or with the %s of util.format. Both print -0 (formatNumber).
  • Verified: test/js/node/errors/error-code-messages.test.ts (new test, the released bun fails six of seven rows). Also buffer.test.js, util.test.js, perf_hooks.test.ts and 23 vendored node tests.
  • Self-reviewed: 3 concerns raised, 3 addressed (the body claimed more than the change covers, the sites it does not reach were not listed, the comment had no Node permalink). Not taken: the -0 case of determineSpecificType, because node errors: render -0 and constructor-less objects in determineSpecificType like node #38642 has it.

Background

Notes

Messages for a -0 input. Node v26.3.0 and this branch print the same text. The new test covers these routes: the C++ Bun::ERR::OUT_OF_RANGE overloads (numeric bounds, range string), the C++ INVALID_ARG_VALUE overload, $ERR_OUT_OF_RANGE and $ERR_UNKNOWN_ENCODING from the JS builtins, and ReadableStream.from.

call Node v26.3.0, this branch bun 1.4.3-canary
Buffer.alloc(8).readUIntBE(0, -0) ... >= 1 and <= 6. Received -0 Received 0
createHistogram().percentile(-0) ... > 0 && <= 100. Received -0 Received 0
createHistogram({ figures: -0 }) ... >= 1 && <= 5. Received -0 Received 0
generateKeyPairSync("ec", { namedCurve: "P-256", paramEncoding: -0 }) The property 'options.paramEncoding' is invalid. Received -0 Received 0
require.resolve("./x", { paths: -0 }) The property 'options.paths' is invalid. Received -0 Received 0
new Writable().setDefaultEncoding(-0) Unknown encoding: -0 Unknown encoding: 0
ReadableStream.from(-0) -0 must be iterable 0 must be iterable

A computed negative zero (0 * -1, Math.round(-0.4)) prints -0 in Node too. 0, NaN, -Infinity, 1e+21 and -1e-7 print the same text before and after this change.

How Node renders the value:

  • ERR_OUT_OF_RANGE and ERR_INVALID_ARG_VALUE call inspect(value) (lib/internal/errors.js).
  • ERR_UNKNOWN_ENCODING, ERR_UNKNOWN_SIGNAL, ERR_OPERATION_FAILED and ERR_ARG_NOT_ITERABLE use %s. formatWithOptions sends a number through formatNumber.
  • The one caller where Node uses String(v) is the list of allowed values in validateOneOf. Those lists are literals in Bun's own code, and none holds -0.

Sites that still print 0 for -0 after this change (tracked in #43070). None of them reaches JSValueToStringSafe with the number:

  • Buffer.alloc(1).toString(-0), new StringDecoder(-0), new Readable().setEncoding(-0) (Unknown encoding: 0): JSBuffer.cpp and JSStringDecoder.cpp call toString on the value before they throw.
  • crypto.randomInt(-0): src/runtime/node/node_crypto_binding.rs converts max to i64 before it formats the message.
  • crypto.pbkdf2Sync("a", "b", -0, 1, "sha1"): the Rust out_of_range formatter. node: render the Rust validators' ERR_OUT_OF_RANGE values like JS #40737 fixes it.
  • process.chdir(-0), Buffer.from(-0) (Received type number (0)): determineSpecificType. node errors: render -0 and constructor-less objects in determineSpecificType like node #38642 fixes it. src/js/node/http2.ts has a JS copy of that function with the same defect.
  • http.validateHeaderName(-0) (["0"]): the table of fixed-template codes uses ToString.
  • child_process.spawnSync("true", [], { killSignal: -0 }): ERR_UNKNOWN_SIGNAL in src/js/node/child_process.ts is a JS template string.

Two differences that this change does not touch:

  • ERR_SOCKET_BAD_PORT now prints Received -0 for dgram.createSocket("udp4").connect(-0). Node prints Received type number (-0). there. The format was different before this change too.
  • A boxed Object(-0) goes to Bun.inspect, which prints [Number: 0]. Node prints [Number: -0].

Suites run with the debug build:

  • test/js/node/errors/, test/js/node/buffer.test.js, test/js/node/util/util.test.js, test/js/node/perf_hooks/perf_hooks.test.ts, test/js/node/crypto/pbkdf2.test.ts, crypto-random.test.ts, argon2.test.ts, test/js/node/zlib/zlib-handle-bounds-check.test.ts, buffer-compare-bounds.test.ts, buffer-jit.test.ts, test/js/bun/util/error-code-mirror.test.ts, fs-write-offset-bound.test.ts, fs-read-buffer-before-offset.test.ts, readline.node.test.ts.
  • Vendored: test-buffer-alloc, test-buffer-fill, test-buffer-readint, test-buffer-readuint, test-buffer-writeint, test-buffer-writeuint, test-buffer-readdouble, test-buffer-readfloat, test-buffer-tostring-range, test-crypto-keygen, test-crypto-random, test-crypto-pbkdf2, test-crypto-scrypt, test-fs-opendir, test-string-decoder, test-whatwg-readablestream, test-zlib-deflate-constructors, test-stream-writable-invalid-chunk, test-stream-writable-decoded-encoding, test-child-process-spawnsync-kill-signal, test-child-process-kill, test-dgram-connect, test-net-connect-options-port.
  • The new test also passes with BUN_JSC_validateExceptionChecks=1.
  • buffer-jit.test.ts "differential fuzzer" needs 177 s with the debug build in my container, above its 120 s timeout. It passes with a longer timeout, and the JIT and the interpreter produce the same digest.

[auto-merge] gate passed · iteration 0 · 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/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 [130.39ms]
52 |     // %s codes.
53 |     setDefaultEncoding: capture(() => new Writable().setDefaultEncoding(-0 as any)),
54 |     readableStreamFrom: capture(() => ReadableStream.from(-0 as any)),
55 |     // Positive zero has no sign.
56 |     positiveZero: capture(() => Buffer.alloc(8).readUIntBE(0, 0)),
57 |   }).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: 1 FAILED
bun test v1.4.3-canary.1 (c6b7fcb5b)

test/js/node/errors/error-code-messages.test.ts:
(pass) table-driven ERR_* codes keep their exact messages [1.27ms]
52 |     // %s codes.
53 |     setDefaultEncoding: capture(() => new Writable().setDefaultEncoding(-0 as any)),
54 |     readableStreamFrom: capture(() => ReadableStream.from(-0 as any)),
55 |     // Positive zero has no sign.
56 |     positiveZero: capture(() => Buffer.alloc(8).readUIntBE(0, 0)),
57 |   }).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 range. It must be >= 1 && <= 5. Received 0",
+   "paramEncoding": "ERR_INVALID_ARG_VALUE | TypeError | The property 'options.paramEncoding' is invalid. Received 0",
+   "per
... (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/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 [101.55ms]
(pass) a value of -0 keeps its sign in an error message [26.21ms]

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 652ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/144] gen ErrorCode+*.h
[2/144] gen cpp.rs (cppbind)
[3/144] gen JS modules (bundle-modules)
Preprocess modules (7870ms)
Bundle modules (50ms)
Postprocesss modules (611ms)
Bundle Functions (694ms)
Generate Code (37ms)

[9.27s] Bundled "src/js" for production
  2603 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/144] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41
... (truncated)
diff hotspot
src/jsc/bindings/ErrorCode.cpp                  |  7 +++++
 test/js/node/errors/error-code-messages.test.ts | 35 ++++++++++++++++++++++++-
 2 files changed, 41 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/jsc/bindings/ErrorCode.cpp                       6      2      8
test/js/node/errors/error-code-messages.test.ts      2      2      8

JSValueToStringSafe writes a value into ERR_OUT_OF_RANGE,
ERR_INVALID_ARG_VALUE and the %s codes such as ERR_UNKNOWN_ENCODING.
It used JS ToString for a number, so -0 printed as 0. Node uses
util.inspect or the %s of util.format there, and both print -0.
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the fix and its test are pushed. This PR is #43069.

How I reproduced it. Run with bun and with node:

const { createHistogram } = require("perf_hooks");
const tests = {
  readUIntBE: () => Buffer.alloc(8).readUIntBE(0, -0),
  percentile: () => createHistogram().percentile(-0),
  figures: () => createHistogram({ figures: -0 }),
};
for (const [k, fn] of Object.entries(tests)) {
  try { fn(); } catch (e) { console.log(k, "|", e.code, "|", e.message); }
}
  • Bun 1.4.3-canary (c6b7fcb): each message ends with Received 0.
  • Node v26.3.0 and this branch: each message ends with Received -0.

Test: bun bd test test/js/node/errors/error-code-messages.test.ts. The released bun fails six of the seven rows of the new test.

The sites that this change does not reach are listed in #43070.

@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: a4c47f23-240a-4d9c-902a-a4b4cca027c5

📥 Commits

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

📒 Files selected for processing (2)
  • src/jsc/bindings/ErrorCode.cpp
  • test/js/node/errors/error-code-messages.test.ts

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


Walkthrough

Changes

Negative-zero error handling

Layer / File(s) Summary
Negative-zero stringification
src/jsc/bindings/ErrorCode.cpp
JSValueToStringSafe uses JavaScriptCore math utilities to detect finite numeric negative zero and return -0. Other non-cell values keep the existing conversion path.
Error-message regression coverage
test/js/node/errors/error-code-messages.test.ts
Tests verify -0 in several Node error messages and verify that positive zero remains 0. Imports support the added cases.

Suggested reviewers: cirospaciari, jarred-sumner

Priority: ⬇️ Low

Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to b4053

The negative-zero formatting change has focused regression coverage and no actionable merge risk remains.

🚥 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: preserving the sign of negative zero in Node error stringification.
Description check ✅ Passed The description explains the problem, fix, affected paths, limitations, and verification results. It does not use the template headings exactly, but it provides the required content in equivalent sect…

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

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

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

  • 🟣 src/jsc/bindings/ErrorCode.cpp — Users who pass -0 as an encoding get two different ERR_UNKNOWN_ENCODING texts from Bun after this merges, and two of them still disagree with Node. The JSValue overload at ErrorCode.cpp:1283 now prints "Unknown encoding: -0", but JSBuffer.cpp:327 and JSStringDecoder.cpp:589 call toString on the value first and then the StringView overload at ErrorCode.cpp:1270, so Buffer#toString(-0), new StringDecoder(-0) and Readable#setEncoding(-0) still print "Unknown encoding: 0". Fix: every ERR_UNKNOWN_ENCODING site that holds the original JSValue must pass it to the JSValue overload (or the StringView overload must accept the value), so one error code renders -0 the same way at every entry point. [also at: src/jsc/bindings/ErrorCode.cpp:1943 - Users calling http.validateHeaderName(-0) or res.setHeader(-0, "x") still get Header name must be a valid HTTP token ["0"] after this merges, while Node prints ["-0"].; src/jsc/bindings/ErrorCode.cpp:443 - Users hitting ERR_INVALID_ARG_TYPE with -0 (process.chdir(-0), Buffer.from(-0), fs.readFile(-0)) still see Received type number (0) after this merges, while Node prints type number (-0).; +1 more]

    Extended reasoning...

    The PR body itself lists these three sites as still printing 0 and calls them excluded. REVIEW.md says same-class sites are one concern and must be fixed in the same PR, preferring the shared helper; the dismissing finder accepted the author's exclusion as author_intended without weighing that rule. I opened JSBuffer.cpp:678-682: it does arg1.toString(lexicalGlobalObject), takes the view, and calls Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, view). JSStringDecoder.cpp:585-589 does the same. Both have the original JSValue in hand (arg1, jsEncoding) and a JSValue overload already exists at ErrorCode.cpp:1279. Node's ERR_UNKNOWN_ENCODING is 'Unknown encoding: %s' and util.format's %s prints -0, so Node prints…

    Verification: pre-existing; acknowledged in diff: the PR body's Notes list Buffer.alloc(1).toString(-0), new StringDecoder(-0) and new Readable().setEncoding(-0) as still printing Unknown encoding: 0 after this change, and that claim is accurate. Trigger: a user passes -0 as an encoding to any of those APIs (or buf.indexOf(str, off, -0)). Mechanism verified. Bun::ERR::UNKNOWN_ENCODING has two…

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

These sites are excluded on purpose. The PR body names them in the Notes, and #43070 tracks them.

ERR_UNKNOWN_ENCODING from JSBuffer.cpp and JSStringDecoder.cpp. A move to the JSValue overload is not a safe swap. The two routes already disagree on main for more than -0. I ran nine values through new StringDecoder(v) (the toString route) and new Writable().setDefaultEncoding(v) (the JSValue route), on Node v26.3.0 and on this branch. The text after Unknown encoding: is:

value Node, both routes Bun, toString route Bun, JSValue route
-0 -0 0 -0
1n 1n 1 1n
Symbol("x") Symbol(x) TypeError: Cannot convert a symbol to a string Symbol(x)
{ a: 1 } { a: 1 } [object Object] { a: 1 }
[1, [2, [3]]] [ 1, [Array] ] 1,2,3 [ 1, [ 2, [ 3 ] ] ]
function foo() {} function foo() {} function foo() {} [Function: foo]
() => 1 () => 1 () => 1 [Function: () => 1]
{ toString() { return "own-toString" } } own-toString own-toString { toString: [Function: toString] }
instance of a class with toString class-toString class-toString WithToString { toString: [Function: toString] }

The swap fixes four values and breaks four that match Node today. The correct fix is in the %s mode of JSValueToStringSafe: call a user-defined toString and print a function as its source, as Node's %s does. After that, every site can use the JSValue overload. That change affects every %s code, and it runs user code while the message is built. It needs its own PR and tests.

http.validateHeaderName(-0). The fixed-template table renders each argument with ToString, so it has the same differences as the toString route above. It belongs with the %s work.

determineSpecificType (type number (-0)). #38642 already has that change.

robobun added a commit that referenced this pull request Sep 17, 2026
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

Closing: #43087 carries the same JSValueToStringSafe change, so this PR is a subset of it.

The work continues in #43087.

@robobun robobun closed this Sep 17, 2026
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