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..0d4e6b915e06 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.cpp +++ b/src/bun.js/bindings/webcore/AbortSignal.cpp @@ -66,6 +66,17 @@ 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(); + 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 +221,13 @@ 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 + 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 68ded5d832f2..8f6e7b43791e 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.h +++ b/src/bun.js/bindings/webcore/AbortSignal.h @@ -55,7 +55,8 @@ enum class CommonAbortReason : uint8_t { ConnectionClosed, }; -JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason); +// Implemented in ErrorCode.cpp +JSC::JSValue toJS(JSC::JSGlobalObject*, CommonAbortReason, JSC::JSObject* capturedStack = nullptr); typedef void* AbortSignalTimeout; @@ -125,6 +126,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 +191,9 @@ 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 }; }; 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"); + } + }); +});