From 8e85b81f22fe8b16e003f61ee0c687f3164af3ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:54:46 +0000 Subject: [PATCH 1/6] node errors: keep the sign of a received -0 in messages that format the 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. --- src/js/builtins.d.ts | 1 + src/js/node/child_process.ts | 8 +- src/js/node/http2.ts | 2 +- src/jsc/bindings/ErrorCode.cpp | 74 +++++++++++++++---- src/jsc/bindings/ErrorCode.h | 5 +- src/jsc/bindings/JSBuffer.cpp | 10 +-- src/jsc/bindings/JSStringDecoder.cpp | 6 +- src/runtime/node/node_crypto_binding.rs | 8 +- .../node/errors/error-code-messages.test.ts | 62 ++++++++++++++++ 9 files changed, 138 insertions(+), 38 deletions(-) diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 727e7e9cef4d..81ff2e830357 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -405,6 +405,7 @@ declare function $ERR_INVALID_ARG_TYPE(argName: string, expectedType: string, ac declare function $ERR_INVALID_ARG_TYPE(argName: string, expectedTypes: string[], actualValue: any): TypeError; declare function $ERR_INVALID_ARG_VALUE(name: string, value: any, reason?: string): TypeError; declare function $ERR_UNKNOWN_ENCODING(enc: string): TypeError; +declare function $ERR_UNKNOWN_SIGNAL(signal: string | number): TypeError; declare function $ERR_STREAM_DESTROYED(method: string): Error; declare function $ERR_METHOD_NOT_IMPLEMENTED(method: string): Error; declare function $ERR_STREAM_ALREADY_FINISHED(method: string): Error; diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 29eee5b7b3f2..03eb2ec4444d 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -823,7 +823,7 @@ function convertToValidSignal(signal) { if (signalName) return signalName; } - throw ERR_UNKNOWN_SIGNAL(signal); + throw $ERR_UNKNOWN_SIGNAL(signal); } function sanitizeKillSignal(killSignal) { @@ -1955,12 +1955,6 @@ function genericNodeError(message, errorProperties) { return err; } -function ERR_UNKNOWN_SIGNAL(name) { - const err = new TypeError(`Unknown signal: ${name}`); - err.code = "ERR_UNKNOWN_SIGNAL"; - return err; -} - function ERR_INVALID_OPT_VALUE(name, value) { const err = new TypeError(`The value "${value}" is invalid for option "${name}"`); err.code = "ERR_INVALID_OPT_VALUE"; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 9c7747d20737..4496ba33453e 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3852,7 +3852,7 @@ function receivedValueLabel(value) { if (typeof value === "function") return `function ${value.name}`; if (typeof value === "string") return `type string ('${value}')`; if (typeof value === "symbol") return `type symbol (${String(value)})`; - if (typeof value === "number") return `type number (${String(value)})`; + if (typeof value === "number") return `type number (${value === 0 && 1 / value < 0 ? "-0" : String(value)})`; return `type ${typeof value} (${JSON.stringify(value)})`; } diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index e3818ae41c34..304c85346964 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -12,8 +12,11 @@ #include "helpers.h" #include "JavaScriptCore/JSCJSValue.h" #include "JavaScriptCore/ErrorInstance.h" +#include "JavaScriptCore/JSFunction.h" #include "JavaScriptCore/JSString.h" #include "JavaScriptCore/JSType.h" +#include "JavaScriptCore/MathCommon.h" +#include "JavaScriptCore/ProxyObject.h" #include "JavaScriptCore/Symbol.h" #include "wtf/Assertions.h" #include "wtf/Vector.h" @@ -351,6 +354,12 @@ void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& buil { ASSERT(!arg.isEmpty()); if (!arg.isCell()) { + // util.inspect and util.format's %s both print -0 with its sign. ToString drops it. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/inspect.js#L2191-L2197 + if (arg.isDouble() && JSC::isNegativeZero(arg.asDouble())) { + builder.append("-0"_s); + return; + } builder.append(arg.toWTFStringForConsole(globalObject)); return; } @@ -412,6 +421,48 @@ void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& buil builder.append(Bun__inspect_singleline(defaultGlobalObject(globalObject), arg).transferToWTFString()); } +// util.format's %s prints an object through String() when the toString or Symbol.toPrimitive +// it would call is user code, and through util.inspect otherwise (hasBuiltInToString in +// lib/internal/util/inspect.js). +static bool hasUserToString(JSC::JSGlobalObject* globalObject, JSC::JSObject* object) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + while (auto* proxy = dynamicDowncast(object)) { + if (proxy->isRevoked()) + return false; + object = proxy->target(); + } + const JSC::Identifier* names[] = { &vm.propertyNames->toPrimitiveSymbol, &vm.propertyNames->toString }; + for (const auto* name : names) { + JSValue method = object->get(globalObject, *name); + RETURN_IF_EXCEPTION(scope, false); + if (!method.isCell()) + continue; + auto* function = dynamicDowncast(method.asCell()); + if (function && !function->isHostFunction()) + return true; + } + return false; +} + +void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSValue arg) +{ + if (arg.isObject()) { + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + bool userToString = hasUserToString(globalObject, arg.getObject()); + RETURN_IF_EXCEPTION(scope, ); + if (userToString) { + auto string = arg.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, ); + builder.append(string); + return; + } + } + JSValueToStringSafe(globalObject, builder, arg); +} + void determineSpecificType(JSC::VM& vm, JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSValue value) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -1260,20 +1311,11 @@ JSC::EncodedJSValue INVALID_FILE_URL_PATH(JSC::ThrowScope& throwScope, JSC::JSGl return {}; } -JSC::EncodedJSValue UNKNOWN_ENCODING(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::StringView encoding) -{ - MessageBuilder message; - message.append("Unknown encoding: "_s, encoding); - throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_UNKNOWN_ENCODING, message)); - throwScope.release(); - return {}; -} - JSC::EncodedJSValue UNKNOWN_ENCODING(JSC::ThrowScope& scope, JSGlobalObject* globalObject, JSValue encodingValue) { MessageBuilder builder; builder.append("Unknown encoding: "_s); - JSValueToStringSafe(globalObject, builder, encodingValue); + JSValueToStringLikeFormatS(globalObject, builder, encodingValue); RELEASE_RETURN_IF_EXCEPTION(scope, {}); scope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_UNKNOWN_ENCODING, builder)); scope.release(); @@ -1878,9 +1920,9 @@ JSC::EncodedJSValue Bun::throwError(JSC::JSGlobalObject* globalObject, JSC::Thro namespace Bun { -// Error codes whose message is fixed text around one or two stringified -// arguments; `jsFunctionMakeErrorWithCode` builds these from the table instead -// of a switch case each. +// Error codes whose message is fixed text around one or two `%s` arguments; +// `jsFunctionMakeErrorWithCode` builds these from the table instead of a switch +// case each. struct SimpleErrorMessage { Bun::ErrorCode code; uint8_t argumentCount; @@ -1926,6 +1968,7 @@ static constexpr SimpleErrorMessage simpleErrorMessages[] = { { ErrorCode::ERR_VM_MODULE_STATUS, 1, { "Module status "_s, ""_s, ""_s } }, { ErrorCode::ERR_ZSTD_INVALID_PARAM, 1, { ""_s, " is not a valid zstd parameter"_s, ""_s } }, { ErrorCode::ERR_INSPECTOR_COMMAND, 1, { "Inspector error "_s, ""_s, ""_s } }, + { ErrorCode::ERR_UNKNOWN_SIGNAL, 1, { "Unknown signal: "_s, ""_s, ""_s } }, }; static JSC::EncodedJSValue makeSimpleErrorMessage(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame, JSC::ThrowScope& scope, const SimpleErrorMessage& entry) @@ -1933,9 +1976,8 @@ static JSC::EncodedJSValue makeSimpleErrorMessage(JSC::JSGlobalObject* globalObj MessageBuilder builder; builder.append(entry.pieces[0]); for (unsigned i = 0; i < entry.argumentCount; ++i) { - auto string = callFrame->argument(i + 1).toWTFString(globalObject); + JSValueToStringLikeFormatS(globalObject, builder, callFrame->argument(i + 1)); RETURN_IF_EXCEPTION(scope, {}); - builder.append(string); builder.append(entry.pieces[i + 1]); } return JSC::JSValue::encode(createError(globalObject, entry.code, builder)); @@ -2055,7 +2097,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att auto arg0 = callFrame->argument(1); MessageBuilder builder; builder.append("Unknown encoding: "_s); - JSValueToStringSafe(globalObject, builder, arg0); + JSValueToStringLikeFormatS(globalObject, builder, arg0); RETURN_IF_EXCEPTION(scope, {}); return JSC::JSValue::encode(createError(globalObject, error, builder)); } diff --git a/src/jsc/bindings/ErrorCode.h b/src/jsc/bindings/ErrorCode.h index 84761a1c0f77..7a9764fa4891 100644 --- a/src/jsc/bindings/ErrorCode.h +++ b/src/jsc/bindings/ErrorCode.h @@ -106,6 +106,10 @@ void determineSpecificType(JSC::VM& vm, JSC::JSGlobalObject* globalObject, Messa // stringified, everything else through util.inspect. `quotesLikeInspect` quotes strings. void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSC::JSValue arg, bool quotesLikeInspect); +// Appends the value the way util.format's `%s` renders it. Differs from JSValueToStringSafe +// for an object whose toString or Symbol.toPrimitive is user code: that prints through String(). +void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSC::JSValue arg); + enum Bound { LOWER, UPPER, @@ -134,7 +138,6 @@ JSC::EncodedJSValue INVALID_ARG_VALUE(JSC::ThrowScope& throwScope, JSC::JSGlobal JSC::EncodedJSValue INVALID_ARG_VALUE_RangeError(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, WTF::ASCIILiteral name, JSC::JSValue value, const WTF::String& reason = "is invalid"_s); JSC::EncodedJSValue INVALID_ARG_VALUE(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, JSC::JSValue name, JSC::JSValue value, const WTF::String& reason = "is invalid"_s); JSC::EncodedJSValue INVALID_ARG_VALUE(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::String& name, JSC::JSValue value, const WTF::String& reason = "is invalid"_s); -JSC::EncodedJSValue UNKNOWN_ENCODING(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::StringView encoding); JSC::EncodedJSValue UNKNOWN_ENCODING(JSC::ThrowScope&, JSC::JSGlobalObject*, JSValue encodingValue); JSC::EncodedJSValue INVALID_STATE(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::String& statemsg); JSC::EncodedJSValue STRING_TOO_LONG(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject); diff --git a/src/jsc/bindings/JSBuffer.cpp b/src/jsc/bindings/JSBuffer.cpp index f9dd25d36898..326242cecaac 100644 --- a/src/jsc/bindings/JSBuffer.cpp +++ b/src/jsc/bindings/JSBuffer.cpp @@ -324,7 +324,7 @@ static WebCore::BufferEncodingType parseEncoding(JSC::ThrowScope& scope, JSC::JS Bun::V::validateString(scope, lexicalGlobalObject, arg, "encoding"_s); RETURN_IF_EXCEPTION(scope, WebCore::BufferEncodingType::utf8); } - Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, view); + Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, arg); return WebCore::BufferEncodingType::utf8; } @@ -675,11 +675,7 @@ static JSC::EncodedJSValue constructBufferFromStringAndEncoding(JSC::JSGlobalObj std::optional encoded = parseEnumeration(*lexicalGlobalObject, arg1); RETURN_IF_EXCEPTION(scope, {}); if (!encoded) { - auto* encodingString = arg1.toString(lexicalGlobalObject); - RETURN_IF_EXCEPTION(scope, {}); - const auto& view = encodingString->view(lexicalGlobalObject); - RETURN_IF_EXCEPTION(scope, {}); - return Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, view); + return Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, arg1); } encoding = encoded.value(); @@ -1850,7 +1846,7 @@ static int64_t indexOf(JSC::JSGlobalObject* lexicalGlobalObject, ThrowScope& sco if (valueValue.isString()) { if (!encoding.has_value()) { - return Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, encodingString); + return Bun::ERR::UNKNOWN_ENCODING(scope, lexicalGlobalObject, encodingValue); } auto* str = valueValue.toString(lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, -1); diff --git a/src/jsc/bindings/JSStringDecoder.cpp b/src/jsc/bindings/JSStringDecoder.cpp index 903c326d2934..13f7527fdcbc 100644 --- a/src/jsc/bindings/JSStringDecoder.cpp +++ b/src/jsc/bindings/JSStringDecoder.cpp @@ -582,11 +582,7 @@ static JSStringDecoder* createDecoder(JSC::JSGlobalObject* lexicalGlobalObject, if (opt.has_value()) { encoding = opt.value(); } else { - auto* encodingString = jsEncoding.toString(lexicalGlobalObject); - RETURN_IF_EXCEPTION(throwScope, nullptr); - const auto& view = encodingString->view(lexicalGlobalObject); - RETURN_IF_EXCEPTION(throwScope, nullptr); - Bun::ERR::UNKNOWN_ENCODING(throwScope, lexicalGlobalObject, view); + Bun::ERR::UNKNOWN_ENCODING(throwScope, lexicalGlobalObject, jsEncoding); return nullptr; } } diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index 5c76bcdec710..8fe2ed278bbc 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -348,12 +348,18 @@ pub mod random { let max: i64 = max_value.as_number().trunc() as i64; if max <= min { + // Node renders the received value with util.inspect, which keeps the sign of -0. + let mut buf = [0u8; 124]; + let received = bun_core::fmt::FormatDouble::dtoa_with_negative_zero( + &mut buf, + max_value.as_number(), + ); return Err(global .err( jsc::ErrorCode::OUT_OF_RANGE, format_args!( "The value of \"max\" is out of range. It must be greater than the value of \"min\" ({}). Received {}", - min, max + min, bstr::BStr::new(received) ), ) .throw()); diff --git a/test/js/node/errors/error-code-messages.test.ts b/test/js/node/errors/error-code-messages.test.ts index cbb3d1222d79..52a56d08f96e 100644 --- a/test/js/node/errors/error-code-messages.test.ts +++ b/test/js/node/errors/error-code-messages.test.ts @@ -1,7 +1,10 @@ import { expect, test } from "bun:test"; import child_process from "node:child_process"; +import crypto from "node:crypto"; import http from "node:http"; +import http2 from "node:http2"; import { Readable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; import tls from "node:tls"; import zlib from "node:zlib"; @@ -35,3 +38,62 @@ test("table-driven ERR_* codes keep their exact messages", () => { "ERR_METHOD_NOT_IMPLEMENTED | Error | The _read() method is not implemented", ); }); + +// Node renders the value in these messages with util.inspect, or with the %s of util.format. +// Both keep the sign of -0. Node v26.3.0 prints these exact messages. +test("a received -0 keeps its sign at sites that format the number themselves", () => { + expect({ + bufferToString: capture(() => Buffer.alloc(1).toString(-0 as any)), + stringDecoder: capture(() => new StringDecoder(-0 as any)), + setEncoding: capture(() => new Readable().setEncoding(-0 as any)), + randomInt: capture(() => crypto.randomInt(-0)), + randomIntWithMin: capture(() => crypto.randomInt(5, -0)), + getUnpackedSettings: capture(() => http2.getUnpackedSettings(-0 as any)), + validateHeaderName: capture(() => http.validateHeaderName(-0 as any)), + killSignal: capture(() => child_process.spawnSync("true", [], { killSignal: -0 as any })), + // Positive zero has no sign. + positiveZero: capture(() => crypto.randomInt(0)), + }).toEqual({ + bufferToString: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: -0", + stringDecoder: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: -0", + setEncoding: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: -0", + randomInt: + 'ERR_OUT_OF_RANGE | RangeError | The value of "max" is out of range. It must be greater than the value of "min" (0). Received -0', + randomIntWithMin: + 'ERR_OUT_OF_RANGE | RangeError | The value of "max" is out of range. It must be greater than the value of "min" (5). Received -0', + getUnpackedSettings: + 'ERR_INVALID_ARG_TYPE | TypeError | The "buf" argument must be an instance of Buffer or TypedArray. Received type number (-0)', + validateHeaderName: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["-0"]', + killSignal: "ERR_UNKNOWN_SIGNAL | TypeError | Unknown signal: -0", + positiveZero: + 'ERR_OUT_OF_RANGE | RangeError | The value of "max" is out of range. It must be greater than the value of "min" (0). Received 0', + }); +}); + +// The %s of util.format prints an object through String() when its toString or +// Symbol.toPrimitive is user code, and through util.inspect otherwise. +test("%s codes render an object like util.format", () => { + class Enc { + toString() { + return "enc!"; + } + } + const toPrimitive = { [Symbol.toPrimitive]: () => "prim" }; + expect({ + userToString: capture(() => Buffer.alloc(1).toString(new Enc() as any)), + userToPrimitive: capture(() => new StringDecoder(toPrimitive as any)), + proxied: capture(() => http.validateHeaderName(new Proxy(new Enc(), {}) as any)), + plainObject: capture(() => http.validateHeaderName({ a: 1 } as any)), + nullPrototype: capture(() => http.validateHeaderName(Object.create(null))), + builtinToString: capture(() => http.validateHeaderName(new Date(0) as any)), + }).toEqual({ + userToString: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: enc!", + userToPrimitive: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: prim", + proxied: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["enc!"]', + plainObject: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["{ a: 1 }"]', + nullPrototype: + 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["[Object: null prototype] {}"]', + builtinToString: + 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["1970-01-01T00:00:00.000Z"]', + }); +}); From d878a17b44efbdec5624ee4f8f94f4a1bbb19621 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:07:18 +0000 Subject: [PATCH 2/6] node errors: keep one throw scope across JSValueToStringLikeFormatS 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. --- src/jsc/bindings/ErrorCode.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 304c85346964..38c3976fb56a 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -448,9 +448,9 @@ static bool hasUserToString(JSC::JSGlobalObject* globalObject, JSC::JSObject* ob void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSValue arg) { + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); if (arg.isObject()) { - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); bool userToString = hasUserToString(globalObject, arg.getObject()); RETURN_IF_EXCEPTION(scope, ); if (userToString) { @@ -460,7 +460,7 @@ void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilde return; } } - JSValueToStringSafe(globalObject, builder, arg); + RELEASE_AND_RETURN(scope, JSValueToStringSafe(globalObject, builder, arg)); } void determineSpecificType(JSC::VM& vm, JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSValue value) From 5f928b8c73ea3665eaa6ee01652c235e9c610f35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:07:54 +0000 Subject: [PATCH 3/6] node errors: shorten two comments --- src/jsc/bindings/ErrorCode.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 38c3976fb56a..e2ae12d016a2 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -354,8 +354,7 @@ void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& buil { ASSERT(!arg.isEmpty()); if (!arg.isCell()) { - // util.inspect and util.format's %s both print -0 with its sign. ToString drops it. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/inspect.js#L2191-L2197 + // util.inspect and %s keep the sign of -0 (lib/internal/util/inspect.js, formatNumber). ToString drops it. if (arg.isDouble() && JSC::isNegativeZero(arg.asDouble())) { builder.append("-0"_s); return; @@ -421,9 +420,7 @@ void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& buil builder.append(Bun__inspect_singleline(defaultGlobalObject(globalObject), arg).transferToWTFString()); } -// util.format's %s prints an object through String() when the toString or Symbol.toPrimitive -// it would call is user code, and through util.inspect otherwise (hasBuiltInToString in -// lib/internal/util/inspect.js). +// The inverse of hasBuiltInToString in node's lib/internal/util/inspect.js. static bool hasUserToString(JSC::JSGlobalObject* globalObject, JSC::JSObject* object) { auto& vm = JSC::getVM(globalObject); From 597e15c1e1a1222b1ab256b24c4f102a77230ffe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:16:32 +0000 Subject: [PATCH 4/6] node errors: port hasBuiltInToString for the %s renderer, print functions 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. --- src/js/node/http2.ts | 1 + src/jsc/bindings/ErrorCode.cpp | 110 +++++++++++++++--- src/jsc/bindings/ErrorCode.h | 4 +- .../streams/ReadableStreamOperations.cpp | 2 +- src/runtime/node/node_crypto_binding.rs | 58 ++++----- .../node/errors/error-code-messages.test.ts | 35 ++++++ 6 files changed, 165 insertions(+), 45 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 4496ba33453e..8aba70b64645 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3853,6 +3853,7 @@ function receivedValueLabel(value) { if (typeof value === "string") return `type string ('${value}')`; if (typeof value === "symbol") return `type symbol (${String(value)})`; if (typeof value === "number") return `type number (${value === 0 && 1 / value < 0 ? "-0" : String(value)})`; + if (typeof value === "bigint") return `type bigint (${value}n)`; return `type ${typeof value} (${JSON.stringify(value)})`; } diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index e2ae12d016a2..2d745afdc3bd 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -420,27 +420,103 @@ void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& buil builder.append(Bun__inspect_singleline(defaultGlobalObject(globalObject), arg).transferToWTFString()); } -// The inverse of hasBuiltInToString in node's lib/internal/util/inspect.js. -static bool hasUserToString(JSC::JSGlobalObject* globalObject, JSC::JSObject* object) +// The globals that util.format's %s counts as built-in constructors: the ECMAScript ones on +// globalThis before node installs its own (lib/internal/util/inspect.js, builtInObjects). +static bool isBuiltInObjectName(const WTF::String& name) +{ + static constexpr ASCIILiteral names[] = { + "AggregateError"_s, "Array"_s, "ArrayBuffer"_s, "Atomics"_s, "BigInt"_s, "BigInt64Array"_s, + "BigUint64Array"_s, "Boolean"_s, "DataView"_s, "Date"_s, "Error"_s, "EvalError"_s, + "FinalizationRegistry"_s, "Float32Array"_s, "Float64Array"_s, "Function"_s, "Infinity"_s, + "Int16Array"_s, "Int32Array"_s, "Int8Array"_s, "Intl"_s, "Iterator"_s, "JSON"_s, "Map"_s, + "Math"_s, "NaN"_s, "Number"_s, "Object"_s, "Promise"_s, "Proxy"_s, "RangeError"_s, + "ReferenceError"_s, "Reflect"_s, "RegExp"_s, "Set"_s, "String"_s, "Symbol"_s, "SyntaxError"_s, + "TypeError"_s, "URIError"_s, "Uint16Array"_s, "Uint32Array"_s, "Uint8Array"_s, + "Uint8ClampedArray"_s, "WeakMap"_s, "WeakRef"_s, "WeakSet"_s + }; + for (const auto& candidate : names) { + if (name == candidate) + return true; + } + return false; +} + +// hasBuiltInToString from node's lib/internal/util/inspect.js. When it is false, %s prints the +// object through String(). +static bool hasBuiltInToString(JSC::JSGlobalObject* globalObject, JSC::JSObject* object) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); while (auto* proxy = dynamicDowncast(object)) { if (proxy->isRevoked()) - return false; + return true; object = proxy->target(); } - const JSC::Identifier* names[] = { &vm.propertyNames->toPrimitiveSymbol, &vm.propertyNames->toString }; - for (const auto* name : names) { - JSValue method = object->get(globalObject, *name); - RETURN_IF_EXCEPTION(scope, false); - if (!method.isCell()) - continue; - auto* function = dynamicDowncast(method.asCell()); - if (function && !function->isHostFunction()) + + const JSC::Identifier& toStringName = vm.propertyNames->toString; + const JSC::Identifier& toPrimitiveName = vm.propertyNames->toPrimitiveSymbol; + bool walkToString = true; + bool walkToPrimitive = true; + + JSValue toString = object->get(globalObject, toStringName); + RETURN_IF_EXCEPTION(scope, true); + JSValue toPrimitive = object->get(globalObject, toPrimitiveName); + RETURN_IF_EXCEPTION(scope, true); + if (!toString.isCallable()) { + if (!toPrimitive.isCallable()) return true; + walkToString = false; + } else { + bool ownToString = object->hasOwnProperty(globalObject, toStringName); + RETURN_IF_EXCEPTION(scope, true); + if (ownToString) + return false; + walkToPrimitive = toPrimitive.isCallable(); } - return false; + if (walkToPrimitive) { + bool ownToPrimitive = object->hasOwnProperty(globalObject, toPrimitiveName); + RETURN_IF_EXCEPTION(scope, true); + if (ownToPrimitive) + return false; + } + + // Find the prototype that owns the method, then ask whether its constructor is a built-in. + JSObject* pointer = object; + while (true) { + JSValue prototype = pointer->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, true); + if (!prototype.isObject()) + return true; + pointer = JSC::asObject(prototype); + bool owns = false; + if (walkToString) { + owns = pointer->hasOwnProperty(globalObject, toStringName); + RETURN_IF_EXCEPTION(scope, true); + } + if (!owns && walkToPrimitive) { + owns = pointer->hasOwnProperty(globalObject, toPrimitiveName); + RETURN_IF_EXCEPTION(scope, true); + } + if (owns) + break; + } + + JSC::PropertySlot slot(pointer, JSC::PropertySlot::InternalMethodType::GetOwnProperty); + bool hasConstructor = pointer->getOwnPropertySlot(pointer, globalObject, vm.propertyNames->constructor, slot); + RETURN_IF_EXCEPTION(scope, true); + if (!hasConstructor || slot.isAccessor()) + return false; + JSValue constructor = slot.getValue(globalObject, vm.propertyNames->constructor); + RETURN_IF_EXCEPTION(scope, true); + if (!constructor.isCallable()) + return false; + JSValue name = constructor.get(globalObject, vm.propertyNames->name); + RETURN_IF_EXCEPTION(scope, true); + if (!name.isString()) + return false; + auto nameString = name.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, true); + return isBuiltInObjectName(nameString); } void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSValue arg) @@ -448,9 +524,13 @@ void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilde auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); if (arg.isObject()) { - bool userToString = hasUserToString(globalObject, arg.getObject()); - RETURN_IF_EXCEPTION(scope, ); - if (userToString) { + // typeof "function" values go through String() in node, so a function prints its source. + bool stringify = arg.isCallable(); + if (!stringify) { + stringify = !hasBuiltInToString(globalObject, arg.getObject()); + RETURN_IF_EXCEPTION(scope, ); + } + if (stringify) { auto string = arg.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, ); builder.append(string); diff --git a/src/jsc/bindings/ErrorCode.h b/src/jsc/bindings/ErrorCode.h index 7a9764fa4891..20de153012eb 100644 --- a/src/jsc/bindings/ErrorCode.h +++ b/src/jsc/bindings/ErrorCode.h @@ -106,8 +106,8 @@ void determineSpecificType(JSC::VM& vm, JSC::JSGlobalObject* globalObject, Messa // stringified, everything else through util.inspect. `quotesLikeInspect` quotes strings. void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSC::JSValue arg, bool quotesLikeInspect); -// Appends the value the way util.format's `%s` renders it. Differs from JSValueToStringSafe -// for an object whose toString or Symbol.toPrimitive is user code: that prints through String(). +// Appends the value the way util.format's `%s` renders it: a function and an object without a +// built-in toString go through String(), any other object through util.inspect. void JSValueToStringLikeFormatS(JSC::JSGlobalObject* globalObject, MessageBuilder& builder, JSC::JSValue arg); enum Bound { diff --git a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp index 05dbedda9385..4b7b0a01c3f9 100644 --- a/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp +++ b/src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp @@ -809,7 +809,7 @@ JSReadableStream* createReadableByteStream(JSGlobalObject* globalObject, SourceK static void throwNotIterable(JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSValue iterable) { Bun::MessageBuilder builder; - Bun::JSValueToStringSafe(globalObject, builder, iterable, false); + Bun::JSValueToStringLikeFormatS(globalObject, builder, iterable); RETURN_IF_EXCEPTION(scope, ); builder.append(" must be iterable"_s); Bun::throwError(globalObject, scope, Bun::ErrorCode::ERR_ARG_NOT_ITERABLE, builder); diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index 8fe2ed278bbc..f696c19d163a 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -232,6 +232,30 @@ pub mod random { }; const MAX_RANGE: i64 = 0xffff_ffff_ffff; + // The "Received" value of ERR_OUT_OF_RANGE. Node adds "_" numerical + // separators to an integer whose magnitude exceeds 2^32 + // (lib/internal/errors.js, addNumericalSeparator). + fn received_integer(value: i64) -> String { + let digits = value.to_string(); + if value.unsigned_abs() <= 1 << 32 { + return digits; + } + let (sign, digits) = match digits.strip_prefix('-') { + Some(rest) => ("-", rest), + None => ("", digits.as_str()), + }; + let mut out = String::with_capacity(digits.len() + digits.len() / 3 + 1); + out.push_str(sign); + let lead = digits.len() % 3; + for (i, ch) in digits.chars().enumerate() { + if i != 0 && (i + 3 - lead) % 3 == 0 { + out.push('_'); + } + out.push(ch); + } + out + } + impl JobContext for RandomFillJob { type OffThread = Self; type Js = RandomFillJs; @@ -348,44 +372,24 @@ pub mod random { let max: i64 = max_value.as_number().trunc() as i64; if max <= min { - // Node renders the received value with util.inspect, which keeps the sign of -0. - let mut buf = [0u8; 124]; - let received = bun_core::fmt::FormatDouble::dtoa_with_negative_zero( - &mut buf, - max_value.as_number(), - ); + let received = if max_value.as_number().is_sign_negative() && max == 0 { + String::from("-0") + } else { + received_integer(max) + }; return Err(global .err( jsc::ErrorCode::OUT_OF_RANGE, format_args!( "The value of \"max\" is out of range. It must be greater than the value of \"min\" ({}). Received {}", - min, bstr::BStr::new(received) + min, received ), ) .throw()); } if max - min > MAX_RANGE { - // Node's ERR_OUT_OF_RANGE adds "_" numerical separators to integer - // "Received" values whose magnitude exceeds 2^32 - // (lib/internal/errors.js, addNumericalSeparator). - let received = { - let digits = (max - min).to_string(); - let (sign, digits) = match digits.strip_prefix('-') { - Some(rest) => ("-", rest), - None => ("", digits.as_str()), - }; - let mut out = String::with_capacity(digits.len() + digits.len() / 3 + 1); - out.push_str(sign); - let lead = digits.len() % 3; - for (i, ch) in digits.chars().enumerate() { - if i != 0 && (i + 3 - lead) % 3 == 0 { - out.push('_'); - } - out.push(ch); - } - out - }; + let received = received_integer(max - min); if min_specified { return Err(global .err( diff --git a/test/js/node/errors/error-code-messages.test.ts b/test/js/node/errors/error-code-messages.test.ts index 52a56d08f96e..f56043480f52 100644 --- a/test/js/node/errors/error-code-messages.test.ts +++ b/test/js/node/errors/error-code-messages.test.ts @@ -86,6 +86,16 @@ test("%s codes render an object like util.format", () => { plainObject: capture(() => http.validateHeaderName({ a: 1 } as any)), nullPrototype: capture(() => http.validateHeaderName(Object.create(null))), builtinToString: capture(() => http.validateHeaderName(new Date(0) as any)), + ownBoundToString: capture(() => http.validateHeaderName({ toString: (() => "bound").bind(null) } as any)), + inheritedToString: capture(() => http.validateHeaderName(new (class extends Enc {})() as any)), + inheritedToPrimitive: capture(() => http.validateHeaderName({ __proto__: toPrimitive } as any)), + // Only the ECMAScript globals count as built-in constructors. Buffer, TypedArray and URL do not. + buffer: capture(() => http.validateHeaderName(Buffer.from("a") as any)), + uint8Array: capture(() => http.validateHeaderName(new Uint8Array([1]) as any)), + url: capture(() => http.validateHeaderName(new URL("http://x/") as any)), + toStringNotCallable: capture(() => http.validateHeaderName({ toString: 5 } as any)), + fn: capture(() => http.validateHeaderName(function foo() {} as any)), + notIterable: capture(() => ReadableStream.from(new Enc() as any)), }).toEqual({ userToString: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: enc!", userToPrimitive: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: prim", @@ -95,5 +105,30 @@ test("%s codes render an object like util.format", () => { 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["[Object: null prototype] {}"]', builtinToString: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["1970-01-01T00:00:00.000Z"]', + ownBoundToString: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["bound"]', + inheritedToString: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["enc!"]', + inheritedToPrimitive: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["prim"]', + buffer: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["a"]', + uint8Array: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["1"]', + url: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["http://x/"]', + toStringNotCallable: + 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["{ toString: 5 }"]', + fn: 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["function foo() {}"]', + notIterable: "ERR_ARG_NOT_ITERABLE | TypeError | enc! must be iterable", + }); +}); + +test("randomInt renders a large received max with numerical separators like node", () => { + expect({ + large: capture(() => crypto.randomInt(2 ** 40, 2 ** 40 - 1)), + negative: capture(() => crypto.randomInt(1, -5_000_000_000)), + bigintBuf: capture(() => http2.getUnpackedSettings(1n as any)), + }).toEqual({ + large: + 'ERR_OUT_OF_RANGE | RangeError | The value of "max" is out of range. It must be greater than the value of "min" (1099511627776). Received 1_099_511_627_775', + negative: + 'ERR_OUT_OF_RANGE | RangeError | The value of "max" is out of range. It must be greater than the value of "min" (1). Received -5_000_000_000', + bigintBuf: + 'ERR_INVALID_ARG_TYPE | TypeError | The "buf" argument must be an instance of Buffer or TypedArray. Received type bigint (1n)', }); }); From efd2be99d24d3cb9a41caaaedf9faee6c25a9d60 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:17:20 +0000 Subject: [PATCH 5/6] node errors: shorten three comments --- src/jsc/bindings/ErrorCode.cpp | 6 ++---- src/runtime/node/node_crypto_binding.rs | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 2d745afdc3bd..60ff51727f05 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -420,8 +420,7 @@ void JSValueToStringSafe(JSC::JSGlobalObject* globalObject, MessageBuilder& buil builder.append(Bun__inspect_singleline(defaultGlobalObject(globalObject), arg).transferToWTFString()); } -// The globals that util.format's %s counts as built-in constructors: the ECMAScript ones on -// globalThis before node installs its own (lib/internal/util/inspect.js, builtInObjects). +// builtInObjects in node's lib/internal/util/inspect.js: the ECMAScript globals only. static bool isBuiltInObjectName(const WTF::String& name) { static constexpr ASCIILiteral names[] = { @@ -441,8 +440,7 @@ static bool isBuiltInObjectName(const WTF::String& name) return false; } -// hasBuiltInToString from node's lib/internal/util/inspect.js. When it is false, %s prints the -// object through String(). +// hasBuiltInToString in node's lib/internal/util/inspect.js. static bool hasBuiltInToString(JSC::JSGlobalObject* globalObject, JSC::JSObject* object) { auto& vm = JSC::getVM(globalObject); diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index f696c19d163a..898fb18e5cf4 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -232,9 +232,7 @@ pub mod random { }; const MAX_RANGE: i64 = 0xffff_ffff_ffff; - // The "Received" value of ERR_OUT_OF_RANGE. Node adds "_" numerical - // separators to an integer whose magnitude exceeds 2^32 - // (lib/internal/errors.js, addNumericalSeparator). + // addNumericalSeparator in node's lib/internal/errors.js, applied past 2^32. fn received_integer(value: i64) -> String { let digits = value.to_string(); if value.unsigned_abs() <= 1 << 32 { From 4e69367bb5ff243cf4c921d734d3738887102434 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:13:45 +0000 Subject: [PATCH 6/6] node errors: assert the -0 rows for the shared value renderer 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. --- .../node/errors/error-code-messages.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/js/node/errors/error-code-messages.test.ts b/test/js/node/errors/error-code-messages.test.ts index f56043480f52..b0b62a370280 100644 --- a/test/js/node/errors/error-code-messages.test.ts +++ b/test/js/node/errors/error-code-messages.test.ts @@ -3,7 +3,8 @@ import child_process from "node:child_process"; import crypto from "node:crypto"; import http from "node:http"; import http2 from "node:http2"; -import { Readable } from "node:stream"; +import { createHistogram } from "node:perf_hooks"; +import { Readable, Writable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; import tls from "node:tls"; import zlib from "node:zlib"; @@ -41,6 +42,35 @@ test("table-driven ERR_* codes keep their exact messages", () => { // Node renders the value in these messages with util.inspect, or with the %s of util.format. // Both keep the sign of -0. Node v26.3.0 prints these exact messages. +test("a received -0 keeps its sign at sites that use the shared value renderer", () => { + expect({ + // ERR_OUT_OF_RANGE thrown from C++, with numeric bounds and with a range string. + readUIntBE: capture(() => Buffer.alloc(8).readUIntBE(0, -0)), + percentile: capture(() => createHistogram().percentile(-0)), + // ERR_OUT_OF_RANGE thrown from JS ($ERR_OUT_OF_RANGE). + figures: capture(() => createHistogram({ figures: -0 })), + // ERR_INVALID_ARG_VALUE. + paramEncoding: capture(() => crypto.generateKeyPairSync("ec", { namedCurve: "P-256", paramEncoding: -0 as any })), + // %s codes. + setDefaultEncoding: capture(() => new Writable().setDefaultEncoding(-0 as any)), + readableStreamFrom: capture(() => ReadableStream.from(-0 as any)), + // Positive zero has no sign. + positiveZero: capture(() => Buffer.alloc(8).readUIntBE(0, 0)), + }).toEqual({ + readUIntBE: + 'ERR_OUT_OF_RANGE | RangeError | The value of "byteLength" is out of range. It must be >= 1 and <= 6. 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", + setDefaultEncoding: "ERR_UNKNOWN_ENCODING | TypeError | Unknown encoding: -0", + readableStreamFrom: "ERR_ARG_NOT_ITERABLE | TypeError | -0 must be iterable", + positiveZero: + 'ERR_OUT_OF_RANGE | RangeError | The value of "byteLength" is out of range. It must be >= 1 and <= 6. Received 0', + }); +}); + test("a received -0 keeps its sign at sites that format the number themselves", () => { expect({ bufferToString: capture(() => Buffer.alloc(1).toString(-0 as any)),