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
8 changed files with 151 additions and 208 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

@@ -57,11 +57,10 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
for (const auto& it : vec) {
const auto& name = it.key;
const auto& value = it.value;
const auto lowercaseName = WTF::httpHeaderNameStringImpl(name);
const auto canonicalName = WTF::httpHeaderNameDefaultCaseStringImpl(name);
const auto impl = WTF::httpHeaderNameStringImpl(name);
JSString* jsValue = jsString(vm, value);
obj->putDirect(vm, Identifier::fromString(vm, lowercaseName), jsValue, 0);
array->putDirectIndex(globalObject, arrayI++, jsString(vm, canonicalName));
obj->putDirect(vm, Identifier::fromString(vm, impl), jsValue, 0);
array->putDirectIndex(globalObject, arrayI++, jsString(vm, impl));
array->putDirectIndex(globalObject, arrayI++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -75,10 +74,9 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
if (count > 0) {
JSC::JSArray* setCookies = constructEmptyArray(globalObject, nullptr, count);
RETURN_IF_EXCEPTION(scope, {});
const auto setCookieHeaderStringLowercase = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieHeaderStringCanonical = WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieHeaderString = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
JSString* setCookie = jsString(vm, setCookieHeaderStringCanonical);
JSString* setCookie = jsString(vm, setCookieHeaderString);
for (size_t i = 0; i < count; ++i) {
auto* out = jsString(vm, values[i]);
@@ -89,7 +87,7 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
}
RETURN_IF_EXCEPTION(scope, {});
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderStringLowercase), setCookies, 0);
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderString), setCookies, 0);
}
}
@@ -156,14 +154,14 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers();
Identifier nameIdentifier;
JSString* rawHeaderNameString = nullptr;
JSString* nameString = nullptr;
if (WebCore::findHTTPHeaderName(nameView, name)) {
rawHeaderNameString = jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(name));
nameString = identifiers.stringFor(globalObject, name);
nameIdentifier = identifiers.identifierFor(vm, name);
} else {
WTF::String wtfString = nameView.toString();
rawHeaderNameString = jsString(vm, wtfString);
nameString = jsString(vm, wtfString);
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
}
@@ -171,7 +169,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, );
setCookiesHeaderString = rawHeaderNameString;
setCookiesHeaderString = nameString;
headersObject->putDirect(vm, nameIdentifier, setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, void());
}
@@ -183,7 +181,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(rawHeaderNameString);
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
@@ -337,15 +335,15 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
WTF::String nameString;
WTF::String lowercasedNameString;
WTF::String canonicalNameString;
if (WebCore::findHTTPHeaderName(nameView, name)) {
lowercasedNameString = WTF::httpHeaderNameStringImpl(name);
canonicalNameString = WTF::httpHeaderNameDefaultCaseStringImpl(name);
nameString = WTF::httpHeaderNameStringImpl(name);
lowercasedNameString = nameString;
} else {
canonicalNameString = nameView.toString();
lowercasedNameString = canonicalNameString.convertToASCIILowercase();
nameString = nameView.toString();
lowercasedNameString = nameString.convertToASCIILowercase();
}
JSString* jsValue = jsString(vm, value);
@@ -354,7 +352,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
setCookiesHeaderString = jsString(vm, canonicalNameString);
setCookiesHeaderString = jsString(vm, nameString);
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -365,7 +363,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
} else {
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, canonicalNameString));
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}

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

@@ -34,112 +34,34 @@ const { FakeSocket } = require("internal/http/FakeSocket");
var defaultIncomingOpts = { type: "request" };
const nop = () => {};
// Map of lowercase header names to their canonical (Title-Case) form.
// Used by assignHeadersSlow to restore proper casing for rawHeaders,
// since the Fetch API normalizes all header names to lowercase.
const canonicalHeaderNames: Record<string, string> = {
"accept": "Accept",
"accept-charset": "Accept-Charset",
"accept-encoding": "Accept-Encoding",
"accept-language": "Accept-Language",
"accept-ranges": "Accept-Ranges",
"access-control-allow-credentials": "Access-Control-Allow-Credentials",
"access-control-allow-headers": "Access-Control-Allow-Headers",
"access-control-allow-methods": "Access-Control-Allow-Methods",
"access-control-allow-origin": "Access-Control-Allow-Origin",
"access-control-expose-headers": "Access-Control-Expose-Headers",
"access-control-max-age": "Access-Control-Max-Age",
"access-control-request-headers": "Access-Control-Request-Headers",
"access-control-request-method": "Access-Control-Request-Method",
"age": "Age",
"authorization": "Authorization",
"cache-control": "Cache-Control",
"connection": "Connection",
"content-disposition": "Content-Disposition",
"content-encoding": "Content-Encoding",
"content-language": "Content-Language",
"content-length": "Content-Length",
"content-location": "Content-Location",
"content-range": "Content-Range",
"content-security-policy": "Content-Security-Policy",
"content-security-policy-report-only": "Content-Security-Policy-Report-Only",
"content-type": "Content-Type",
"cookie": "Cookie",
"cookie2": "Cookie2",
"date": "Date",
"dnt": "DNT",
"etag": "ETag",
"expect": "Expect",
"expires": "Expires",
"host": "Host",
"if-match": "If-Match",
"if-modified-since": "If-Modified-Since",
"if-none-match": "If-None-Match",
"if-range": "If-Range",
"if-unmodified-since": "If-Unmodified-Since",
"keep-alive": "Keep-Alive",
"last-modified": "Last-Modified",
"link": "Link",
"location": "Location",
"origin": "Origin",
"pragma": "Pragma",
"proxy-authorization": "Proxy-Authorization",
"range": "Range",
"referer": "Referer",
"referrer-policy": "Referrer-Policy",
"refresh": "Refresh",
"sec-fetch-dest": "Sec-Fetch-Dest",
"sec-fetch-mode": "Sec-Fetch-Mode",
"sec-websocket-accept": "Sec-WebSocket-Accept",
"sec-websocket-extensions": "Sec-WebSocket-Extensions",
"sec-websocket-key": "Sec-WebSocket-Key",
"sec-websocket-protocol": "Sec-WebSocket-Protocol",
"sec-websocket-version": "Sec-WebSocket-Version",
"server-timing": "Server-Timing",
"set-cookie": "Set-Cookie",
"set-cookie2": "Set-Cookie2",
"strict-transport-security": "Strict-Transport-Security",
"te": "TE",
"trailer": "Trailer",
"transfer-encoding": "Transfer-Encoding",
"upgrade": "Upgrade",
"upgrade-insecure-requests": "Upgrade-Insecure-Requests",
"user-agent": "User-Agent",
"vary": "Vary",
"via": "Via",
"x-content-type-options": "X-Content-Type-Options",
"x-dns-prefetch-control": "X-DNS-Prefetch-Control",
"x-frame-options": "X-Frame-Options",
"x-xss-protection": "X-XSS-Protection",
};
function assignHeadersSlow(object, req) {
const headers = req.headers;
var outHeaders = Object.create(null);
const rawHeaders: string[] = [];
var i = 0;
for (let key in headers) {
var value = headers[key];
var lowercaseKey = key.toLowerCase();
var rawHeaderName = canonicalHeaderNames[lowercaseKey] || key;
var originalKey = key;
var value = headers[originalKey];
if (lowercaseKey !== "set-cookie") {
key = key.toLowerCase();
if (key !== "set-cookie") {
value = String(value);
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, value);
outHeaders[lowercaseKey] = value;
outHeaders[key] = value;
} else {
if ($isJSArray(value)) {
outHeaders[lowercaseKey] = value.slice();
outHeaders[key] = value.slice();
for (let entry of value) {
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, entry);
}
} else {
value = String(value);
outHeaders[lowercaseKey] = [value];
$putByValDirect(rawHeaders, i++, rawHeaderName);
outHeaders[key] = [value];
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, value);
}
}

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");
}
});

View File

@@ -1,77 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/20433
// req.rawHeaders on node:http server should preserve canonical header name casing
test("node:http IncomingMessage rawHeaders should have canonical-cased header names", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http = require("node:http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(req.rawHeaders));
});
server.listen(0, () => {
const port = server.address().port;
fetch("http://localhost:" + port + "/", {
headers: {
"Accept-Encoding": "gzip, deflate",
"Accept": "*/*",
"Connection": "keep-alive",
"Authorization": "xxx",
"Origin": "something",
"Content-Type": "text/plain",
},
})
.then((r) => r.json())
.then((rawHeaders) => {
// rawHeaders is [name, value, name, value, ...]
const headerNames = rawHeaders.filter((_, i) => i % 2 === 0);
console.log(JSON.stringify(headerNames));
server.close();
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const headerNames: string[] = JSON.parse(stdout.trim());
// Known headers should be in canonical Title-Case form
const expectedCanonical: Record<string, string> = {
accept: "Accept",
"accept-encoding": "Accept-Encoding",
connection: "Connection",
authorization: "Authorization",
origin: "Origin",
"content-type": "Content-Type",
host: "Host",
"user-agent": "User-Agent",
};
for (const name of headerNames) {
const lower = name.toLowerCase();
if (expectedCanonical[lower]) {
expect(name).toBe(expectedCanonical[lower]);
}
}
// Verify that multi-word headers are present and properly cased
expect(headerNames).toContain("Accept-Encoding");
expect(headerNames).toContain("Authorization");
expect(headerNames).toContain("Content-Type");
expect(headerNames).toContain("Connection");
expect(headerNames).toContain("Origin");
expect(exitCode).toBe(0);
});