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
11 changes: 10 additions & 1 deletion src/runtime/test_runner/pretty_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::test_runner::expect::JSValueTestExt;
use core::ffi::c_void;

use bun_collections::HashMap;
use bun_core::{fmt as bun_fmt, Output};
use bun_core::{fmt as bun_fmt, Output, StackCheck};
use bun_jsc::{
self as jsc, ComptimeStringMapExt as _, JSGlobalObject, JSObject,
JSPropertyIterator, JSType, JSValue, JsError, JsResult, VM,
Expand Down Expand Up @@ -342,6 +342,7 @@ pub struct Formatter<'a> {
pub failed: bool,
pub estimated_line_length: usize,
pub always_newline_scope: bool,
pub stack_check: StackCheck,
}

impl<'a> Formatter<'a> {
Expand All @@ -357,6 +358,7 @@ impl<'a> Formatter<'a> {
failed: false,
estimated_line_length: 0,
always_newline_scope: false,
stack_check: StackCheck::init(),
}
}

Expand Down Expand Up @@ -1156,6 +1158,13 @@ impl<'a> Formatter<'a> {
if self.failed {
return Ok(());
}
if !self.stack_check.is_safe_to_recurse() {
// Deeply nested (non-cyclic) values would otherwise exhaust the native
// stack. Checked before the circular-reference gate so every
// self-recursive tag (Array/Object/Map/Set/JSX) is covered.
self.failed = true;
return Ok(());
}
// reshaped for borrowck — `WrappedWriter` borrows both writer_
// and &mut self.estimated_line_length; we use a local wrapper and sync
// `failed` at scope exit. estimated_line_length is unused by WrappedWriter
Expand Down
108 changes: 107 additions & 1 deletion test/js/bun/test/pretty-format-overflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Platform: Windows x86_64_baseline, Bun v1.3.0

import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness";

describe("pretty_format should handle deeply nested objects without crashing", () => {
test("deeply nested object with many properties", async () => {
Expand Down Expand Up @@ -51,3 +51,109 @@ test("deep nesting", () => {
expect(stderr).toContain("expect(received).toEqual(expected)");
}, 30000);
});

// A failing toEqual / toMatchSnapshot on a deeply nested (non-circular) value used to exhaust
// the native stack in pretty_format's Formatter::print_as and SIGSEGV the runner mid-run. Run
// in a subprocess so a regression fails these tests instead of segfaulting the outer runner.
describe.concurrent("pretty_format stops recursion before native stack overflow", () => {
const depth = 20000;

test("failing toEqual on a deeply nested array", async () => {
using dir = tempDir("pretty-format-deep-array", {
"deep.test.ts": `
import { test, expect } from "bun:test";
test("deep array", () => {
let a: any = [];
for (let i = 0; i < ${depth}; i++) a = [a];
expect(a).toEqual([1]);
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "deep.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("expect(received).toEqual(expected)");
expect(stderr).toContain("+ Received");
expect(stderr).toContain("1 fail");
expect(exitCode).toBe(1);
});

test("failing toEqual on a deeply nested object", async () => {
using dir = tempDir("pretty-format-deep-object", {
"deep.test.ts": `
import { test, expect } from "bun:test";
test("deep object", () => {
let a: any = {};
for (let i = 0; i < ${depth}; i++) a = { k: a };
expect(a).toEqual({ k: 1 });
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "deep.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("expect(received).toEqual(expected)");
expect(stderr).toContain("+ Received");
expect(stderr).toContain("1 fail");
expect(exitCode).toBe(1);
});

test("toMatchSnapshot on a deeply nested array", async () => {
using dir = tempDir("pretty-format-deep-snapshot", {
"deep.test.ts": `
import { test, expect } from "bun:test";
test("deep snapshot", () => {
let a: any = [];
for (let i = 0; i < ${depth}; i++) a = [a];
expect(a).toMatchSnapshot();
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "deep.test.ts"],
env: { ...bunEnv, CI: "false" },
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("1 pass");
expect(stderr).toContain("Ran 1 test");
expect(exitCode).toBe(0);
});

test("failing toEqual on a deeply nested React element chain", async () => {
using dir = tempDir("pretty-format-deep-jsx", {
"deep.test.ts": `
import { test, expect } from "bun:test";
test("deep jsx", () => {
let e: any = "x";
for (let i = 0; i < ${depth}; i++)
e = { $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: { children: e } };
expect(e).toEqual({});
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "deep.test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("expect(received).toEqual(expected)");
expect(stderr).toContain("1 fail");
expect(exitCode).toBe(1);
});
});
Loading