diff --git a/src/jsc/bindings/AsyncStackTrace.cpp b/src/jsc/bindings/AsyncStackTrace.cpp index 6956902cbaff..1595732b3261 100644 --- a/src/jsc/bindings/AsyncStackTrace.cpp +++ b/src/jsc/bindings/AsyncStackTrace.cpp @@ -4,6 +4,7 @@ #include "BunClientData.h" #include "ErrorStackFrame.h" +#include "FormatStackTraceForJS.h" #include #include @@ -162,17 +163,20 @@ extern "C" void Bun__attachAsyncStackFromPromise(JSC::JSGlobalObject* globalObje // would desync m_stackTrace from the cached property. if (instance->hasMaterializedErrorInfo()) return; - if (auto* existing = instance->stackTrace(); existing && !existing->isEmpty()) + // Null: no capture at all (Error.stackTraceLimit deleted) or a stack string is already set (structuredClone, GC finalizer). + auto* existing = instance->stackTrace(); + if (!existing || !existing->isEmpty()) return; - size_t limit = globalObject->stackTraceLimit().value_or(10); - if (!limit) - return; - - WTF::Vector frames; - collectAsyncStackFramesFromPromise(vm, instance, promise, frames, limit); - if (frames.isEmpty()) - return; + size_t limit = globalObject->stackTraceLimit().value_or(Bun::DEFAULT_ERROR_STACK_TRACE_LIMIT); + if (limit) { + WTF::Vector frames; + collectAsyncStackFramesFromPromise(vm, instance, promise, frames, limit); + if (!frames.isEmpty()) { + instance->setStackFrames(vm, WTF::move(frames)); + return; + } + } - instance->setStackFrames(vm, WTF::move(frames)); + Bun::installLazyStackIfFrameless(vm, globalObject, instance); } diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 6114d1d771e6..224594822229 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -29,6 +29,7 @@ #include #include "ErrorCode.h" #include "ErrorStackTrace.h" +#include "FormatStackTraceForJS.h" #include "KeyObject.h" namespace WTF { @@ -216,6 +217,7 @@ JSObject* ErrorCodeCache::createError(VM& vm, Zig::GlobalObject* globalObject, E // exception were thrown by ErrorInstance::create) return uncheckedDowncast(thrown_exception->value()); } + Bun::installLazyStackIfFrameless(vm, globalObject, created_error); return created_error; } diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..8e4a2a7cdec5 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -756,6 +756,28 @@ JSC_DEFINE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter, (JSGlobalObject * g return true; } +void installLazyStackIfFrameless(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::ErrorInstance* error) +{ + auto* stackTrace = error->stackTrace(); + // Null means Error.stackTraceLimit was deleted; V8 leaves .stack undefined for that too. + if (!stackTrace || !stackTrace->isEmpty()) + return; + + // Already installed, or assigned explicitly. + if (error->getDirect(vm, vm.propertyNames->stack)) + return; + + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + error->putDirectCustomAccessor(vm, vm.propertyNames->stack, globalObject->m_lazyStackCustomGetterSetter.get(globalObject), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::CustomAccessor | 0); +} + +JSC::JSValue installLazyStackIfFrameless(JSC::JSGlobalObject* globalObject, JSC::JSValue value) +{ + if (auto* error = dynamicDowncast(value)) + installLazyStackIfFrameless(JSC::getVM(globalObject), globalObject, error); + return value; +} + JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = static_cast(lexicalGlobalObject); diff --git a/src/jsc/bindings/FormatStackTraceForJS.h b/src/jsc/bindings/FormatStackTraceForJS.h index fc9515a4668b..57a4a04eb378 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.h +++ b/src/jsc/bindings/FormatStackTraceForJS.h @@ -79,6 +79,11 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionDefaultErrorPrepareStackTrace); JSC_DECLARE_CUSTOM_GETTER(errorInstanceLazyStackCustomGetter); JSC_DECLARE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter); +// JSC defines no .stack at all for an error that captured zero frames; V8 still gives it "Name: message". +void installLazyStackIfFrameless(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::ErrorInstance* error); +// Same, for constructors that hand back a JSValue; anything that is not an ErrorInstance passes through. +JSC::JSValue installLazyStackIfFrameless(JSC::JSGlobalObject* globalObject, JSC::JSValue value); + // Internal wrapper functions for JSC error info callbacks WTF::String computeErrorInfoWrapperToString(JSC::VM& vm, WTF::Vector& stackTrace, unsigned int& line_in, unsigned int& column_in, WTF::String& sourceURL, void* bunErrorData); JSC::JSValue computeErrorInfoWrapperToJSValue(JSC::VM& vm, WTF::Vector& stackTrace, unsigned int& line_in, unsigned int& column_in, WTF::String& sourceURL, JSC::JSObject* errorInstance, void* bunErrorData); diff --git a/src/jsc/bindings/S3Error.cpp b/src/jsc/bindings/S3Error.cpp index e16cca5fe023..b42f757f8912 100644 --- a/src/jsc/bindings/S3Error.cpp +++ b/src/jsc/bindings/S3Error.cpp @@ -6,6 +6,7 @@ #include #include "ZigGeneratedClasses.h" #include "S3Error.h" +#include "FormatStackTraceForJS.h" namespace Bun { @@ -38,6 +39,7 @@ SYSV_ABI JSC::EncodedJSValue S3Error__toErrorInstance(const S3Error* arg0, auto prototype = defaultGlobalObject(globalObject)->m_S3ErrorStructure.getInitializedOnMainThread(globalObject); JSC::JSObject* result = JSC::ErrorInstance::create(vm, prototype, message, {}); + installLazyStackIfFrameless(globalObject, result); result->putDirect(vm, vm.propertyNames->name, defaultGlobalObject(globalObject)->commonStrings().s3ErrorString(globalObject), JSC::PropertyAttribute::DontEnum | 0); if (err.code.tag != BunStringTag::Empty) { JSC::JSValue code = Bun::toJS(globalObject, err.code); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 40e19b3e571e..b15945766df0 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -171,6 +171,7 @@ #include "ErrorStackFrame.h" #include "AsyncStackTrace.h" #include "ErrorStackTrace.h" +#include "FormatStackTraceForJS.h" #include "ObjectBindings.h" #include @@ -2583,6 +2584,7 @@ static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, J auto& names = WebCore::builtinNames(vm); JSC::JSObject* result = createError(globalObject, errorType, message); + Bun::installLazyStackIfFrameless(globalObject, result); auto clientData = WebCore::clientData(vm); @@ -2664,6 +2666,7 @@ JSC::EncodedJSValue SystemError__toErrorInstanceWithInfoObject(const SystemError auto message = makeString("A system error occurred: "_s, syscallString, " returned "_s, codeString, " ("_s, messageString, ")"_s); JSC::JSObject* result = JSC::ErrorInstance::create(vm, JSC::ErrorInstance::createStructure(vm, globalObject, globalObject->errorPrototype()), message, {}); + Bun::installLazyStackIfFrameless(globalObject, result); JSC::JSObject* info = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 0); auto clientData = WebCore::clientData(vm); @@ -3631,14 +3634,18 @@ JSC::EncodedJSValue JSC__JSGlobalObject__createAggregateError(JSC::JSGlobalObjec JSC::Structure* errorStructure = globalObject->errorStructure(JSC::ErrorType::AggregateError); - RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::createAggregateError(vm, errorStructure, array, message, cause, nullptr, JSC::TypeNothing, false))); + auto* error = JSC::createAggregateError(vm, errorStructure, array, message, cause, nullptr, JSC::TypeNothing, false); + Bun::installLazyStackIfFrameless(vm, globalObject, error); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(error)); } JSC::EncodedJSValue JSC__JSGlobalObject__createAggregateErrorWithArray(JSC::JSGlobalObject* global, JSC::JSArray* array, BunString message, JSValue cause) { auto& vm = JSC::getVM(global); JSC::Structure* errorStructure = global->errorStructure(JSC::ErrorType::AggregateError); WTF::String messageString = message.toWTFString(); - return JSC::JSValue::encode(JSC::createAggregateError(vm, errorStructure, array, messageString, cause, nullptr, JSC::TypeNothing, false)); + auto* error = JSC::createAggregateError(vm, errorStructure, array, messageString, cause, nullptr, JSC::TypeNothing, false); + Bun::installLazyStackIfFrameless(vm, global, error); + return JSC::JSValue::encode(error); } JSC::EncodedJSValue ZigString__toAtomicValue(const ZigString* arg0, JSC::JSGlobalObject* arg1) @@ -3757,27 +3764,27 @@ JSC::EncodedJSValue ZigString__toExternalValueWithCallback(const ZigString* arg0 JSC::EncodedJSValue ZigString__toErrorInstance(const ZigString* str, JSC::JSGlobalObject* globalObject) { - return JSC::JSValue::encode(Zig::getErrorInstance(str, globalObject)); + return JSC::JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, Zig::getErrorInstance(str, globalObject))); } JSC::EncodedJSValue ZigString__toTypeErrorInstance(const ZigString* str, JSC::JSGlobalObject* globalObject) { - return JSC::JSValue::encode(Zig::getTypeErrorInstance(str, globalObject)); + return JSC::JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, Zig::getTypeErrorInstance(str, globalObject))); } JSC::EncodedJSValue ZigString__toDOMExceptionInstance(const ZigString* str, JSC::JSGlobalObject* globalObject, WebCore::ExceptionCode code) { - return JSValue::encode(createDOMException(globalObject, code, toStringCopy(*str))); + return JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, createDOMException(globalObject, code, toStringCopy(*str)))); } JSC::EncodedJSValue ZigString__toSyntaxErrorInstance(const ZigString* str, JSC::JSGlobalObject* globalObject) { - return JSC::JSValue::encode(Zig::getSyntaxErrorInstance(str, globalObject)); + return JSC::JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, Zig::getSyntaxErrorInstance(str, globalObject))); } JSC::EncodedJSValue ZigString__toRangeErrorInstance(const ZigString* str, JSC::JSGlobalObject* globalObject) { - return JSC::JSValue::encode(Zig::getRangeErrorInstance(str, globalObject)); + return JSC::JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, Zig::getRangeErrorInstance(str, globalObject))); } JSC::JSPromise* @@ -6335,17 +6342,17 @@ CPP_DECL void JSC__VM__performOpportunisticallyScheduledTasks(JSC::VM* vm, doubl extern "C" EncodedJSValue JSC__createError(JSC::JSGlobalObject* globalObject, const BunString* str) { - return JSValue::encode(JSC::createError(globalObject, str->toWTFString(BunString::ZeroCopy))); + return JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, JSC::createError(globalObject, str->toWTFString(BunString::ZeroCopy)))); } extern "C" EncodedJSValue JSC__createTypeError(JSC::JSGlobalObject* globalObject, const BunString* str) { - return JSValue::encode(JSC::createTypeError(globalObject, str->toWTFString(BunString::ZeroCopy))); + return JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, JSC::createTypeError(globalObject, str->toWTFString(BunString::ZeroCopy)))); } extern "C" EncodedJSValue JSC__createRangeError(JSC::JSGlobalObject* globalObject, const BunString* str) { - return JSValue::encode(JSC::createRangeError(globalObject, str->toWTFString(BunString::ZeroCopy))); + return JSValue::encode(Bun::installLazyStackIfFrameless(globalObject, JSC::createRangeError(globalObject, str->toWTFString(BunString::ZeroCopy)))); } extern "C" EncodedJSValue ExpectMatcherUtils__getSingleton(JSC::JSGlobalObject* globalObject_) diff --git a/src/jsc/bindings/node/crypto/CryptoUtil.cpp b/src/jsc/bindings/node/crypto/CryptoUtil.cpp index 951df2c46d25..a31e8107ee6c 100644 --- a/src/jsc/bindings/node/crypto/CryptoUtil.cpp +++ b/src/jsc/bindings/node/crypto/CryptoUtil.cpp @@ -13,6 +13,7 @@ #include #include "CryptoKeyRaw.h" #include "JSKeyObject.h" +#include "FormatStackTraceForJS.h" namespace Bun { @@ -382,6 +383,7 @@ JSValue createCryptoError(JSC::JSGlobalObject* globalObject, ThrowScope& scope, // Create error object with the message JSC::JSObject* errorObject = createError(globalObject, errorMessage); RETURN_IF_EXCEPTION(scope, {}); + installLazyStackIfFrameless(globalObject, errorObject); PutPropertySlot messageSlot(errorObject, false); errorObject->put(errorObject, globalObject, Identifier::fromString(vm, "message"_s), jsString(vm, errorMessage), messageSlot); diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 4a56e345ec31..3b96afe58747 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -57,6 +57,7 @@ #include "JSBuffer.h" #include "BunClientData.h" #include "ErrorEvent.h" +#include "FormatStackTraceForJS.h" #include "WebSocketDeflate.h" namespace WebCore { @@ -76,7 +77,7 @@ static ErrorEvent::Init createErrorEventInit(WebSocket& webSocket, const String& eventInit.bubbles = false; eventInit.cancelable = false; eventInit.colno = 0; - eventInit.error = JSC::createError(globalObject, eventInit.message); + eventInit.error = Bun::installLazyStackIfFrameless(globalObject, JSC::createError(globalObject, eventInit.message)); return eventInit; } diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 27657f0da198..2b55f50b6add 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -279,7 +279,7 @@ impl JSBundleCompletionTask { global_this, BunString::static_(b"Bundle failed"), ); - return promise.reject(global_this, aggregate_error); + return promise.reject_with_async_stack(global_this, aggregate_error); } else { return promise.resolve(global_this, build_result); } diff --git a/test/js/node/crypto/crypto-sign-regression.test.ts b/test/js/node/crypto/crypto-sign-regression.test.ts index e908c8bb202d..12c480b90232 100644 --- a/test/js/node/crypto/crypto-sign-regression.test.ts +++ b/test/js/node/crypto/crypto-sign-regression.test.ts @@ -43,3 +43,20 @@ test("crypto.sign() supports all RSA digest variants", () => { expect(signature.length).toBeGreaterThan(0); } }); + +test("an error from the sign job is passed to the callback with a .stack", async () => { + // A PSS salt longer than the modulus makes the signing itself fail, so the error is + // created when the thread pool job completes, with no JS on the stack (as in node). + const { promise, resolve } = Promise.withResolvers(); + sign( + "sha256", + Buffer.from("test message"), + { key: DUMMY_PRIVATE_KEY, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 4000 }, + resolve, + ); + const err = (await promise)!; + + expect(err).toBeInstanceOf(Error); + expect(Object.prototype.hasOwnProperty.call(err, "stack")).toBe(true); + expect(err.stack).toBe(`Error: ${err.message}`); +}); diff --git a/test/js/node/fs/promises.test.js b/test/js/node/fs/promises.test.js index 06811b04cba8..06dcf8f16a56 100644 --- a/test/js/node/fs/promises.test.js +++ b/test/js/node/fs/promises.test.js @@ -259,8 +259,8 @@ test("fs.promises async stack through Promise subclass", async () => { expect(caught).toBeDefined(); expect(caught.code).toBe("ENOENT"); - // Subclass .then() may not preserve the reaction chain — must not crash. - expect(typeof caught.stack === "string" || caught.stack === undefined).toBe(true); + // Subclass .then() may not preserve the reaction chain; the error still gets a .stack. + expect(caught.stack).toStartWith(`Error: ${caught.message}`); }); test("fs.promises async stack through custom thenable", async () => { @@ -282,8 +282,8 @@ test("fs.promises async stack through custom thenable", async () => { expect(caught).toBeDefined(); expect(caught.code).toBe("ENOENT"); - // Custom thenables break the direct reaction chain — must not crash. - expect(typeof caught.stack === "string" || caught.stack === undefined).toBe(true); + // Custom thenables break the direct reaction chain; the error still gets a .stack. + expect(caught.stack).toStartWith(`Error: ${caught.message}`); }); test("fs.promises async stack with Promise.all", async () => { @@ -300,8 +300,20 @@ test("fs.promises async stack with Promise.all", async () => { expect(caught).toBeDefined(); expect(caught.code).toBe("ENOENT"); - // Promise.all uses combinator context — must not crash. - expect(typeof caught.stack === "string" || caught.stack === undefined).toBe(true); + // Promise.all uses combinator context, so no async frames are recovered; the error + // still gets a .stack. + expect(caught.stack).toStartWith(`Error: ${caught.message}`); +}); + +test("fs.promises errors that nothing awaits still have a .stack", async () => { + // .catch() attaches a handler but nothing awaits the derived promise, so there is no + // async chain to recover frames from: the error keeps an empty trace. + const caught = await new Promise(resolve => readFile("/nonexistent-path/x.txt").catch(resolve)); + + expect(caught.code).toBe("ENOENT"); + expect(caught.stack).toBe(`Error: ${caught.message}`); + expect(Object.prototype.hasOwnProperty.call(caught, "stack")).toBe(true); + expect(Object.keys(caught)).not.toContain("stack"); }); it("an unused FileHandle.writer() does not prevent close()", async () => { diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 94e4bef75b63..549f09668d82 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -1,7 +1,10 @@ import { nativeFrameForTesting } from "bun:internal-for-testing"; import { noInline } from "bun:jsc"; -import { afterEach, expect, mock, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { once } from "node:events"; +import fs from "node:fs"; +import net from "node:net"; const origPrepareStackTrace = Error.prepareStackTrace; afterEach(() => { Error.prepareStackTrace = origPrepareStackTrace; @@ -1121,3 +1124,204 @@ test("lazy error-info materialization does not store an empty stack value when t }); expect(exitCode).toBe(0); }); + +describe("errors created by native code while no JS is running", () => { + // fs callbacks run from the event loop, so the ENOENT error is built with no JS frames + // on the stack and JSC captures an empty trace for it. + async function framelessError() { + using dir = tempDir("frameless-error", {}); + const { promise, resolve } = Promise.withResolvers(); + fs.readFile(join(String(dir), "missing.txt"), resolve); + const err = await promise; + expect(err.code).toBe("ENOENT"); + return err; + } + + test("have an own, non-enumerable .stack holding the 'name: message' line", async () => { + const err = await framelessError(); + expect(Object.getOwnPropertyDescriptor(err, "stack")).toMatchObject({ enumerable: false, configurable: true }); + expect(Object.keys(err)).not.toContain("stack"); + expect(err.stack).toBe(`Error: ${err.message}`); + expect(Object.getOwnPropertyDescriptor(err, "stack")).toMatchObject({ + value: `Error: ${err.message}`, + writable: true, + enumerable: false, + }); + }); + + test(".stack is formatted on first read, like an error with frames", async () => { + const err = await framelessError(); + err.name = "Renamed"; + err.message = "changed"; + expect(err.stack).toBe("Renamed: changed"); + }); + + test("Error.prepareStackTrace runs on first read and receives no call sites", async () => { + const err = await framelessError(); + Error.prepareStackTrace = mock((error, callSites) => `prepared ${error.code} with ${callSites.length} call sites`); + expect(err.stack).toBe("prepared ENOENT with 0 call sites"); + expect(err.stack).toBe("prepared ENOENT with 0 call sites"); + expect(Error.prepareStackTrace).toHaveBeenCalledTimes(1); + }); + + test("assigning .stack before it is read replaces it", async () => { + const err = await framelessError(); + err.stack = "mine"; + expect(Object.getOwnPropertyDescriptor(err, "stack")).toMatchObject({ value: "mine", enumerable: false }); + }); + + // fetch() builds its error when the connection attempt fails and recovers frames, if any, + // from the async functions awaiting the promise it is about to reject. + async function closedPortURL() { + const listener = net.createServer(); + await once(listener.listen(0, "127.0.0.1"), "listening"); + const { port } = listener.address(); + await new Promise(resolve => listener.close(resolve)); + return `http://127.0.0.1:${port}/`; + } + + test("a rejection nothing awaits gets the 'name: message' line", async () => { + const url = await closedPortURL(); + const err = await new Promise(resolve => fetch(url).then(resolve, resolve)); + + expect(err).toBeInstanceOf(Error); + expect(err.stack).toBe(`${err.name}: ${err.message}`); + }); + + test("a rejection awaited inside an async function still lists the async frame", async () => { + const url = await closedPortURL(); + async function requestIt() { + await fetch(url); + } + const err = await requestIt().then( + () => new Error("unexpected response"), + e => e, + ); + + expect(err.stack).toStartWith(`${err.name}: ${err.message}\n at async requestIt `); + }); + + test("the AggregateError from a failed Bun.build() behaves the same way", async () => { + using dir = tempDir("frameless-error", { "entry.ts": `import "./does-not-exist";` }); + const options = { entrypoints: [join(String(dir), "entry.ts")] }; + const nothingAwaits = await new Promise(resolve => Bun.build(options).then(resolve, resolve)); + async function buildIt() { + await Bun.build(options); + } + const awaited = await buildIt().then( + () => new Error("unexpected success"), + e => e, + ); + + expect(nothingAwaits).toBeInstanceOf(AggregateError); + expect(nothingAwaits.stack).toBe(`AggregateError: ${nothingAwaits.message}`); + expect(awaited).toBeInstanceOf(AggregateError); + expect(awaited.stack).toStartWith(`AggregateError: ${awaited.message}\n at async buildIt `); + }); + + test("Error.stackTraceLimit = 0 leaves the 'name: message' line, as in V8", () => { + using dir = tempDir("frameless-error", {}); + const originalLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + let systemError, codeError; + try { + try { + fs.readFileSync(join(String(dir), "missing.txt")); + } catch (e) { + systemError = e; + } + try { + Buffer.alloc(-1); + } catch (e) { + codeError = e; + } + } finally { + Error.stackTraceLimit = originalLimit; + } + expect({ code: systemError.code, stack: systemError.stack }).toEqual({ + code: "ENOENT", + stack: `Error: ${systemError.message}`, + }); + expect(codeError.code).toBe("ERR_OUT_OF_RANGE"); + expect(codeError.stack).toStartWith("RangeError"); + expect(codeError.stack).toEndWith(`: ${codeError.message}`); + }); + + test("deleting Error.stackTraceLimit still disables .stack entirely, as in V8", () => { + using dir = tempDir("frameless-error", {}); + const originalLimit = Error.stackTraceLimit; + delete Error.stackTraceLimit; + let systemError; + try { + try { + fs.readFileSync(join(String(dir), "missing.txt")); + } catch (e) { + systemError = e; + } + } finally { + Error.stackTraceLimit = originalLimit; + } + expect({ code: systemError.code, stack: systemError.stack }).toEqual({ code: "ENOENT", stack: undefined }); + }); + + // Bun.password.verify builds a plain Error once its thread pool job finishes and rejects + // through the async stack attach, so these exercise that path by itself. + function verifyNothingAwaits() { + return new Promise(resolve => Bun.password.verify("pw", "not a hash").then(resolve, resolve)); + } + async function verifyAwaited() { + try { + await Bun.password.verify("pw", "not a hash"); + } catch (e) { + return e; + } + } + function describeStack(err) { + const header = `${err.name}: ${err.message}`; + if (err.stack === undefined) return "undefined"; + if (err.stack === header) return "header"; + if (err.stack.startsWith(`${header}\n at async verifyAwaited `)) return "header + async frame"; + return err.stack; + } + + test("Error.stackTraceLimit applies the same way whether or not something awaits a native rejection", async () => { + const originalLimit = Error.stackTraceLimit; + const results = {}; + try { + Error.stackTraceLimit = 10; + results.limit10 = [describeStack(await verifyNothingAwaits()), describeStack(await verifyAwaited())]; + Error.stackTraceLimit = 0; + results.limit0 = [describeStack(await verifyNothingAwaits()), describeStack(await verifyAwaited())]; + delete Error.stackTraceLimit; + results.deleted = [describeStack(await verifyNothingAwaits()), describeStack(await verifyAwaited())]; + } finally { + Error.stackTraceLimit = originalLimit; + } + expect(results).toEqual({ + limit10: ["header", "header + async frame"], + limit0: ["header", "header"], + deleted: ["undefined", "undefined"], + }); + }); + + // node's unhandled rejection warning only prints the rejection value's .stack when the + // value has an own .stack property; without one it falls back to a generic rendering. + test("--unhandled-rejections=warn prints the error for a native rejection", async () => { + using dir = tempDir("frameless-error", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--unhandled-rejections=warn", + "-e", + `require("node:fs/promises").readFile(${JSON.stringify(join(String(dir), "missing.txt"))})`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory"); + expect(stderr).not.toContain("[object Object]"); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 0 }); + }); +}); diff --git a/test/js/valkey/reliability/connection-failures.test.ts b/test/js/valkey/reliability/connection-failures.test.ts index bcf8964ac348..4b757b958723 100644 --- a/test/js/valkey/reliability/connection-failures.test.ts +++ b/test/js/valkey/reliability/connection-failures.test.ts @@ -441,3 +441,25 @@ describe("Valkey: Auto-Reconnect In-Flight Commands", () => { } }); }); + +describe("Valkey: Connection Error Shape", () => { + test("a command rejected because the connection failed has a .stack", async () => { + const listener = net.createServer(); + await new Promise(resolve => listener.listen(0, "127.0.0.1", resolve)); + const { port } = listener.address() as net.AddressInfo; + await new Promise(resolve => listener.close(resolve)); + + const client = new RedisClient(`redis://127.0.0.1:${port}`, { autoReconnect: false }); + try { + // The error is created by the socket's failure callback, with no JS on the stack, and + // .then() leaves no await chain to recover frames from. + const err = await new Promise(resolve => client.get("key").then(resolve, resolve)); + + expect(err.code).toBe("ERR_REDIS_CONNECTION_CLOSED"); + expect(err.stack).toStartWith(err.name); + expect(err.stack).toEndWith(`: ${err.message}`); + } finally { + client.close(); + } + }); +}); diff --git a/test/js/web/websocket/error-event.test.ts b/test/js/web/websocket/error-event.test.ts index 083c0f0c4b25..68003319adce 100644 --- a/test/js/web/websocket/error-event.test.ts +++ b/test/js/web/websocket/error-event.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test"; +import { once } from "node:events"; +import net from "node:net"; test("WebSocket error event snapshot", async () => { const ws = new WebSocket("ws://127.0.0.1:8080"); @@ -38,3 +40,19 @@ test("ErrorEvent with no message", async () => { error: null }`); }); + +test("event.error of a failed connection has a .stack", async () => { + const listener = net.createServer(); + await once(listener.listen(0, "127.0.0.1"), "listening"); + const { port } = listener.address() as net.AddressInfo; + await new Promise(resolve => listener.close(resolve)); + + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const { promise, resolve } = Promise.withResolvers(); + ws.onerror = resolve; + const { error } = await promise; + + // The error is created when the connection attempt fails, with no JS on the stack. + expect(Object.prototype.hasOwnProperty.call(error, "stack")).toBe(true); + expect(error.stack).toBe(`Error: ${error.message}`); +});