Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/jsc/bindings/FormatStackTraceForJS.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,28 @@ WTF::String formatStackTrace(
return sb.toString();
}

void addErrorInfoWithSourceMap(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSObject* object, const WTF::String& name, const WTF::String& message)
{
auto& vm = JSC::getVM(lexicalGlobalObject);
std::unique_ptr<Vector<StackFrame>> stackTrace = JSC::getStackTrace(vm, object, /* useCurrentFrame */ true);
if (!stackTrace)
return;

OrdinalNumber line = OrdinalNumber::beforeFirst();
OrdinalNumber column = OrdinalNumber::beforeFirst();
WTF::String sourceURL;
WTF::String stack = formatStackTrace(vm, defaultGlobalObject(lexicalGlobalObject), lexicalGlobalObject, name, message, line, column, sourceURL, *stackTrace, object);

constexpr unsigned attributes = JSC::PropertyAttribute::DontEnum | 0;
if (line != OrdinalNumber::beforeFirst()) {
object->putDirect(vm, vm.propertyNames->line, jsNumber(line.oneBasedInt()), attributes);
object->putDirect(vm, vm.propertyNames->column, jsNumber(column.oneBasedInt()), attributes);
}
if (!sourceURL.isEmpty())
object->putDirect(vm, vm.propertyNames->sourceURL, jsString(vm, WTF::move(sourceURL)), attributes);
object->putDirect(vm, vm.propertyNames->stack, jsString(vm, WTF::move(stack)), attributes);
}

// error.stack calls this function
static String computeErrorInfoWithoutPrepareStackTrace(
JSC::VM& vm,
Expand Down
3 changes: 3 additions & 0 deletions src/jsc/bindings/FormatStackTraceForJS.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ WTF::String formatStackTrace(
WTF::Vector<JSC::StackFrame>& stackTrace,
JSC::JSObject* errorInstance);

// JSC::addErrorInfo() for objects that are not ErrorInstances (DOMException), putting what an ErrorInstance materializes: source-mapped positions and a Bun-format stack.
void addErrorInfoWithSourceMap(JSC::JSGlobalObject*, JSC::JSObject*, const WTF::String& name, const WTF::String& message);

// JSC Host Functions - Error constructor methods
JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace);
JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncAppendStackTrace);
Expand Down
6 changes: 4 additions & 2 deletions src/jsc/bindings/JSDOMExceptionHandling.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include "ErrorCode.h"

#include "DOMException.h"
#include "FormatStackTraceForJS.h"
#include "JSDOMException.h"
#include "JSDOMExceptionHandling.h"
#include "JSDOMPromiseDeferred.h"
Expand Down Expand Up @@ -181,10 +182,11 @@ JSValue createDOMException(JSGlobalObject* lexicalGlobalObject, ExceptionCode ec
// frames[0].document.createElement(null, null); // throws an exception which should have the subframe's prototypes.
// https://bugs.webkit.org/show_bug.cgi?id=222229
JSDOMGlobalObject* globalObject = deprecatedGlobalObjectForPrototype(lexicalGlobalObject);
JSValue errorObject = toJS(lexicalGlobalObject, globalObject, DOMException::create(ec, message));
auto domException = DOMException::create(ec, message);
JSValue errorObject = toJS(lexicalGlobalObject, globalObject, domException.get());

ASSERT(errorObject);
addErrorInfo(lexicalGlobalObject, asObject(errorObject), true);
Bun::addErrorInfoWithSourceMap(lexicalGlobalObject, asObject(errorObject), domException->name(), domException->message());
return errorObject;
}
}
Expand Down
21 changes: 14 additions & 7 deletions src/jsc/bindings/ZigException.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -770,19 +770,26 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal
}
if (line) {
if (line.isNumber()) {
except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(line.toInt32(global)).zeroBasedInt();
ZigStackFrame& frame = except.stack.frames_ptr[0];
frame.position.line_zero_based = OrdinalNumber::fromOneBasedInt(line.toInt32(global)).zeroBasedInt();
except.stack.frames_len = 1;

// TODO: don't sourcemap it twice
auto originalLine = obj->getIfPropertyExists(global, builtinNames(vm).originalLinePublicName());
auto column = obj->getIfPropertyExists(global, vm.propertyNames->column);
if (scope.exception()) [[unlikely]] {
scope.clearExceptionExceptTermination();
}
if (originalLine) {
if (originalLine.isNumber()) {
except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(originalLine.toInt32(global)).zeroBasedInt();
if (column) {
if (column.isNumber()) {
frame.position.column_zero_based = OrdinalNumber::fromOneBasedInt(column.toInt32(global)).zeroBasedInt();
}
}
except.stack.frames_len = 1;

// originalLine means line/column are already source-mapped, as in fromErrorInstance().
auto originalLine = obj->getIfPropertyExists(global, builtinNames(vm).originalLinePublicName());
if (scope.exception()) [[unlikely]] {
scope.clearExceptionExceptTermination();
}
frame.remapped = !!originalLine;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/webcrypto/SubtleCrypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,9 @@ static void rejectWithCause(Ref<DeferredPromise>&& promise, ExceptionCode ec, co
{
promise->rejectWithCallback([&](JSDOMGlobalObject& globalObject) -> JSC::JSValue {
auto& vm = globalObject.vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSC::JSValue cause = makeCause(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto exception = createDOMException(&globalObject, ec, message);
if (auto* exceptionObject = exception.getObject(); exceptionObject && cause)
exceptionObject->putDirect(vm, vm.propertyNames->cause, cause);
Expand Down
204 changes: 204 additions & 0 deletions test/js/node/domexception-node.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { realpathSync } from "node:fs";

describe("DOMException in Node.js environment", () => {
it("exists globally", () => {
Expand Down Expand Up @@ -72,3 +74,205 @@ describe("DOMException in Node.js environment", () => {
expect(abortError.code).toBe(20); // ABORT_ERR
});
});

// The transpiler drops these comment lines (and reformats what follows), so the positions
// JSC reports for the code below them are not the positions in the file. Positions Bun
// records on a DOMException it creates have to go through the source map like an Error's.
const padded = source => Array.from({ length: 10 }, (_, i) => `// padding ${i + 1}`).join("\n") + "\n" + source + "\n";

// 1-based line of the (unique) line of `source` containing `needle`.
function lineContaining(source, needle) {
const matches = source.split("\n").flatMap((line, i) => (line.includes(needle) ? [i + 1] : []));
expect(matches).toHaveLength(1);
return matches[0];
}

async function runFixture(name, source) {
using dir = tempDir("domexception-creation-site", { [name]: source });
await using proc = Bun.spawn({
cmd: [bunExe(), name],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
let [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
for (const path of new Set([realpathSync.native(String(dir)), String(dir)])) {
stderr = stderr.replaceAll(path, "<dir>");
}
return { stdout, stderr: stderr.replaceAll("\\", "/"), exitCode };
}

describe.concurrent("DOMExceptions created by Bun record where they were created", () => {
it("line, column, sourceURL and stack are positions in the source file, like an Error's", async () => {
// The callbacks passed to caught() have block bodies so that the call inside them is
// not a tail call, which strict mode code would otherwise drop from the stack.
const source = padded(`
function caught(fn) { try { fn(); } catch (e) { return e; } }
function nested() {
const error = new Error("reference"), reason = AbortSignal.abort().reason;
return { error, reason };
}
const controller = new AbortController();
controller.abort();
const fromNested = nested();
const rejectionOf = promise => promise.then(() => { throw new Error("expected a rejection"); }, e => e);
const exceptions = {
"AbortSignal.abort().reason": AbortSignal.abort().reason,
"AbortController#abort() reason": controller.signal.reason,
"atob() InvalidCharacterError": caught(() => { atob("!"); }),
"structuredClone() DataCloneError": caught(() => { structuredClone(() => {}); }),
"crypto.subtle.importKey() rejection": await rejectionOf(crypto.subtle.importKey("raw", new Uint8Array(3), "AES-GCM", false, ["encrypt"])),
"created inside a function": fromNested.reason,
};
const dump = e => ({
isDOMException: e instanceof DOMException,
name: e.name,
message: e.message,
line: e.line,
column: e.column,
sourceURL: e.sourceURL?.replaceAll(import.meta.path, "<file>"),
stack: e.stack?.replaceAll(import.meta.path, "<file>"),
enumerableKeys: Object.keys(e),
});
for (const name in exceptions) exceptions[name] = dump(exceptions[name]);
console.log(JSON.stringify({ exceptions, reference: dump(fromNested.error) }));
`);
const { stdout, stderr, exitCode } = await runFixture("creation-site.js", source);
expect(stderr).toBe("");
const { exceptions, reference } = JSON.parse(stdout);

// [line, column] of each frame of `stack` that is in the fixture file.
const framePositions = stack =>
stack
.split("\n")
.slice(1)
.filter(frame => frame.includes("<file>"))
.map(frame =>
frame
.match(/:(\d+):(\d+)\)?$/)
.slice(1)
.map(Number),
);
const summarize = ({ stack, line, column, ...rest }) => ({
...rest,
header: stack.split("\n")[0],
line,
topFrameIsAtLineAndColumn: Bun.deepEquals(framePositions(stack)[0], [line, column]),
frameLines: framePositions(stack).map(([line]) => line),
});
const expected = (name, message, frameLines) => ({
isDOMException: true,
name,
message,
header: `${name}: ${message}`,
line: frameLines[0],
sourceURL: "<file>",
topFrameIsAtLineAndColumn: true,
frameLines,
enumerableKeys: [],
});

const nestedLine = lineContaining(source, 'new Error("reference")');
const nestedCallLine = lineContaining(source, "= nested()");
const caughtLine = lineContaining(source, "function caught");
const atobLine = lineContaining(source, 'atob("!")');
const structuredCloneLine = lineContaining(source, "structuredClone(");
expect(Object.fromEntries(Object.entries(exceptions).map(([name, e]) => [name, summarize(e)]))).toEqual({
// AbortSignal.abort() stores the default reason as an enum; the DOMException is
// created when .reason is first read, which here is this line.
"AbortSignal.abort().reason": expected("AbortError", "The operation was aborted.", [
lineContaining(source, '"AbortSignal.abort().reason":'),
]),
// AbortController#abort() creates the reason while aborting.
"AbortController#abort() reason": expected("AbortError", "The operation was aborted.", [
lineContaining(source, "controller.abort();"),
]),
"atob() InvalidCharacterError": expected("InvalidCharacterError", "The string contains invalid characters.", [
atobLine,
caughtLine,
atobLine,
]),
"structuredClone() DataCloneError": expected("DataCloneError", "The object can not be cloned.", [
structuredCloneLine,
caughtLine,
structuredCloneLine,
]),
// Rejections are created while the API is being called, so they point at the call too.
"crypto.subtle.importKey() rejection": expected(
"DataError",
"Data provided to an operation does not meet requirements",
[lineContaining(source, '"crypto.subtle.importKey() rejection":')],
),
"created inside a function": expected("AbortError", "The operation was aborted.", [nestedLine, nestedCallLine]),
});
// The Error created on the same line as the nested DOMException reports the same frames.
expect({
sourceURL: reference.sourceURL,
frameLines: framePositions(reference.stack).map(([line]) => line),
}).toEqual({ sourceURL: "<file>", frameLines: [nestedLine, nestedCallLine] });
expect(exitCode).toBe(0);
});

// ML key imports reject through SubtleCrypto's rejectWithCause(), which builds the cause
// and then the DOMException, while the import call is still on the stack. The reference
// Error is created on the same line as the call.
it("a rejection that carries a cause is recorded at the call site too", async () => {
// prettier-ignore
const reference = new Error("reference"), pending = crypto.subtle.importKey("pkcs8", new Uint8Array(64), "ML-DSA-44", false, ["sign"]);
const rejection = await pending.then(
() => null,
e => e,
);

expect({
isDOMException: rejection instanceof DOMException,
causeIsError: rejection?.cause instanceof Error,
header: rejection?.stack.split("\n")[0],
line: rejection?.line,
sourceURL: rejection?.sourceURL,
}).toEqual({
isDOMException: true,
causeIsError: true,
header: "DataError: Invalid keyData",
line: reference.line,
sourceURL: reference.sourceURL,
});
});

it("a DOMException created with no JavaScript on the stack gets a header-only stack", async () => {
const signal = AbortSignal.timeout(0);
const { promise, resolve } = Promise.withResolvers();
signal.addEventListener("abort", resolve, { once: true });
await promise;

const { reason } = signal;
expect({ stack: reason.stack, line: reason.line, column: reason.column, sourceURL: reason.sourceURL }).toEqual({
stack: "TimeoutError: The operation timed out.",
line: undefined,
column: undefined,
sourceURL: undefined,
});
});

it.each([
["unhandled rejection", `Promise.reject(AbortSignal.abort().reason);`, "Promise.reject("],
["uncaught exception", `function fail() {\n atob("!");\n}\nfail();`, 'atob("!")'],
])("the %s printer reports the source line the DOMException was created on", async (_, body, needle) => {
const source = padded(body);
const expectedLine = lineContaining(source, needle);
const { stdout, stderr, exitCode } = await runFixture("uncaught.js", source);

const stackLines = stderr
.split("\n")
.map(line => line.trim())
.filter(line => line.startsWith("at "));
expect({ stdout, stackLines }).toEqual({
stdout: "",
stackLines: [expect.stringMatching(new RegExp(`^at <dir>/uncaught\\.js:${expectedLine}:\\d+$`))],
});
// The code excerpt above the error is taken from the source file at that line.
expect(stderr).toContain(`${expectedLine} | ${source.split("\n")[expectedLine - 1]}`);
expect(exitCode).toBe(1);
});
});
Loading