From c3c28ef993474aa2bf65c615fb32ee52d63fbe25 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:29:04 +0000 Subject: [PATCH 1/5] test_runner/pretty_format: add StackCheck to stop native stack overflow on deep values A failing toEqual diff or toMatchSnapshot over a ~20k-deep (non-circular) array or object would recurse Formatter::print_as until the native stack was exhausted and the test runner SIGSEGV'd mid-run with no summary. The sibling formatter in ConsoleObject.rs already carries a StackCheck guard for the same recursion; this one never had it. Add the field (initialized via StackCheck::init() so the bound is armed), check is_safe_to_recurse() before descending into Array/Object/Map/Set, and set failed=true when the limit is reached so the matcher still reports a normal failure with a truncated diff instead of crashing the process. --- src/runtime/test_runner/pretty_format.rs | 11 ++- .../bun/test/pretty-format-overflow.test.ts | 82 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 0c59618aa3d4..df37a78934c7 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(), } } @@ -1163,6 +1165,13 @@ impl<'a> Formatter<'a> { let mut writer = WrappedWriter::new(writer_); if FORMAT.can_have_circular_references() { + if !self.stack_check.is_safe_to_recurse() { + // Deeply nested (non-cyclic) values can exhaust the native stack. + // Stop recursion; the matcher still reports a normal failure. + self.failed = true; + return Ok(()); + } + if self.map_node.is_none() { // `visited::Pool::get()` returns an RAII `PoolGuard` that // would release on scope exit; instead the raw node is stashed on diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index 4acdf03b5110..b71dbafd1356 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,83 @@ test("deep nesting", () => { expect(stderr).toContain("expect(received).toEqual(expected)"); }, 30000); }); + +// A failing toEqual / toMatchSnapshot on a deeply-nested (non-circular) value used to walk +// the native stack to exhaustion inside pretty_format's Formatter::print_as, taking the whole +// runner down with SIGSEGV instead of reporting a matcher failure. Run in a subprocess so a +// regression fails these tests rather than 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("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("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); + }); +}); From ee3a94530388d0918b50d7830c3c0e73f821d38b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:32:22 +0000 Subject: [PATCH 2/5] test: condense regression comment to three lines --- test/js/bun/test/pretty-format-overflow.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index b71dbafd1356..760051f8fcfe 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -52,10 +52,9 @@ test("deep nesting", () => { }, 30000); }); -// A failing toEqual / toMatchSnapshot on a deeply-nested (non-circular) value used to walk -// the native stack to exhaustion inside pretty_format's Formatter::print_as, taking the whole -// runner down with SIGSEGV instead of reporting a matcher failure. Run in a subprocess so a -// regression fails these tests rather than segfaulting the outer runner. +// 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; From 38df8b3135e84276e8d3f5c8f09bb16389ad1624 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:40:22 +0000 Subject: [PATCH 3/5] test: assert diff legend is present in deep toEqual output --- test/js/bun/test/pretty-format-overflow.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index 760051f8fcfe..b1bee0444cc7 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -78,6 +78,7 @@ describe.concurrent("pretty_format stops recursion before native stack overflow" }); 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); }); @@ -102,6 +103,7 @@ describe.concurrent("pretty_format stops recursion before native stack overflow" }); 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); }); From bf068fb050833ee297087ebf1c0a27e6e461f039 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:49:19 +0000 Subject: [PATCH 4/5] pretty_format: hoist StackCheck above the circular-reference gate Tag::JSX recurses print_as -> format -> print_as:: via props.children but is not in can_have_circular_references(), so a ~20k-deep React element chain still exhausted the native stack with the check gated there. Move the is_safe_to_recurse() check to the top of print_as so every self-recursive tag is covered, and add a subprocess test for the JSX shape. --- src/runtime/test_runner/pretty_format.rs | 14 +++++------ .../bun/test/pretty-format-overflow.test.ts | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index df37a78934c7..424efe745f9b 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -1158,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 @@ -1165,13 +1172,6 @@ impl<'a> Formatter<'a> { let mut writer = WrappedWriter::new(writer_); if FORMAT.can_have_circular_references() { - if !self.stack_check.is_safe_to_recurse() { - // Deeply nested (non-cyclic) values can exhaust the native stack. - // Stop recursion; the matcher still reports a normal failure. - self.failed = true; - return Ok(()); - } - if self.map_node.is_none() { // `visited::Pool::get()` returns an RAII `PoolGuard` that // would release on scope exit; instead the raw node is stashed on diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index b1bee0444cc7..cdb53c0879ad 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -131,4 +131,29 @@ describe.concurrent("pretty_format stops recursion before native stack overflow" 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); + }); }); From faf9c956f67b1c0a536df1f5d4e7942fbe5f83fb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:06:06 +0000 Subject: [PATCH 5/5] ci: retrigger