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
2 changes: 2 additions & 0 deletions docs/runtime/console.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ console.log(nested);

The CLI flag takes precedence over the configuration file setting.

Bun uses the same depth when it prints an uncaught exception or an unhandled rejection whose value is not an `Error`.

---

## Reading from stdin
Expand Down
24 changes: 16 additions & 8 deletions src/jsc/ConsoleObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ bun_opaque::opaque_ffi! {
/// Only `--console-depth` CLI flag and `console.depth` bunfig option should modify this.
const DEFAULT_CONSOLE_LOG_DEPTH: u16 = 2;

/// `--console-depth`, bunfig `console.depth`, or the default.
pub(crate) fn console_depth() -> u16 {
bun_options_types::context::try_get()
.and_then(|ctx| ctx.runtime_options.console_depth)
.unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH)
}

type Counter = HashMap<u64, u32>;

pub struct ConsoleObject {
Expand Down Expand Up @@ -468,17 +475,13 @@ fn message_with_type_and_level_(
}

let mut print_length = len;
// Get console depth from CLI options or bunfig, fallback to default.
let console_depth = bun_options_types::context::try_get()
.and_then(|ctx| ctx.runtime_options.console_depth)
.unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH);

let mut print_options = FormatOptions {
enable_colors,
add_newline: true,
flush: true,
default_indent,
max_depth: console_depth,
max_depth: console_depth(),
error_display_level: match level {
MessageLevel::Error => ErrorDisplayLevel::Full,
MessageLevel::Warning => ErrorDisplayLevel::Warn,
Expand Down Expand Up @@ -1683,6 +1686,13 @@ pub mod formatter {
}
}

/// The error handler prints at the console depth, as `console.error` does.
pub fn for_error_handler(global_this: &'a JSGlobalObject) -> Self {
let mut formatter = Self::new(global_this);
formatter.max_depth = console_depth();
formatter
}

/// `Formatter` has a `Drop` impl and owns `map`/`map_node`,
/// so a bit-copy via `ptr::read` would double-free. Only scalar
/// config needs to ship — `map`/`map_node` are always empty
Expand Down Expand Up @@ -5924,9 +5934,7 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog(
// `Formatter` has a `Drop` impl, so struct-update from a
// temporary is rejected (E0509). Construct via `new()` then mutate.
let mut fmt = Formatter::new(global);
fmt.max_depth = bun_options_types::context::try_get()
.and_then(|ctx| ctx.runtime_options.console_depth)
.unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH);
fmt.max_depth = console_depth();
fmt.stack_check = StackCheck::init();
fmt.can_throw_stack_overflow = true;
let console = vm_console(global);
Expand Down
11 changes: 6 additions & 5 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4803,7 +4803,7 @@ impl VirtualMachine {
writer: &mut bun_core::io::Writer,
allow_side_effects: bool,
) {
let mut formatter = crate::console_object::Formatter::new(self.global());
let mut formatter = crate::console_object::Formatter::for_error_handler(self.global());
let colors = bun_core::Output::enable_ansi_colors_stderr();
self.print_errorlike_object(
exception.value(),
Expand Down Expand Up @@ -6437,11 +6437,12 @@ impl VirtualMachine {
TagOptions::DISABLE_INSPECT_CUSTOM | TagOptions::HIDE_GLOBAL,
)?;
if !matches!(tag.tag, TagPayload::NativeCode) {
let _ = if allow_ansi_color {
formatter.format::<true>(tag, writer, error_instance, global_ref)
// The caller clears the exception a getter or the stack guard left pending.
if allow_ansi_color {
formatter.format::<true>(tag, writer, error_instance, global_ref)?;
} else {
formatter.format::<false>(tag, writer, error_instance, global_ref)
};
formatter.format::<false>(tag, writer, error_instance, global_ref)?;
}
writer.write_all(b"\n")?;
}
}
Expand Down
4 changes: 1 addition & 3 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1250,9 +1250,7 @@ fn print_exception(
let exception = unsafe { &*exception };
vm_ref.print_exception(exception, exception_list, writer, true);
} else {
let mut formatter = bun_jsc::console_object::Formatter::new(global);
// `Formatter::new` already
// defaults `error_display_level` to `Full` (ConsoleObject.rs:1176).
let mut formatter = bun_jsc::console_object::Formatter::for_error_handler(global);
let colors = bun_core::Output::enable_ansi_colors_stderr();
vm_ref.print_errorlike_object(
value,
Expand Down
218 changes: 216 additions & 2 deletions test/js/bun/util/reportError.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawnSync } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness";
import { join } from "path";

test("reportError", () => {
Expand Down Expand Up @@ -122,3 +122,217 @@ test("native error printer handles lone surrogates in message and stack frame na
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(1);
});

// The error printer receives these values through `run_error_handler`. A value
// that is not an `Error` is printed whole, so it follows the `console.log`
// depth (2 by default, `--console-depth`, bunfig `console.depth`) instead of
// the formatter's default depth of 8.
describe("an uncaught value that is not an Error is printed at the console depth", () => {
// Four object levels. Depth 2 prints `a` and `b` and elides `c`.
const value = "{ a: { b: { c: { d: 1 } } } }";

async function run(args: string[], files: Record<string, string> = {}) {
using dir = tempDir("uncaught-depth", files);
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return {
stdout: normalizeBunSnapshot(stdout, String(dir)),
stderr: normalizeBunSnapshot(stderr, String(dir)),
exitCode,
};
}

const atDepth2 = `error
{
a: {
b: {
c: [Object ...],
},
},
}

Bun v<bun-version>`;

const atDepth3 = `error
{
a: {
b: {
c: {
d: 1,
},
},
},
}

Bun v<bun-version>`;

test.concurrent.each([
["reportError", `reportError(${value})`],
["unhandled rejection", `Promise.reject(${value})`],
["uncaught exception", `throw ${value}`],
["member of an uncaught AggregateError", `reportError(new AggregateError([${value}]))`],
])("%s", async (_, code) => {
expect(await run(["-e", code])).toEqual({ stdout: "", stderr: atDepth2, exitCode: 1 });
});

test.concurrent("--console-depth 1 prints one level", async () => {
expect(await run(["--console-depth", "1", "-e", `reportError(${value})`])).toEqual({
stdout: "",
stderr: `error
{
a: {
b: [Object ...],
},
}

Bun v<bun-version>`,
exitCode: 1,
});
});

test.concurrent("--console-depth 3 prints three levels", async () => {
expect(await run(["--console-depth", "3", "-e", `reportError(${value})`])).toEqual({
stdout: "",
stderr: atDepth3,
exitCode: 1,
});
});

test.concurrent("bunfig console.depth = 3 prints three levels", async () => {
const files = { "bunfig.toml": "[console]\ndepth = 3\n" };
expect(await run(["-e", `reportError(${value})`], files)).toEqual({
stdout: "",
stderr: atDepth3,
exitCode: 1,
});
});

test.concurrent("--console-depth 0 (unlimited) prints every level", async () => {
// 12 levels of `o`, more than the formatter's default depth of 8.
const levels = 12;
const lines = ["error", "{"];
for (let i = 1; i <= levels; i++) lines.push(`${" ".repeat(i)}o: {`);
lines.push(`${" ".repeat(levels + 1)}leaf: 1,`);
for (let i = levels; i >= 1; i--) lines.push(`${" ".repeat(i)}},`);
lines.push("}", "", "Bun v<bun-version>");
const code = `let o = { leaf: 1 }; for (let i = 0; i < ${levels}; i++) o = { o }; reportError(o);`;
expect(await run(["--console-depth", "0", "-e", code])).toEqual({
stdout: "",
stderr: lines.join("\n"),
exitCode: 1,
});
});

test.concurrent("--console-depth 0 on a value deeper than the stack: the script continues", async () => {
// The property walk throws a stack overflow RangeError partway down. The
// printer has to clear it, or reportError() throws it into the script.
// The partial dump is several MB, so stderr is not captured.
const code = `
let o = { leaf: 1 };
for (let i = 0; i < 20000; i++) o = { o };
try {
reportError(o);
console.log("after");
} catch (e) {
console.log("caught " + e.name);
}
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "--console-depth", "0", "-e", code],
env: bunEnv,
stdout: "pipe",
stderr: "ignore",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe("after\n");
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(1);
});

// An Error does not read the console depth: its own properties print at
// depth 1, and every cause prints. A change that makes more of this output
// follow the console depth has to update these on purpose.
test.concurrent("an uncaught Error prints its properties and causes as before", async () => {
const code = `
const e = new Error("root", { cause: new Error("c1", { cause: new Error("c2", { cause: new Error("c3") }) }) });
e.deep = { a: { b: { c: 1 } } };
throw e;
`;
const { stdout, stderr, exitCode } = await run(["-e", code]);
expect(stderr).toContain("error: root\n deep: {\n a: [Object ...],\n},\n");
expect(stderr.split("\n").filter(line => line.startsWith("error: "))).toEqual([
"error: root",
"error: c1",
"error: c2",
"error: c3",
]);
expect(stdout).toBe("");
expect(exitCode).toBe(1);
});

test.concurrent("an uncaught AggregateError prints each Error member as before", async () => {
const code = `throw new AggregateError([new Error("m1"), new Error("m2"), new Error("m3")], "agg");`;
const { stdout, stderr, exitCode } = await run(["-e", code]);
expect(stderr.split("\n").filter(line => line.startsWith("error: "))).toEqual([
"error: m1",
"error: m2",
"error: m3",
]);
expect(stdout).toBe("");
expect(exitCode).toBe(1);
});

test.concurrent(
"bun test: a test that rejects with the value, and an unhandled rejection during a test",
async () => {
const files = {
"depth.test.ts": `
import { test } from "bun:test";
const value = ${value};
test("rejects with the value", async () => {
throw value;
});
test("unhandled rejection while the test runs", async () => {
Promise.reject(value);
// The rejection is reported at the microtask checkpoint after the
// callback returns, before this timer fires.
await new Promise(resolve => setTimeout(resolve, 0));
});
`,
};
expect(await run(["test", "./depth.test.ts"], files)).toEqual({
stdout: "bun test <version> (<revision>)",
stderr: `depth.test.ts:
error
{
a: {
b: {
c: [Object ...],
},
},
}
(fail) rejects with the value
error
{
a: {
b: {
c: [Object ...],
},
},
}
(fail) unhandled rejection while the test runs

0 pass
2 fail
Ran 2 tests across 1 file.`,
exitCode: 1,
});
},
);
});
Loading