diff --git a/bench/snippets/node-error-code.mjs b/bench/snippets/node-error-code.mjs new file mode 100644 index 000000000000..0f4fe465a2a8 --- /dev/null +++ b/bench/snippets/node-error-code.mjs @@ -0,0 +1,58 @@ +// What it costs to make, throw and catch an error that has a Node `code`, for each way into the table of codes. +import { EventEmitter } from "node:events"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { bench, run } from "../runner.mjs"; + +const emitter = new EventEmitter(); +const aborted = AbortSignal.abort(); +const errors = Array.from({ length: 1024 }, () => { + try { + Buffer.alloc(-1); + } catch (e) { + return e; + } +}); + +bench("throw new RangeError() (no code)", () => { + try { + throw new RangeError("x"); + } catch {} +}); + +bench("Buffer.alloc(-1): ERR_OUT_OF_RANGE from C++", () => { + try { + Buffer.alloc(-1); + } catch {} +}); + +bench("path.join(1): ERR_INVALID_ARG_TYPE from Rust", () => { + try { + join(1); + } catch {} +}); + +bench("emitter.on('a', 1): ERR_INVALID_ARG_TYPE from a built-in module", () => { + try { + emitter.on("a", 1); + } catch {} +}); + +bench("process.cpuUsage({ user: -1 }): the RangeError of ERR_INVALID_ARG_VALUE", () => { + try { + process.cpuUsage({ user: -1, system: 0 }); + } catch {} +}); + +bench("timers/promises setTimeout with an aborted signal: ABORT_ERR", async () => { + try { + await sleep(1, undefined, { signal: aborted }); + } catch {} +}); + +let index = 0; +bench("read error.code", () => { + return errors[index++ & 1023].code; +}); + +await run(); diff --git a/src/codegen/builtins/host/private-globals.builtins.ts b/src/codegen/builtins/host/private-globals.builtins.ts index 36e0309f3bd0..18e97eb12569 100644 --- a/src/codegen/builtins/host/private-globals.builtins.ts +++ b/src/codegen/builtins/host/private-globals.builtins.ts @@ -29,11 +29,12 @@ const asksCellForItsClass = { } satisfies b.FunctionField; // ErrorCode.cpp: an error with a code is an instance of the kind of error the code stands for, with an object of its own for each code in front of the prototype of that kind. +// The error has `code` itself. That object has `name` and `toString`, and an error whose name is not the name of its kind has `name` itself too. const kindsOfErrorsWithCode = ["Error", "TypeError", "RangeError", "SyntaxError", "URIError"]; const [firstKind, ...otherKinds] = kindsOfErrorsWithCode.map(kind => b.instance(kind)); const newErrorWithCode = b.union(firstKind, ...otherKinds).unaliased; const printsErrorWithCode = b.builtin("%NativeFunctions%.ErrorCodeCppNodeError_proto_toString"); -const membersInheritedByErrorWithCode = { name: b.string, code: b.string, toString: printsErrorWithCode }; +const membersOfErrorWithCode = { name: b.string, code: b.string, toString: printsErrorWithCode }; // The message reads constructor.name of a value, converts it to a string, or prints it the way console.log does. const formattedInMessage = [b.printedByFormatter("constructor")]; @@ -67,7 +68,7 @@ const makesErrorWithCode = [ args: { code: b.int(first, last), operands: b.any.rest }, returns: newErrorWithCode, // ERR_SSL_NO_CIPHER_MATCH. - resultProperties: { ...membersInheritedByErrorWithCode, reason: b.string, library: b.string }, + resultProperties: { ...membersOfErrorWithCode, reason: b.string, library: b.string }, capability: "js", throws: true, runsOnlyOperandCode: true, @@ -82,7 +83,7 @@ const makesAbortError = [ { args: { message: b.string.optional, options: b.undefined.optional }, returns: b.instance("Error").unaliased, - resultProperties: membersInheritedByErrorWithCode, + resultProperties: membersOfErrorWithCode, capability: "heap", implementation: b.hostCall, }, @@ -112,7 +113,7 @@ const looksAtPromise = (returns: b.TableType, otherwise: b.TableType, uses: read const closedPromise = b.instance("Promise").holding({ value: b.undefined, reason: b.any }); export default b.declarations( - // Each of those objects has a function of its own, and one entry stands for them all. It reads the three members of its receiver and converts each to a string. + // Each of those objects has a function of its own, and one entry stands for them all. It reads the name and the message of its receiver and converts each to a string. The code it prints is the one its function was made with, and the function of a SystemError code reads the code of its receiver. b.hostFunction(printsErrorWithCodeCell, "ErrorCode.cpp", "NodeError_proto_toString", { this: b.any, returns: b.string, diff --git a/src/codegen/generate-node-errors.ts b/src/codegen/generate-node-errors.ts index 2c6adf6226d6..2deab5436945 100644 --- a/src/codegen/generate-node-errors.ts +++ b/src/codegen/generate-node-errors.ts @@ -19,6 +19,11 @@ if (count > 1 << 16) { throw new Error(`NodeError can't fit ${count} codes in a u16`); } +// A name that is not the name of the row's constructor is a property of the error itself, as in Node's AbortError and UnhandledPromiseRejection: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L985-L986 and https://github.com/nodejs/node/blob/v26.3.0/lib/internal/process/promises.js#L47-L48 +const ownNames = [ + ...new Set(NodeErrors.flatMap(([, constructor, name]) => (name != null && name !== constructor.name ? [name] : []))), +].sort(); + let enumHeader = ``; let listHeader = ``; let rustConsts = ``; @@ -35,6 +40,7 @@ enumHeader = ` namespace Bun { static constexpr size_t NODE_ERROR_COUNT = ${count}; + static constexpr size_t NODE_ERROR_OWN_NAME_COUNT = ${ownNames.length}; enum class ErrorCode : uint16_t { `; @@ -45,12 +51,19 @@ listHeader = ` #include +enum class ErrorOwnName : uint8_t { None, ${ownNames.join(", ")} }; + struct ErrorCodeData { JSC::ErrorType type; + // Not None: an error of this row gets \`name\` as a property of its own. + ErrorOwnName ownName; WTF::ASCIILiteral name; WTF::ASCIILiteral code; }; -static constexpr ErrorCodeData errors[${count}] = { + +const ErrorCodeData& ErrorCodeCache::dataFor(ErrorCode code) +{ + static constexpr ErrorCodeData errors[${count}] = { `; let i = 0; @@ -61,7 +74,7 @@ for (let [code, constructor, name, ...other_constructors] of NodeErrors) { const codeWithoutPrefix = code === "ERR_MODULE_NOT_FOUND" ? code : code.replace(/^ERR_/, ""); enumHeader += ` ${code} = ${i},\n`; - listHeader += ` { JSC::ErrorType::${constructor.name}, "${name}"_s, "${code}"_s },\n`; + listHeader += ` { JSC::ErrorType::${constructor.name}, ErrorOwnName::${name === constructor.name ? "None" : name}, "${name}"_s, "${code}"_s },\n`; rustConsts += ` /// \`${code}\` (instanceof ${constructor.name})\n`; rustConsts += ` pub const ${codeWithoutPrefix}: ErrorCode = ErrorCode(${i});\n`; if (codeWithoutPrefix !== code) { @@ -74,7 +87,7 @@ for (let [code, constructor, name, ...other_constructors] of NodeErrors) { if (con == null) continue; if (name == null) name = con.name; enumHeader += ` ${code}_${con.name} = ${i},\n`; - listHeader += ` { JSC::ErrorType::${con.name}, "${con.name}"_s, "${code}"_s },\n`; + listHeader += ` { JSC::ErrorType::${con.name}, ErrorOwnName::None, "${con.name}"_s, "${code}"_s },\n`; rustConsts += ` /// \`${code}\` (instanceof ${con.name})\n`; rustConsts += ` pub const ${codeWithoutPrefix}_${con.name}: ErrorCode = ErrorCode(${i});\n`; rustCodeStr += ` "${code}",\n`; @@ -87,8 +100,9 @@ enumHeader += ` } // namespace Bun `; -listHeader += ` -}; +listHeader += ` }; + return errors[static_cast(code)]; +} `; const rustSource = `\ diff --git a/src/js/internal/fs/cp-sync.ts b/src/js/internal/fs/cp-sync.ts index 981592c5cc26..c03a83c58d29 100644 --- a/src/js/internal/fs/cp-sync.ts +++ b/src/js/internal/fs/cp-sync.ts @@ -61,7 +61,6 @@ function decorateSystemError(err: SystemError, prefix: string, context: SystemEr if (path !== undefined) message += ` ${path}`; if (dest !== undefined) message += ` => ${dest}`; err.message = message; - err.name = "SystemError"; err.info = context; err.errno = errno; err.syscall = syscall; diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 3b0faef5b83d..9f454345b8fc 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1540,7 +1540,7 @@ class ChildProcess extends EventEmitter { } } catch (ex) { const exCode = - ex != null && typeof ex === "object" && Object.hasOwn(ex, "code") ? (ex as SystemError).code : undefined; + ex != null && typeof ex === "object" && ObjectHasOwn(ex, "code") ? (ex as SystemError).code : undefined; if ( // node sends these errors on the next tick rather than throwing exCode === "EACCES" || @@ -1563,7 +1563,8 @@ class ChildProcess extends EventEmitter { this.#stdioOptions[2] = "undefined"; } } else { - if (exCode !== undefined) { + // Only an errno error is an ErrnoException(err, "spawn") in Node: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L440. An ERR_* validation error has an own `code` too. + if (exCode !== undefined && ObjectHasOwn(ex as SystemError, "errno")) { // Node throws errors that are not in the deferred list above // synchronously, with `syscall: "spawn"` (no file appended). (ex as SystemError).syscall = "spawn"; diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index c2ebd56fccd4..b4f104b73ca1 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -58,10 +58,16 @@ JSC_DEFINE_HOST_FUNCTION(NodeError_proto_toString, (JSC::JSGlobalObject * global auto scope = DECLARE_THROW_SCOPE(vm); auto thisVal = callFrame->thisValue().toThis(globalObject, JSC::ECMAMode::strict()); + // The function of a code holds the code, as the toString of Node's NodeError closes over it: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L462-L464 + JSC::JSValue code = callFrame->jsCallee()->getDirect(vm, WebCore::builtinNames(vm).codePrivateName()); + auto name = thisVal.get(globalObject, vm.propertyNames->name); RETURN_IF_EXCEPTION(scope, {}); - auto code = thisVal.get(globalObject, WebCore::builtinNames(vm).codePublicName()); - RETURN_IF_EXCEPTION(scope, {}); + // The function of a SystemError code holds none, as that toString reads `this.code`: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L386-L388 + if (!code) { + code = thisVal.get(globalObject, WebCore::builtinNames(vm).codePublicName()); + RETURN_IF_EXCEPTION(scope, {}); + } auto message = thisVal.get(globalObject, vm.propertyNames->message); RETURN_IF_EXCEPTION(scope, {}); @@ -107,11 +113,13 @@ namespace Bun { using namespace JSC; using namespace WTF; -static JSC::JSObject* createErrorPrototype(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::ErrorType type, WTF::ASCIILiteral name, WTF::ASCIILiteral code) +#include "ErrorCode+Data.h" + +JSC::JSObject* ErrorCodeCache::createPrototype(JSC::VM& vm, JSC::JSGlobalObject* globalObject, const ErrorCodeData& data, JSC::JSString* code) { JSC::JSObject* prototype; - switch (type) { + switch (data.type) { case JSC::ErrorType::TypeError: prototype = JSC::constructEmptyObject(globalObject, globalObject->m_typeErrorStructure.prototype(globalObject)); break; @@ -141,17 +149,28 @@ static JSC::JSObject* createErrorPrototype(JSC::VM& vm, JSC::JSGlobalObject* glo break; } - prototype->putDirect(vm, vm.propertyNames->name, jsString(vm, String(name)), 0); - prototype->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), jsString(vm, String(code)), 0); - prototype->putDirect(vm, vm.propertyNames->toString, JSC::JSFunction::create(vm, globalObject, 0, "toString"_s, NodeError_proto_toString, JSC::ImplementationVisibility::Private), 0); + auto* toString = JSC::JSFunction::create(vm, globalObject, 0, "toString"_s, NodeError_proto_toString, JSC::ImplementationVisibility::Private); + if (data.ownName != ErrorOwnName::SystemError) + toString->putDirect(vm, WebCore::builtinNames(vm).codePrivateName(), code, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete); + + // The name of the kind, also where that is not "Error": ErrorInstance::sanitizedNameString() looks at the error and at this object only, and then answers "Error". + prototype->putDirect(vm, vm.propertyNames->name, jsString(vm, String(JSC::errorTypeName(data.type))), JSC::PropertyAttribute::DontEnum | 0); + prototype->putDirect(vm, vm.propertyNames->toString, toString, JSC::PropertyAttribute::DontEnum | 0); return prototype; } -#include "ErrorCode+Data.h" +static unsigned ownNameField(ErrorOwnName name) +{ + ASSERT(name != ErrorOwnName::None); + return 2 * NODE_ERROR_COUNT + static_cast(name) - 1; +} const ClassInfo ErrorCodeCache::s_info = { "ErrorCodeCache"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(ErrorCodeCache) }; +// The cache is one cell of an IsoSubspace, and such a cell has to fit in a MarkedBlock. +static_assert(sizeof(ErrorCodeCache) <= JSC::MarkedBlock::payloadSize); + ErrorCodeCache::ErrorCodeCache(VM& vm, Structure* structure) : Base(vm, structure) { @@ -184,7 +203,7 @@ void ErrorCodeCache::finishCreation(VM& vm) Base::finishCreation(vm); ASSERT(inherits(info())); - for (unsigned i = 0; i < NODE_ERROR_COUNT; i++) { + for (unsigned i = 0; i < numberOfInternalFields; i++) { this->internalField(i).clear(); } } @@ -195,23 +214,32 @@ static ErrorCodeCache* errorCache(Zig::GlobalObject* globalObject) } // clang-format on -static Structure* createErrorStructure(JSC::VM& vm, JSGlobalObject* globalObject, JSC::ErrorType type, WTF::ASCIILiteral name, WTF::ASCIILiteral code) +JSObject* ErrorCodeCache::createInstance(VM& vm, Zig::GlobalObject* globalObject, ErrorCode code, const String& message, JSValue cause) { - auto* prototype = createErrorPrototype(vm, globalObject, type, name, code); - return ErrorInstance::createStructure(vm, globalObject, prototype); + const auto& data = dataFor(code); + auto& structureField = internalField(static_cast(code)); + auto& codeField = internalField(NODE_ERROR_COUNT + static_cast(code)); + if (!structureField) { + auto* codeString = jsString(vm, String(data.code)); + codeField.set(vm, this, codeString); + if (data.ownName != ErrorOwnName::None && !internalField(ownNameField(data.ownName))) + internalField(ownNameField(data.ownName)).set(vm, this, jsString(vm, String(data.name))); + auto* prototype = createPrototype(vm, globalObject, data, codeString); + structureField.set(vm, this, ErrorInstance::createStructure(vm, globalObject, prototype)); + } + + auto* error = ErrorInstance::create(vm, uncheckedDowncast(structureField.get()), message, cause, nullptr, RuntimeType::TypeNothing, data.type, true); + // `code` is a class field of Node's errors, so it is own, enumerable, and ahead of what a producer adds: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L441 + error->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), codeField.get(), 0); + // Node's SystemError is the one whose own `name` is not enumerable: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L303-L309 + if (data.ownName != ErrorOwnName::None) + error->putDirect(vm, vm.propertyNames->name, internalField(ownNameField(data.ownName)).get(), data.ownName == ErrorOwnName::SystemError ? static_cast(PropertyAttribute::DontEnum) : 0); + return error; } JSObject* ErrorCodeCache::createError(VM& vm, Zig::GlobalObject* globalObject, ErrorCode code, JSValue message, JSValue options) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - auto* cache = errorCache(globalObject); - const auto& data = errors[static_cast(code)]; - if (!cache->internalField(static_cast(code))) { - auto* structure = createErrorStructure(vm, globalObject, data.type, data.name, data.code); - cache->internalField(static_cast(code)).set(vm, cache, structure); - } - - auto* structure = uncheckedDowncast(cache->internalField(static_cast(code)).get()); // Convert the message and `cause` here rather than in ErrorInstance::create(JSGlobalObject*, ...), // which hands back nullptr when that conversion is interrupted: every caller throws or rejects with @@ -231,12 +259,13 @@ JSObject* ErrorCodeCache::createError(VM& vm, Zig::GlobalObject* globalObject, E return object; } } - return JSC::ErrorInstance::create(vm, structure, messageString, cause, nullptr, JSC::RuntimeType::TypeNothing, data.type, true); + return createInstance(vm, globalObject, code, messageString, cause); } NEVER_INLINE JSObject* createError(VM& vm, Zig::GlobalObject* globalObject, ErrorCode code, const String& message) { - return errorCache(globalObject)->createError(vm, globalObject, code, jsString(vm, message), jsUndefined()); + // Every caller means an own `message`: a null String stands for the empty one. + return errorCache(globalObject)->createInstance(vm, globalObject, code, message.isNull() ? emptyString() : message, JSValue()); } JSObject* createError(Zig::GlobalObject* globalObject, ErrorCode code, const String& message) @@ -251,15 +280,7 @@ NEVER_INLINE JSObject* createError(VM& vm, JSC::JSGlobalObject* globalObject, Er JSObject* createError(VM& vm, JSC::JSGlobalObject* globalObject, ErrorCode code, JSValue message) { - if (auto* zigGlobalObject = dynamicDowncast(globalObject)) - return createError(vm, zigGlobalObject, code, message, jsUndefined()); - - auto* structure = createErrorStructure(vm, globalObject, errors[static_cast(code)].type, errors[static_cast(code)].name, errors[static_cast(code)].code); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - String messageString = message.isUndefined() ? String() : message.toWTFString(globalObject); - if (scope.exception() && !vm.hasPendingTerminationException()) - (void)scope.tryClearException(); - return JSC::ErrorInstance::create(vm, structure, messageString, JSValue(), nullptr, JSC::RuntimeType::TypeNothing, errors[static_cast(code)].type, true); + return createError(vm, defaultGlobalObject(globalObject), code, message, jsUndefined()); } JSC::JSObject* createError(VM& vm, Zig::GlobalObject* globalObject, ErrorCode code, JSValue message, JSValue options) @@ -1315,7 +1336,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) { - auto& vm = JSC::getVM(globalObject); ASCIILiteral type = StringView(name).contains('.') ? "property"_s : "argument"_s; MessageBuilder builder; @@ -1329,12 +1349,7 @@ JSC::EncodedJSValue INVALID_ARG_VALUE_RangeError(JSC::ThrowScope& throwScope, JS JSValueToStringSafe(globalObject, builder, value, true); RELEASE_RETURN_IF_EXCEPTION(throwScope, {}); - auto message = builder.finish(globalObject, throwScope); - RELEASE_RETURN_IF_EXCEPTION(throwScope, {}); - - auto* structure = createErrorStructure(vm, globalObject, ErrorType::RangeError, "RangeError"_s, "ERR_INVALID_ARG_VALUE"_s); - auto error = JSC::ErrorInstance::create(vm, structure, message, jsUndefined(), nullptr, JSC::RuntimeType::TypeNothing, ErrorType::RangeError, true); - throwScope.throwException(globalObject, error); + throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_INVALID_ARG_VALUE_RangeError, builder)); throwScope.release(); return {}; } diff --git a/src/jsc/bindings/ErrorCode.h b/src/jsc/bindings/ErrorCode.h index 276fd6a19068..eb8ac403613d 100644 --- a/src/jsc/bindings/ErrorCode.h +++ b/src/jsc/bindings/ErrorCode.h @@ -57,9 +57,12 @@ class MessageBuilder final : public WTF::StringBuilder { // For a message built with `tryMakeString`: a null `message` did not fit, and is thrown as `RangeError: Out of memory`. void throwTypeErrorOrOutOfMemory(JSC::JSGlobalObject*, JSC::ThrowScope&, const WTF::String& message); -class ErrorCodeCache : public JSC::JSInternalFieldObjectImpl { +struct ErrorCodeData; + +// Field `code` holds the Structure of the errors of that code, and field `NODE_ERROR_COUNT + code` the string each of them gets as its own `code`. The last fields hold the names that an error can get as its own `name`. +class ErrorCodeCache : public JSC::JSInternalFieldObjectImpl<2 * NODE_ERROR_COUNT + NODE_ERROR_OWN_NAME_COUNT> { public: - using Base = JSInternalFieldObjectImpl; + using Base = JSInternalFieldObjectImpl<2 * NODE_ERROR_COUNT + NODE_ERROR_OWN_NAME_COUNT>; using Field = ErrorCode; DECLARE_EXPORT_INFO; @@ -75,12 +78,18 @@ class ErrorCodeCache : public JSC::JSInternalFieldObjectImpl { static ErrorCodeCache* create(VM& vm, Structure* structure); static Structure* createStructure(VM& vm, JSGlobalObject* globalObject); + // The one place that makes an error from the table. It runs no JavaScript. A null `message` makes an error with no own `message`, an empty `cause` one with no own `cause`. + JSObject* createInstance(VM& vm, Zig::GlobalObject* globalObject, ErrorCode code, const String& message, JSValue cause); + // Converts `message` to a string and reads `options.cause` first. What either of them throws is returned in place of the error. JSObject* createError(VM& vm, Zig::GlobalObject* globalObject, ErrorCode code, JSValue message, JSValue options); private: JS_EXPORT_PRIVATE ErrorCodeCache(VM&, Structure*); DECLARE_VISIT_CHILDREN_WITH_MODIFIER(JS_EXPORT_PRIVATE); void finishCreation(VM&); + // Private, so that createInstance() stays the only reader of the table and the only maker of the prototype of a code. + static const ErrorCodeData& dataFor(ErrorCode); + static JSObject* createPrototype(VM&, JSGlobalObject*, const ErrorCodeData&, JSString* code); }; JSC::EncodedJSValue throwError(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, ErrorCode code, const WTF::String& message); diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 3f27b3a32c67..7b9f71cec999 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -68,13 +68,13 @@ const errors: ErrorCodeMapping = [ ["ERR_EVENT_RECURSION", Error], ["ERR_FEATURE_UNAVAILABLE_ON_PLATFORM", TypeError], ["ERR_FORMDATA_PARSE_ERROR", TypeError], - ["ERR_FS_CP_DIR_TO_NON_DIR", Error], - ["ERR_FS_CP_EINVAL", Error], - ["ERR_FS_CP_FIFO_PIPE", Error], - ["ERR_FS_CP_NON_DIR_TO_DIR", Error], - ["ERR_FS_CP_SOCKET", Error], - ["ERR_FS_CP_UNKNOWN", Error], - ["ERR_FS_EISDIR", Error], + ["ERR_FS_CP_DIR_TO_NON_DIR", Error, "SystemError"], + ["ERR_FS_CP_EINVAL", Error, "SystemError"], + ["ERR_FS_CP_FIFO_PIPE", Error, "SystemError"], + ["ERR_FS_CP_NON_DIR_TO_DIR", Error, "SystemError"], + ["ERR_FS_CP_SOCKET", Error, "SystemError"], + ["ERR_FS_CP_UNKNOWN", Error, "SystemError"], + ["ERR_FS_EISDIR", Error, "SystemError"], ["ERR_HTTP_BODY_NOT_ALLOWED", Error], ["ERR_HTTP_HEADERS_SENT", Error], ["ERR_HTTP_CONTENT_LENGTH_MISMATCH", Error], @@ -327,8 +327,8 @@ const errors: ErrorCodeMapping = [ ["ERR_HTTP2_GOAWAY_SESSION", Error], ["ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", TypeError], ["ERR_PROXY_TUNNEL", Error], - ["ERR_FS_CP_EEXIST", Error], - ["ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", Error], + ["ERR_FS_CP_EEXIST", Error, "SystemError"], + ["ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", Error, "SystemError"], ["ERR_DIR_CONCURRENT_OPERATION", Error], ["ERR_INVALID_BUFFER_SIZE", RangeError], ["ERR_TRACE_EVENTS_CATEGORY_REQUIRED", TypeError], diff --git a/test/js/node/child_process/child_process-node.test.js b/test/js/node/child_process/child_process-node.test.js index 4a5b29897903..c934ca096b51 100644 --- a/test/js/node/child_process/child_process-node.test.js +++ b/test/js/node/child_process/child_process-node.test.js @@ -1,7 +1,7 @@ import { bunEnv, bunExe, isWindows } from "harness"; import { createTest } from "node-harness"; import { ChildProcess, exec, fork, spawn } from "node:child_process"; -import { tmpdir } from "node:os"; +import { constants, tmpdir } from "node:os"; import path from "node:path"; import util from "node:util"; const { beforeAll, beforeEach, afterAll, describe, expect, it, throws, assert, createCallCheckCtx, createDoneDotAll } = @@ -178,6 +178,45 @@ describe("ChildProcess.spawn", () => { }); }); +// Node gives `syscall: "spawn"` to the errno errors that spawn() throws: +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/child_process.js#L440 +// An ERR_* error from the validation of an option is not one of them, though it has an own `code` too. +describe("errors that spawn() throws", () => { + function thrownBy(options) { + try { + spawn(bunExe(), ["-e", "0"], options); + } catch (e) { + return e; + } + throw new Error("spawn() did not throw"); + } + + // The errno of a bad fd is a libuv number on Windows, so that platform has a case of its own. + it.skipIf(isWindows)("an errno error has the syscall", () => { + const thrown = thrownBy({ stdio: [99999] }); + expect({ code: thrown.code, errno: thrown.errno, syscall: thrown.syscall }).toEqual({ + code: "EBADF", + errno: -constants.errno.EBADF, + syscall: "spawn", + }); + }); + + it.skipIf(!isWindows)("an errno error has the syscall on Windows", () => { + const thrown = thrownBy({ uid: 0 }); + expect({ code: thrown.code, errno: typeof thrown.errno, syscall: thrown.syscall }).toEqual({ + code: "ENOTSUP", + errno: "number", + syscall: "spawn", + }); + }); + + // Bun.spawn() refuses a negative fd inside the try of ChildProcess.spawn(), where the syscall is stamped. Node makes it a pipe, and what Node refuses too is refused before that try. + it("an ERR_* error that Bun.spawn() throws has no syscall", () => { + const thrown = thrownBy({ stdio: [-5] }); + expect({ code: thrown.code, keys: Object.keys(thrown) }).toEqual({ code: "ERR_INVALID_ARG_TYPE", keys: ["code"] }); + }); +}); + describe("ChildProcess spawn bad stdio", () => { // Monkey patch spawn() to create a child process normally, but destroy the // stdout and stderr streams. This replicates the conditions where the streams diff --git a/test/js/node/errors/error-code-messages.test.ts b/test/js/node/errors/error-code-messages.test.ts index c376420b6a07..18b38c601ae8 100644 --- a/test/js/node/errors/error-code-messages.test.ts +++ b/test/js/node/errors/error-code-messages.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness"; import child_process from "node:child_process"; import { EventEmitter, once } from "node:events"; @@ -7,9 +7,12 @@ import http from "node:http"; import https from "node:https"; import net, { type AddressInfo } from "node:net"; import path from "node:path"; -import { Readable } from "node:stream"; +import { Readable, Writable } from "node:stream"; +import { setTimeout as sleep } from "node:timers/promises"; import tls from "node:tls"; import url from "node:url"; +import util from "node:util"; +import { Worker } from "node:worker_threads"; import zlib from "node:zlib"; // Codes whose messages come from the fixed-template table behind $ERR_*. @@ -309,3 +312,212 @@ test("ERR_IPC_CHANNEL_CLOSED message from process.send() in the child", async () expect(stdout).toBe("ERR_IPC_CHANNEL_CLOSED | Error | Channel closed\n"); expect(exitCode).toBe(0); }); + +// `code` is a class field of Node's errors, and AbortError assigns `this.name` after it: +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L441 and #L985-L986. +// So what copies the own keys of an error keeps them: Object.keys, JSON.stringify, a spread. +describe("own properties of an error with a code", () => { + function thrownBy(fn: () => unknown): any { + try { + fn(); + } catch (e) { + return e; + } + throw new Error("did not throw"); + } + + async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e; + } + throw new Error("did not reject"); + } + + function ownShape(e: any) { + const forIn: string[] = []; + for (const key in e) forIn.push(key); + return { + code: Object.getOwnPropertyDescriptor(e, "code"), + keys: Object.keys(e), + forIn, + json: JSON.stringify(e), + spread: { ...e }, + }; + } + + const codeAlone = (code: string) => ({ + code: { value: code, writable: true, enumerable: true, configurable: true }, + keys: ["code"], + forIn: ["code"], + json: JSON.stringify({ code }), + spread: { code }, + }); + + const codeThenName = (code: string, name: string) => ({ + code: { value: code, writable: true, enumerable: true, configurable: true }, + keys: ["code", "name"], + forIn: ["code", "name"], + json: JSON.stringify({ code, name }), + spread: { code, name }, + }); + + const enumerableName = (name: string) => ({ value: name, writable: true, enumerable: true, configurable: true }); + + const abortError = () => rejectionOf(sleep(1, undefined, { signal: AbortSignal.abort() })); + + // A client that is not in subscriber mode throws this before it connects. + function redisError() { + const client = new Bun.RedisClient("redis://127.0.0.1:1", { autoReconnect: false, enableOfflineQueue: false }); + try { + return thrownBy(() => client.unsubscribe("channel")); + } finally { + client.close(); + } + } + + // One producer for each way into the native table of errors (src/jsc/bindings/ErrorCode.cpp). + test.each([ + ["a C++ validator", () => Buffer.alloc(-1), "ERR_OUT_OF_RANGE"], + ["a Rust validator", () => path.join(1 as any), "ERR_INVALID_ARG_TYPE"], + ["$ERR_* in a built-in module", () => new Writable({ write() {} }).write(null), "ERR_STREAM_NULL_VALUES"], + [ + "the RangeError of ERR_INVALID_ARG_VALUE", + () => process.cpuUsage({ user: -1, system: 0 }), + "ERR_INVALID_ARG_VALUE", + ], + ] as const)("code is the only own enumerable key of an error from %s", (_, fn, code) => { + expect(ownShape(thrownBy(fn))).toEqual(codeAlone(code)); + }); + + test("a rejection has the same keys as a thrown error", async () => { + expect(ownShape(await rejectionOf(fs.promises.readFile({} as any)))).toEqual(codeAlone("ERR_INVALID_ARG_TYPE")); + }); + + test("AbortError has code, then name", async () => { + const e = await abortError(); + expect(ownShape(e)).toEqual(codeThenName("ABORT_ERR", "AbortError")); + expect(Object.getOwnPropertyDescriptor(e, "name")).toEqual(enumerableName("AbortError")); + // The name is on the error alone. Without it the error is an Error again, as in Node. + delete e.name; + expect(e.name).toBe("Error"); + }); + + // Bun's own: Node has no RedisError. + test("RedisError has code, then name", () => { + const e = redisError(); + expect(ownShape(e)).toEqual(codeThenName("ERR_REDIS_INVALID_STATE", "RedisError")); + expect(Object.getOwnPropertyDescriptor(e, "name")).toEqual(enumerableName("RedisError")); + }); + + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/process/promises.js#L47-L48 + test("the error of an unhandled rejection has code, then name", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--unhandled-rejections=strict", + "-e", + `process.on("uncaughtException", e => { + console.log(JSON.stringify({ keys: Object.keys(e), name: Object.getOwnPropertyDescriptor(e, "name") })); + }); + Promise.reject(1);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "ignore", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe( + JSON.stringify({ keys: ["code", "name"], name: enumerableName("UnhandledPromiseRejection") }) + "\n", + ); + expect(exitCode).toBe(0); + }); + + // Node's SystemError: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L293-L309 + test("a SystemError has code first, and a name that is not enumerable", () => { + using dir = tempDir("error-code-system-error", {}); + const e = thrownBy(() => fs.rmSync(String(dir))); + expect(Object.keys(e)).toEqual(["code", "info", "errno", "syscall", "path"]); + expect(Object.getOwnPropertyDescriptor(e, "name")).toEqual({ ...enumerableName("SystemError"), enumerable: false }); + expect(e.code).toBe("ERR_FS_EISDIR"); + delete e.name; + expect(e.name).toBe("Error"); + }); + + test("the printers show an own name in the first line only", async () => { + for (const e of [await abortError(), redisError()]) { + expect(Bun.inspect(e)).toContain(`${e.name}: ${e.message}\n code: "${e.code}"\n`); + expect(Bun.inspect(e)).not.toMatch(/^\s*name: /m); + expect(util.inspect(e)).toContain(`code: '${e.code}'`); + expect(util.inspect(e)).not.toMatch(/^\s*name: /m); + } + }); + + test("code comes before the properties that the producer adds", () => { + const e = thrownBy(() => new EventEmitter().emit("error", 42)); + expect(Object.keys(e)).toEqual(["code", "context"]); + expect(JSON.parse(JSON.stringify(e))).toEqual({ code: "ERR_UNHANDLED_ERROR", context: 42 }); + }); + + test("util.inspect() prints the code", () => { + expect(util.inspect(thrownBy(() => Buffer.alloc(-1)))).toContain("code: 'ERR_OUT_OF_RANGE'"); + }); + + test("code is a plain property: it can be assigned, hidden and deleted", () => { + const assigned = thrownBy(() => Buffer.alloc(-1)); + assigned.code = "CUSTOM"; + expect(assigned.code).toBe("CUSTOM"); + + const hidden = thrownBy(() => Buffer.alloc(-1)); + Object.defineProperty(hidden, "code", { enumerable: false }); + expect({ code: hidden.code, keys: Object.keys(hidden) }).toEqual({ code: "ERR_OUT_OF_RANGE", keys: [] }); + + const deleted = thrownBy(() => Buffer.alloc(-1)); + delete deleted.code; + expect({ has: "code" in deleted, code: deleted.code }).toEqual({ has: false, code: undefined }); + }); + + test("the errors of one code share a prototype that holds no code", () => { + const first = thrownBy(() => process.cpuUsage({ user: -1, system: 0 })); + const second = thrownBy(() => process.cpuUsage({ user: -1, system: 0 })); + expect(first).toBeInstanceOf(RangeError); + expect(Object.hasOwn(first, "cause")).toBe(false); + + const prototype = Object.getPrototypeOf(first); + expect(Object.getPrototypeOf(second)).toBe(prototype); + expect({ code: Object.hasOwn(prototype, "code"), enumerable: Object.keys(prototype) }).toEqual({ + code: false, + enumerable: [], + }); + // The first line of the stack has the name of the kind, which the error itself does not hold. + expect(first.stack).toStartWith("RangeError"); + + // A `code` written to the prototype does not reach the next error. + const before = Object.getOwnPropertyDescriptor(prototype, "code"); + prototype.code = "WRITTEN"; + try { + const third = thrownBy(() => process.cpuUsage({ user: -1, system: 0 })); + expect(third.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(String(third)).toStartWith("RangeError [ERR_INVALID_ARG_VALUE]: "); + } finally { + if (before) Object.defineProperty(prototype, "code", before); + else delete prototype.code; + } + }); + + test("an error made in a Worker has the same keys", async () => { + const worker = new Worker( + `const { parentPort } = require("node:worker_threads"); + try { + Buffer.alloc(-1); + } catch (e) { + parentPort.postMessage({ keys: Object.keys(e), json: JSON.stringify(e) }); + }`, + { eval: true }, + ); + const [message] = await once(worker, "message"); + await worker.terminate(); + expect(message).toEqual({ keys: ["code"], json: '{"code":"ERR_OUT_OF_RANGE"}' }); + }); +}); diff --git a/test/js/node/errors/error-code-toString-receiver.test.ts b/test/js/node/errors/error-code-toString-receiver.test.ts index 69ecd6c32584..7ef67dc5f75e 100644 --- a/test/js/node/errors/error-code-toString-receiver.test.ts +++ b/test/js/node/errors/error-code-toString-receiver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; -import { readFileSync } from "node:fs"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync, rmSync } from "node:fs"; // Errors with a Node `code` get their prototype, including `toString`, from the native // error table (src/jsc/bindings/ErrorCode.cpp). @@ -23,6 +23,40 @@ describe("node error toString()", () => { }); }); + // The toString of a NodeError closes over the code of its class and does not read `this.code`: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L462-L464 + test("prints the code the error was made with, whatever `code` the receiver has", () => { + const err = nodeError(); + const text = `TypeError [ERR_INVALID_ARG_TYPE]: ${err.message}`; + err.code = "CUSTOM"; + expect(err.toString()).toBe(text); + delete (err as { code?: string }).code; + expect(err.toString()).toBe(text); + expect(err.toString.call({ name: "Name", message: "message" })).toBe("Name [ERR_INVALID_ARG_TYPE]: message"); + }); + + // The toString of a SystemError reads `this.code`: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/errors.js#L386-L388 + test("a SystemError prints the code that the receiver has", () => { + using dir = tempDir("error-code-system-error-toString", {}); + let err: any; + try { + rmSync(String(dir)); + } catch (e) { + err = e; + } + const text = (code: string) => `SystemError [${code}]: ${err.message}`; + expect({ code: err.code, string: err.toString() }).toEqual({ + code: "ERR_FS_EISDIR", + string: text("ERR_FS_EISDIR"), + }); + err.code = "CUSTOM"; + expect(err.toString()).toBe(text("CUSTOM")); + delete err.code; + expect(err.toString()).toBe(text("undefined")); + expect(err.toString.call({ name: "Name", code: "CODE", message: "message" })).toBe("Name [CODE]: message"); + }); + // A call resolved through a binding that a closure captures is compiled with the scope // object itself in the this slot; native functions see it raw. toString() used to read // name/code/message off that scope object and return "undefined [undefined]: undefined".