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
4 changes: 3 additions & 1 deletion src/jsc/Exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ impl Exception {
JSC__Exception__getStackTrace(self, global, stack);
}

pub fn value(&self) -> JSValue {
/// The `JSC::Exception` cell itself as a `JSValue`, not the value it wraps;
/// `JSValue::to_error` unwraps it.
pub fn as_js_value(&self) -> JSValue {
JSC__Exception__asJSValue(self)
}
}
18 changes: 14 additions & 4 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4773,7 +4773,7 @@ impl VirtualMachine {
let mut formatter = crate::console_object::Formatter::new(self.global());
let colors = bun_core::Output::enable_ansi_colors_stderr();
self.print_errorlike_object(
exception.value(),
exception.as_js_value(),
Some(exception),
exception_list,
&mut formatter,
Expand Down Expand Up @@ -5114,6 +5114,9 @@ impl VirtualMachine {
self.run_error_handler(value, None);
}

/// `value` is the thrown value or, from [`Self::print_exception`], the
/// `JSC::Exception` cell wrapping it (see [`Self::print_error_instance_js`]).
///
/// Note: takes runtime bools and the concrete `bun_core::io::Writer`.
pub fn print_errorlike_object(
&mut self,
Expand Down Expand Up @@ -5318,7 +5321,7 @@ impl VirtualMachine {
exception: &Exception,
) -> JSValue {
let jsc_vm = global_object.bun_vm().as_mut();
let _ = jsc_vm.uncaught_exception(global_object, exception.value(), false);
let _ = jsc_vm.uncaught_exception(global_object, exception.as_js_value(), false);
JSValue::UNDEFINED
}

Expand Down Expand Up @@ -5832,7 +5835,10 @@ impl VirtualMachine {
}

/// JS-value variant of the error printer; see
/// [`Self::print_error_instance_body`].
/// [`Self::print_error_instance_body`]. `error_instance` may be the
/// `JSC::Exception` cell wrapping the thrown value: `toZigException` takes
/// the throw-site stack from the cell, but the body has to be handed the
/// wrapped Error itself to print its own properties and `cause` chain.
fn print_error_instance_js(
&mut self,
error_instance: JSValue,
Expand Down Expand Up @@ -5898,10 +5904,14 @@ impl VirtualMachine {
);
error_instance.ensure_still_alive();

let error_value = match error_instance.to_error() {
Some(err) if err.js_type() == jsc::JSType::ErrorInstance => err,
_ => error_instance,
};
let result = self.print_error_instance_body(
// SAFETY: see above.
unsafe { &mut *exception },
error_instance,
error_value,
None, // Note: `exception_list` was already
// consumed by `remap_zig_exception` above (only writer).
formatter,
Expand Down
122 changes: 120 additions & 2 deletions test/js/bun/test/stack.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, bunRun, normalizeBunSnapshot, tempDir } from "harness";
import { join } from "node:path";

test("name property is used for function calls in Error.stack", () => {
Expand Down Expand Up @@ -149,3 +149,121 @@ test("Async functions frame should be included in stack trace", async () => {
at async <anonymous> (file:NN:NN)"
`);
});

// A test body that throws synchronously and an uncaught exception thrown from a
// callback both reach the error printer wrapped in the JSC exception that unwound
// the stack, whereas a rejection arrives as the Error itself. Both forms have to
// print the same details.
describe("errors printed from an uncaught exception", () => {
const throwErrorWithDetails = /* js */ `
const err = new Error("outer failure", { cause: new Error("inner cause") });
err.code = "ERR_FIXTURE";
err.detail = 42;
throw err;
`;

test.concurrent("bun test prints the properties and cause of a synchronously thrown error", async () => {
using dir = tempDir("sync-throw-details", {
"my.test.js": `import { test } from "bun:test";\ntest("sync throw", () => {${throwErrorWithDetails}});\n`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "my.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(normalizeBunSnapshot(stderr, String(dir))).toMatchInlineSnapshot(`
"my.test.js:
1 | import { test } from "bun:test";
2 | test("sync throw", () => {
3 | const err = new Error("outer failure", { cause: new Error("inner cause") });
4 | err.code = "ERR_FIXTURE";
5 | err.detail = 42;
6 | throw err;
^
error: outer failure
detail: 42,
code: "ERR_FIXTURE"
at <anonymous> (file:NN:NN)

1 | import { test } from "bun:test";
2 | test("sync throw", () => {
3 | const err = new Error("outer failure", { cause: new Error("inner cause") });
^
error: inner cause
at <anonymous> (file:NN:NN)
(fail) sync throw

0 pass
1 fail
Ran 1 test across 1 file."
`);
expect(exitCode).toBe(1);
});

test.concurrent("an uncaught exception thrown from a callback prints the properties and cause", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `setTimeout(() => {${throwErrorWithDetails}});`],
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
"1 | setTimeout(() => {
2 | const err = new Error("outer failure", { cause: new Error("inner cause") });
3 | err.code = "ERR_FIXTURE";
4 | err.detail = 42;
5 | throw err;
^
error: outer failure
detail: 42,
code: "ERR_FIXTURE"
at <anonymous> (file:NN:NN)

1 | setTimeout(() => {
2 | const err = new Error("outer failure", { cause: new Error("inner cause") });
^
error: inner cause
at <anonymous> (file:NN:NN)

Bun v<bun-version>"
`);
expect(exitCode).toBe(1);
});

test.concurrent("a synchronously thrown resolve error is still printed once", async () => {
using dir = tempDir("sync-throw-resolve-message", {
"my.test.js": `import { test } from "bun:test";\ntest("sync require", () => {\n require("./does-not-exist");\n});\n`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "my.test.js"],
cwd: String(dir),
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
// Debug builds add an internal `require` frame, which also moves the divot.
const withoutStackFrames = stderr
.split("\n")
.filter(line => !/^\s*(at |\^\s*$)/.test(line))
.join("\n");
expect(normalizeBunSnapshot(withoutStackFrames, String(dir))).toMatchInlineSnapshot(`
"my.test.js:
1 | import { test } from "bun:test";
2 | test("sync require", () => {
ResolveMessage: Cannot find module './does-not-exist'
Require stack:
- <dir>/my.test.js
(fail) sync require

0 pass
1 fail
Ran 1 test across 1 file."
`);
expect(exitCode).toBe(1);
});
});