Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
92693b40ee fix: capture stack traces for DOMException instances
DOMException instances had `undefined` for their `.stack` property,
unlike Node.js where DOMException properly captures stack traces.
This affected both `new DOMException()` and internally-created
DOMExceptions (e.g., from `AbortSignal.abort()`).

The fix captures a stack trace when any DOMException JS wrapper is
created, using JSC's `Interpreter::getStackTrace()`. The stack trace
is formatted with the correct DOMException name and message (e.g.,
`AbortError: The operation was aborted.`).

Closes #17877

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:38:23 +00:00
5 changed files with 122 additions and 22 deletions

View File

@@ -22,6 +22,7 @@
#include "BunClientData.h"
#include "CallSite.h"
#include "ErrorStackTrace.h"
#include "JSDOMException.h"
#include "headers-handwritten.h"
using namespace JSC;
@@ -379,6 +380,9 @@ static String computeErrorInfoWithoutPrepareStackTrace(
RETURN_IF_EXCEPTION(scope, {});
message = instance->sanitizedMessageString(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
} else if (auto* domException = jsDynamicCast<WebCore::JSDOMException*>(errorInstance)) {
name = domException->wrapped().name();
message = domException->wrapped().message();
}
}

View File

@@ -46,6 +46,10 @@
#include <wtf/PointerPreparations.h>
#include <wtf/URL.h>
#include "FormatStackTraceForJS.h"
#include "ZigGlobalObject.h"
#include <JavaScriptCore/Interpreter.h>
namespace WebCore {
using namespace JSC;
@@ -120,6 +124,34 @@ static const HashTableValue JSDOMExceptionConstructorTableValues[] = {
{ "DATA_CLONE_ERR"_s, JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::ConstantInteger, NoIntrinsic, { HashTableValue::ConstantType, 25 } },
};
static void captureStackTraceForDOMException(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSObject* errorObject)
{
if (!vm.topCallFrame)
return;
auto* zigGlobalObject = jsDynamicCast<Zig::GlobalObject*>(lexicalGlobalObject);
if (!zigGlobalObject)
zigGlobalObject = ::defaultGlobalObject(lexicalGlobalObject);
size_t stackTraceLimit = zigGlobalObject->stackTraceLimit().value();
if (stackTraceLimit == 0)
stackTraceLimit = Bun::DEFAULT_ERROR_STACK_TRACE_LIMIT;
WTF::Vector<JSC::StackFrame> stackTrace;
vm.interpreter.getStackTrace(errorObject, stackTrace, 0, stackTraceLimit);
if (stackTrace.isEmpty())
return;
unsigned int line = 0;
unsigned int column = 0;
String sourceURL;
JSValue result = Bun::computeErrorInfoWrapperToJSValue(vm, stackTrace, line, column, sourceURL, errorObject, nullptr);
if (result)
errorObject->putDirect(vm, vm.propertyNames->stack, result, 0);
}
template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSDOMExceptionDOMConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame)
{
auto& vm = JSC::getVM(lexicalGlobalObject);
@@ -362,26 +394,12 @@ void JSDOMExceptionOwner::finalize(JSC::Handle<JSC::Unknown> handle, void* conte
// #endif
// #endif
JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref<DOMException>&& impl)
JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Ref<DOMException>&& impl)
{
// if constexpr (std::is_polymorphic_v<DOMException>) {
// #if ENABLE(BINDING_INTEGRITY)
// // const void* actualVTablePointer = getVTablePointer(impl.ptr());
// #if PLATFORM(WIN)
// void* expectedVTablePointer = __identifier("??_7DOMException@WebCore@@6B@");
// #else
// // void* expectedVTablePointer = &_ZTVN7WebCore12DOMExceptionE[2];
// #endif
// // If you hit this assertion you either have a use after free bug, or
// // DOMException has subclasses. If DOMException has subclasses that get passed
// // to toJS() we currently require DOMException you to opt out of binding hardening
// // by adding the SkipVTableValidation attribute to the interface IDL definition
// // RELEASE_ASSERT(actualVTablePointer == expectedVTablePointer);
// #endif
// }
return createWrapper<DOMException>(globalObject, WTF::move(impl));
auto* wrapper = createWrapper<DOMException>(globalObject, WTF::move(impl));
auto& vm = globalObject->vm();
captureStackTraceForDOMException(vm, lexicalGlobalObject ? lexicalGlobalObject : globalObject, wrapper);
return wrapper;
}
JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, DOMException& impl)

View File

@@ -56,8 +56,7 @@ describe("DOMException in Node.js environment", () => {
expect(DOMException.DATA_CLONE_ERR).toBe(25);
});
// TODO: missing stack trace on DOMException
it.failing("inherits prototype properties from Error", () => {
it("inherits prototype properties from Error", () => {
const error = new DOMException("Test error");
expect(error.toString()).toBe("Error: Test error");
expect(error.stack).toBeDefined();

View File

@@ -67,7 +67,7 @@ describe("AbortSignal", () => {
function fmt(value: any) {
const res = {};
for (const key in value) {
if (key === "column" || key === "line" || key === "sourceURL") continue;
if (key === "column" || key === "line" || key === "sourceURL" || key === "stack") continue;
res[key] = value[key];
}
return res;

View File

@@ -0,0 +1,79 @@
import { expect, test } from "bun:test";
test("DOMException from new DOMException() has a stack trace", () => {
const e = new DOMException("test error", "AbortError");
expect(typeof e.stack).toBe("string");
expect(e.stack).toContain("AbortError: test error");
expect(e.stack).toContain("17877.test");
expect(e instanceof DOMException).toBe(true);
expect(e instanceof Error).toBe(true);
});
test("DOMException from AbortSignal.abort() has a stack trace", () => {
const signal = AbortSignal.abort();
try {
signal.throwIfAborted();
expect.unreachable();
} catch (err: any) {
expect(typeof err.stack).toBe("string");
expect(err.stack).toContain("AbortError");
expect(err.stack).toContain("The operation was aborted");
expect(err instanceof DOMException).toBe(true);
expect(err instanceof Error).toBe(true);
}
});
test("DOMException stack trace includes correct name and message", () => {
const e = new DOMException("custom message", "NotFoundError");
expect(typeof e.stack).toBe("string");
expect(e.stack).toStartWith("NotFoundError: custom message\n");
});
test("DOMException with default args has a stack trace", () => {
const e = new DOMException();
expect(typeof e.stack).toBe("string");
expect(e.name).toBe("Error");
expect(e.message).toBe("");
});
test("DOMException stack trace shows correct call site", () => {
function createException() {
return new DOMException("inner", "DataError");
}
const e = createException();
expect(typeof e.stack).toBe("string");
expect(e.stack).toContain("createException");
});
test("DOMException.stack is writable", () => {
const e = new DOMException("test", "AbortError");
expect(typeof e.stack).toBe("string");
e.stack = "custom stack";
expect(e.stack).toBe("custom stack");
});
test("DOMException from AbortSignal.abort() with custom reason has no stack on reason", () => {
const reason = "custom reason string";
const signal = AbortSignal.abort(reason);
try {
signal.throwIfAborted();
expect.unreachable();
} catch (err: any) {
// When a custom reason (non-DOMException) is used, it's thrown as-is
expect(err).toBe("custom reason string");
}
});
test("DOMException from AbortSignal.abort() with DOMException reason has stack", () => {
const reason = new DOMException("custom abort", "AbortError");
const signal = AbortSignal.abort(reason);
try {
signal.throwIfAborted();
expect.unreachable();
} catch (err: any) {
expect(err).toBe(reason);
expect(typeof err.stack).toBe("string");
expect(err.stack).toContain("AbortError: custom abort");
}
});