diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 0c59618aa3d4..424efe745f9b 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -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, @@ -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> { @@ -357,6 +358,7 @@ impl<'a> Formatter<'a> { failed: false, estimated_line_length: 0, always_newline_scope: false, + stack_check: StackCheck::init(), } } @@ -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 diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index 4acdf03b5110..cdb53c0879ad 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -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 () => { @@ -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); + }); +});