From 92ed113daea8f791f96cb0e3af04df69ffbdc77f Mon Sep 17 00:00:00 2001 From: Andrew Leng Date: Thu, 4 Dec 2025 18:08:55 -0500 Subject: [PATCH 1/2] fix: add stack trace to AbortSignal.timeout errors Capture the stack trace at the point where AbortSignal.timeout() is called and attach it to the TimeoutError that is thrown when the timeout fires. Previously, the TimeoutError had no stack trace (or only internal frames), making it difficult to debug where the timeout originated in user code. Now the error's stack property points to the user code that called AbortSignal.timeout(), matching Node.js behavior. Changes: - AbortSignal.h: Added m_timeoutCreationStack member and accessor - AbortSignal.cpp: Capture stack in timeout(), pass to signalAbort() - ErrorCode.cpp: Copy captured stack to TimeoutError in toJS() - Added test case for stack trace verification Closes #25182 --- src/bun.js/bindings/ErrorCode.cpp | 20 ++++++-- src/bun.js/bindings/webcore/AbortSignal.cpp | 17 ++++++- src/bun.js/bindings/webcore/AbortSignal.h | 4 +- .../js/bun/abort-signal-timeout-stack.test.ts | 51 +++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 test/js/bun/abort-signal-timeout-stack.test.ts diff --git a/src/bun.js/bindings/ErrorCode.cpp b/src/bun.js/bindings/ErrorCode.cpp index 8fbaf2395c42..3b42b4419507 100644 --- a/src/bun.js/bindings/ErrorCode.cpp +++ b/src/bun.js/bindings/ErrorCode.cpp @@ -1642,11 +1642,25 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionMakeAbortError, (JSC::JSGlobalObject * lexica return JSC::JSValue::encode(error); } -JSC::JSValue WebCore::toJS(JSC::JSGlobalObject* globalObject, CommonAbortReason abortReason) +JSC::JSValue WebCore::toJS(JSC::JSGlobalObject* globalObject, CommonAbortReason abortReason, JSC::JSObject* capturedStack) { + auto& vm = globalObject->vm(); switch (abortReason) { case CommonAbortReason::Timeout: { - return createDOMException(globalObject, ExceptionCode::TimeoutError, "The operation timed out."_s); + auto error = createDOMException(globalObject, ExceptionCode::TimeoutError, "The operation timed out."_s); + + // If we have a captured stack (from AbortSignal.timeout() call site), copy it to this error + if (capturedStack) { + auto scope = DECLARE_CATCH_SCOPE(vm); + JSValue stackValue = capturedStack->get(globalObject, vm.propertyNames->stack); + if (!scope.exception() && stackValue && !stackValue.isUndefined()) { + if (auto* errorObject = error.getObject()) { + errorObject->putDirect(vm, vm.propertyNames->stack, stackValue, 0); + } + } + scope.clearException(); + } + return error; } case CommonAbortReason::UserAbort: { return createDOMException(globalObject, ExceptionCode::AbortError, "The operation was aborted."_s); @@ -1664,7 +1678,7 @@ JSC::JSValue WebCore::toJS(JSC::JSGlobalObject* globalObject, CommonAbortReason extern "C" JSC::EncodedJSValue WebCore__CommonAbortReason__toJS(JSC::JSGlobalObject* globalObject, WebCore::CommonAbortReason abortReason) { - return JSC::JSValue::encode(WebCore::toJS(globalObject, abortReason)); + return JSC::JSValue::encode(WebCore::toJS(globalObject, abortReason, nullptr)); } JSC::JSObject* Bun::createInvalidThisError(JSC::JSGlobalObject* globalObject, const String& message) diff --git a/src/bun.js/bindings/webcore/AbortSignal.cpp b/src/bun.js/bindings/webcore/AbortSignal.cpp index 3b4f2217c6aa..a6e8c4dd8eaa 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.cpp +++ b/src/bun.js/bindings/webcore/AbortSignal.cpp @@ -66,6 +66,18 @@ Ref AbortSignal::abort(JSDOMGlobalObject& globalObject, ScriptExecu Ref AbortSignal::timeout(ScriptExecutionContext& context, uint64_t milliseconds) { auto signal = adoptRef(*new AbortSignal(&context)); + + // Capture stack trace at creation time for later use when timeout fires + // This ensures the TimeoutError has a stack trace pointing to where timeout() was called + if (auto* globalObject = context.jsGlobalObject()) { + auto& vm = globalObject->vm(); + JSC::JSObject* errorForStack = JSC::constructEmptyObject(globalObject); + if (auto* errorInstance = JSC::jsDynamicCast(JSC::createError(globalObject, String()))) { + errorInstance->captureStackTrace(vm, globalObject, 1); + signal->m_timeoutCreationStack.setWeakly(errorInstance); + } + } + signal->m_timeout = AbortSignal__Timeout__create(bunVM(context.vm()), signal.ptr(), milliseconds); ASSERT(signal->m_timeout); signal->ref(); @@ -210,7 +222,10 @@ void AbortSignal::signalAbort(JSC::JSGlobalObject* globalObject, CommonAbortReas return; m_commonReason = reason; - signalAbort(toJS(globalObject, reason)); + + // Pass the captured stack trace if this is a timeout signal + JSValue capturedStack = m_timeoutCreationStack.getValue(); + signalAbort(toJS(globalObject, reason, capturedStack.isObject() ? jsCast(capturedStack) : nullptr)); } void AbortSignal::cleanNativeBindings(void* ref) diff --git a/src/bun.js/bindings/webcore/AbortSignal.h b/src/bun.js/bindings/webcore/AbortSignal.h index 68ded5d832f2..50963ba32f84 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.h +++ b/src/bun.js/bindings/webcore/AbortSignal.h @@ -55,7 +55,7 @@ enum class CommonAbortReason : uint8_t { ConnectionClosed, }; -JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason); +JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason, JSC::JSObject* capturedStack = nullptr); typedef void* AbortSignalTimeout; @@ -125,6 +125,7 @@ class AbortSignal final : public RefCounted, public EventTargetWith size_t memoryCost() const; AbortSignalTimeout getTimeout() const { return m_timeout; } + const JSValueInWrappedObject& timeoutCreationStack() const { return m_timeoutCreationStack; } private: enum class Aborted : bool { @@ -189,6 +190,7 @@ class AbortSignal final : public RefCounted, public EventTargetWith std::atomic pendingActivityCount { 0 }; uint32_t m_algorithmIdentifier { 0 }; AbortSignalTimeout m_timeout { nullptr }; + JSValueInWrappedObject m_timeoutCreationStack; // Captured stack at AbortSignal.timeout() call site uint8_t m_flags { 0 }; }; diff --git a/test/js/bun/abort-signal-timeout-stack.test.ts b/test/js/bun/abort-signal-timeout-stack.test.ts new file mode 100644 index 000000000000..9157f4c39608 --- /dev/null +++ b/test/js/bun/abort-signal-timeout-stack.test.ts @@ -0,0 +1,51 @@ +import { expect, test, describe } from "bun:test"; + +describe("AbortSignal.timeout stack trace", () => { + test("should include stack trace pointing to where AbortSignal.timeout() was called", async () => { + // This test verifies fix for issue #25182 + // The stack trace should point to where AbortSignal.timeout() was called, + // not just where the timeout was triggered internally + + async function myFunction() { + const signal = AbortSignal.timeout(50); + await new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + setTimeout(resolve, 1000); + }); + } + + try { + await myFunction(); + expect.unreachable("Should have thrown"); + } catch (e: any) { + expect(e instanceof DOMException).toBe(true); + expect(e.name).toBe("TimeoutError"); + expect(e.message).toBe("The operation timed out."); + + // The stack trace should include 'myFunction' + // since that's where AbortSignal.timeout() was called + const stack = e.stack; + expect(stack).toBeDefined(); + expect(typeof stack).toBe("string"); + + // Check that stack trace includes the calling function + // This is the key fix - previously this would be empty or only have internals + expect(stack).toInclude("myFunction"); + } + }); + + test("stack trace should include the test file name", async () => { + try { + const signal = AbortSignal.timeout(10); + await new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason)); + setTimeout(resolve, 1000); + }); + } catch (e: any) { + // Stack should reference this test file + expect(e.stack).toInclude("abort-signal-timeout-stack.test.ts"); + } + }); +}); From 1ddf0d0195c072c63650c8aa145f8053187291fb Mon Sep 17 00:00:00 2001 From: Andrew Leng Date: Thu, 4 Dec 2025 18:29:04 -0500 Subject: [PATCH 2/2] fix: address coderabbitai review feedback - Remove unused errorForStack variable in AbortSignal::timeout() - Simplify captured-stack handling in signalAbort() - Add implementation location comment for toJS() declaration - Add FIXME for GC/write-barrier handling of m_timeoutCreationStack --- src/bun.js/bindings/webcore/AbortSignal.cpp | 8 +++++--- src/bun.js/bindings/webcore/AbortSignal.h | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bun.js/bindings/webcore/AbortSignal.cpp b/src/bun.js/bindings/webcore/AbortSignal.cpp index a6e8c4dd8eaa..0d4e6b915e06 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.cpp +++ b/src/bun.js/bindings/webcore/AbortSignal.cpp @@ -71,7 +71,6 @@ Ref AbortSignal::timeout(ScriptExecutionContext& context, uint64_t // This ensures the TimeoutError has a stack trace pointing to where timeout() was called if (auto* globalObject = context.jsGlobalObject()) { auto& vm = globalObject->vm(); - JSC::JSObject* errorForStack = JSC::constructEmptyObject(globalObject); if (auto* errorInstance = JSC::jsDynamicCast(JSC::createError(globalObject, String()))) { errorInstance->captureStackTrace(vm, globalObject, 1); signal->m_timeoutCreationStack.setWeakly(errorInstance); @@ -224,8 +223,11 @@ void AbortSignal::signalAbort(JSC::JSGlobalObject* globalObject, CommonAbortReas m_commonReason = reason; // Pass the captured stack trace if this is a timeout signal - JSValue capturedStack = m_timeoutCreationStack.getValue(); - signalAbort(toJS(globalObject, reason, capturedStack.isObject() ? jsCast(capturedStack) : nullptr)); + JSC::JSObject* capturedStack = nullptr; + JSValue stackValue = m_timeoutCreationStack.getValue(); + if (stackValue.isObject()) + capturedStack = jsCast(stackValue); + signalAbort(toJS(globalObject, reason, capturedStack)); } void AbortSignal::cleanNativeBindings(void* ref) diff --git a/src/bun.js/bindings/webcore/AbortSignal.h b/src/bun.js/bindings/webcore/AbortSignal.h index 50963ba32f84..8f6e7b43791e 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.h +++ b/src/bun.js/bindings/webcore/AbortSignal.h @@ -55,6 +55,7 @@ enum class CommonAbortReason : uint8_t { ConnectionClosed, }; +// Implemented in ErrorCode.cpp JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason, JSC::JSObject* capturedStack = nullptr); typedef void* AbortSignalTimeout; @@ -190,6 +191,8 @@ class AbortSignal final : public RefCounted, public EventTargetWith std::atomic pendingActivityCount { 0 }; uint32_t m_algorithmIdentifier { 0 }; AbortSignalTimeout m_timeout { nullptr }; + // FIXME: m_timeoutCreationStack uses JSValueInWrappedObject + setWeakly like m_reason; + // both need proper write-barrier handling for GC safety. JSValueInWrappedObject m_timeoutCreationStack; // Captured stack at AbortSignal.timeout() call site uint8_t m_flags { 0 }; };