Skip to content

inspect, pretty_format, ipc, yaml: harden recursive walkers against stack overflow and dangling state#34889

Open
robobun wants to merge 4 commits into
mainfrom
farm/76c712bf/formatter-stack-safety
Open

inspect, pretty_format, ipc, yaml: harden recursive walkers against stack overflow and dangling state#34889
robobun wants to merge 4 commits into
mainfrom
farm/76c712bf/formatter-stack-safety

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

A pattern audit of structured-clone / serialization walkers found five crash-class bugs in bun's own value walkers (the WebKit SerializedScriptValue core itself is well-hardened). This PR fixes the five memory-safety ones; the cross-realm inspect output bug will follow separately since it is output-correctness rather than a crash.

console.log / Bun.inspect stack overflow on JSX and Proxy

const jsx = { $$typeof: Symbol.for("react.element"), type: "div", props: {}, key: null };
jsx.props.children = jsx;
console.log(jsx);        // SIGSEGV

let p = {};
for (let i = 0; i < 100000; i++) p = new Proxy(p, {});
console.log(p);          // SIGSEGV

print_as_prelude ran the stack_check.is_safe_to_recurse() guard only after can_have_circular_references() returned true, and Tag::JSX / Tag::Proxy (plus Promise, the iterator tags, and CustomFormattedObject) were not in that set. Any of them could recurse straight past both the visited map and the stack check. The stack check now runs before the gate so every recursive tag is bounded, and JSX joins the circular-reference set so a self-referencing element prints [Circular] instead of hitting the depth limit.

Uncaught exception / unhandled rejection printer stack overflow

let a = []; for (let i = 0; i < 60000; i++) a = [a];
throw a;                 // SIGSEGV

Formatter::new() left stack_check at StackCheck::default() (a no-op whose is_safe_to_recurse() always returns true). format2 (the normal console.log path) reseated it with StackCheck::init(), but print_exception, Bun__inspect, and every expect-matcher call site did not, so their recursion guard never fired. Formatter::new() now seats a live StackCheck; the redundant reseats in format2 etc. are left in place as harmless.

bun test diff formatter stack overflow

let a = []; for (let i = 0; i < 30000; i++) a = [a];
expect(a).toEqual(["leaf"]);    // SIGSEGV in the diff formatter

JestPrettyFormat::Formatter had no stack or depth check at all. Added a StackCheck field and a check at the top of print_as that truncates with ... and sets failed.

IPC advanced-mode malformed frame surfaces as the parent's uncaught TypeError

[0x02][len=4][0xFF 0xFF 0xFF 0xFF]    // SSV version > CurrentVersion

SerializedScriptValue::deserialize throws TypeError: Unable to deserialize data. for an invalid wire version. The advanced-mode decoder propagated that as IPCDecodeError::JSError and the receive loop closed the socket without clearing the pending exception, so the parent saw an uncaught error on its next turn (an abort in debug). The decoder now clears the exception and returns InvalidFormat, matching the JSON-mode path.

YAML.stringify anchor name use-after-free

AnchorAliasName::PropValue.prop_name borrowed the scan pass's JSPropertyIterator PropertyNameArray entry as a bare bun_core::String (no ref). For a Proxy whose ownKeys returns fresh strings, nothing else holds that StringImpl; once the inner iterator drops and a later getter GCs, the emit pass reads freed bytes. prop_name is now an OwnedString built from dupe_ref().

ASAN report before the fix
AddressSanitizer: heap-use-after-free on address ...
READ of size 4 ...
  #3 ... bun_runtime::api::yaml_object::can_use_prop_name_as_anchor YAMLObject.rs:678
  #4 ... <Stringifier>::stringify YAMLObject.rs:407
freed by thread T0 here:
  #13 WTF::StringImpl::destroy
  #18 JSC::JSString::destroy
  (MarkedBlock sweep during Bun.gc)

Verification

All four test files fail before the src/ change and pass after:

bun bd test test/js/bun/util/inspect.test.js -t "deep / self-referencing"
bun bd test test/js/bun/test/pretty-format-overflow.test.ts
bun bd test test/js/bun/spawn/spawn.ipc.test.ts -t "malformed SerializedScriptValue"
bun bd test test/js/bun/yaml/yaml.test.ts -t "anchor name derived"

Supersedes #29709 (which added the Tag::JSX circular-reference entry; that change is included here along with the broader stack-check fix).


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/spawn/spawn.ipc.test.ts

…ending exceptions

ConsoleObject.rs / pretty_format.rs:
- Formatter::new() now seats StackCheck::init() instead of a no-op default,
  so every caller (uncaught-exception printer, Bun__inspect, all expect
  matchers) gets a real stack bound without having to remember to set one.
- print_as_prelude checks the stack before the can_have_circular_references
  gate, so tags that recurse but were not in that list (Proxy, JSX, Promise,
  iterators) no longer overflow the native stack.
- JSX added to can_have_circular_references in both formatters so a
  self-referencing element prints [Circular].
- JestPrettyFormat's Formatter gains a stack_check and print_as checks it,
  so a toEqual diff of a deeply nested value truncates instead of SEGV.

ipc.rs: when advanced-mode payload deserialization throws, clear the
pending TypeError and report InvalidFormat (matching the JSON-mode path),
so a child writing a malformed frame closes the channel instead of
surfacing as the parent's uncaught exception.

YAMLObject.rs: AnchorAliasName::PropValue owns its prop_name (OwnedString)
instead of borrowing the scan iterator's PropertyNameArray entry, which
is freed before the emit pass reads it when a getter GCs in between.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5ff0edb6-2d4c-494a-a9b8-7b95ff4a1d98

📥 Commits

Reviewing files that changed from the base of the PR and between 5b98630 and 520f3b5.

📒 Files selected for processing (9)
  • src/jsc/ConsoleObject.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/ipc.rs
  • src/runtime/api/YAMLObject.rs
  • src/runtime/test_runner/pretty_format.rs
  • test/js/bun/spawn/spawn.ipc.test.ts
  • test/js/bun/test/pretty-format-overflow.test.ts
  • test/js/bun/util/inspect.test.js
  • test/js/bun/yaml/yaml.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:11 AM PT - Jul 21st, 2026

@robobun, your commit 520f3b51e479504b39027a9b72ebe8437c74b316 passed in Build #76713! 🎉


🧪   To try this PR locally:

bunx bun-pr 34889

That installs a local version of the PR into your bun-34889 executable, so you can run:

bun-34889 --bun

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: all four changed test files fail before the src/ diff and pass after (verified via stash/rebuild). CI rerun on 520f3b5.

The YAML test is skipIf(!isASAN) because the use-after-free only manifests under ASAN with Malloc=1; under release the freed StringImpl bytes are usually not yet recycled.

Windows aarch64 CI found that on release lanes the error printer writes ~2.5GB to stderr before the stack check fires when the thrown value is a ~60k-deep array (release frames are small, and print_array has no max_depth gate like print_object does). The tests now spawn with stderr: "ignore" and assert on {signalCode, exitCode} instead of reading the output back. print_array honoring max_depth would be the proper bound there but is a user-visible behaviour change I have not bundled in.

Not in this PR: the cross-realm console.log output bug (foreign Object.prototype methods shown as own props, array index getters invoked). That is output-correctness rather than a crash and needs a more careful change to forEachPropertyImpl's prototype-walk stop condition; I will open that separately.

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. Runaway native recursion in bun 1.3.14 allocates unboundedly until the machine dies (3 concurrent processes; the 2 walkable stacks are identical) #34178 - Runaway native recursion showing recursive inspect/format stack frames; PR hardens recursive walkers against stack overflow
  2. Inconsistent Logging Behavior for Large/Recursive Objects When Used as Second+ Argument in console.log/Bun.inspect #16524 - Inconsistent logging for large/recursive objects in console.log/Bun.inspect; PR fixes recursion handling in inspect formatters
  3. bun crashed with error "panic(main thread): integer overflow oh no: Bun has crashed." when do bun test with @testing-library/react using toMatchSnapshot #19534 - Crash with 'panic: integer overflow' in pretty_format during toMatchSnapshot on React components; PR adds stack overflow protection to pretty_format
  4. ToMatchSnapshot broken with react components #5540 - toMatchSnapshot producing massive output with React components due to uncontrolled recursion; PR's pretty_format stack check directly addresses this

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #34178
Fixes #16524
Fixes #19534
Fixes #5540

🤖 Generated with Claude Code

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

On the suggested issues:

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Seat StackCheck in both Formatter::new() constructors so deep non-Error values don't SIGSEGV the printer #34884 - Also seats StackCheck in Formatter::new() to fix the uncaught exception printer stack overflow (bug Fix calling #private() functions in classes #2 in this PR)
  2. test_runner: add StackCheck to pretty_format to stop SIGSEGV on deeply nested diff/snapshot values #34885 - Also adds StackCheck to pretty_format to fix SIGSEGV on deeply nested diff/snapshot values (bug Copy source lines when generating error messages #3 in this PR)
  3. ipc: clear pending exception when advanced-serialization deserialize fails #34883 - Also clears pending exception when IPC advanced-serialization deserialize fails (bug Support import assertions #4 in this PR)
  4. yaml: own the property-name anchor string so it survives GC during stringify #34887 - Also owns the property-name anchor string in YAML stringify to fix use-after-free (bug Add a way for frameworks to define env vars #5 in this PR)
  5. console: guard JSX/Proxy recursion in Bun.inspect (stack overflow SIGSEGV) #29709 - Guards JSX/Proxy recursion in Bun.inspect, explicitly superseded by this PR (bug Fix ?? operator  #1 in this PR)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap with concurrently-opened PRs (all from the same audit run):

Bug Also fixed by
Formatter::new() seats StackCheck #34884
pretty_format gains a StackCheck #34885
IPC advanced-mode clears pending TypeError #34883
YAML anchor-name OwnedString #34887
Tag::JSX in can_have_circular_references #29709

Unique to this PR and not in any of the above: moving the stack_check.is_safe_to_recurse() check in print_as_prelude to run before the can_have_circular_references() gate. Without that, deep-but-acyclic JSX trees and Proxy chains still bypass the bound even after #34884 seats the StackCheck, because Tag::Proxy (and Promise, the iterator tags, etc.) never enter the guarded block. The inspect.test.js cases for "deep JSX tree" and "deep Proxy chain" exercise exactly that.

Happy to reduce this to just the print_as_prelude reordering and rebase on the others once they land, or leave it as the combined PR; whichever is easier to review.

Comment thread test/js/bun/test/pretty-format-overflow.test.ts Outdated
Comment thread test/js/bun/yaml/yaml.test.ts Outdated
Comment thread test/js/bun/util/inspect.test.js
Comment thread test/js/bun/util/inspect.test.js Outdated
print_array does not check max_depth, so on release lanes the error
printer writes O(N^2) bytes (N bounded only by the stack) before the
stack check fires. On Windows aarch64 release that is ~2.5GB, which
proc.stderr.text() cannot materialise. Discard stderr and assert on
signalCode/exitCode; a regression would be signalCode SIGSEGV (POSIX)
or a non-1 NTSTATUS exitCode (Windows) rather than exit 1.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — 520f3b5 addresses the last stderr-volume nit; all prior feedback is now resolved and I found nothing new this pass. Deferring to a human because this bundles five crash-class fixes across four subsystems (formatter stack-check seating/reordering, pretty_format StackCheck, IPC pending-exception clear, YAML anchor-name UAF) and there's an open coordination question against #34883/#34884/#34885/#34887/#29709 that a maintainer should decide.

What was reviewed:

  • print_as_prelude reorder: stack check now runs for every tag; !can_circ early-return still preserved after the check, so non-recursive tags skip the visited-map work as before.
  • Formatter::new() seating StackCheck::init(): checked that format2/Bun.inspect reseats are still present (harmless), and that init() is a cheap SP capture so per-console.log cost is negligible.
  • IPC advanced-mode: the clear_exception() + InvalidFormat mapping matches the existing JSON-mode path at ipc.rs:522, and on_data2 already handles InvalidFormat by closing the socket.
  • YAML OwnedString: dupe_ref() takes a WTF ref and OwnedString releases on Drop, so the anchor map's lifetime now owns the name; append_string(prop_name.get()) and the can_use_prop_name_as_anchor(&BunString) deref both still see the right bytes.
Extended reasoning...

Overview

Five targeted hardening changes across src/jsc/ConsoleObject.rs, src/jsc/VirtualMachine.rs (comment-only), src/jsc/ipc.rs, src/runtime/api/YAMLObject.rs, and src/runtime/test_runner/pretty_format.rs, plus four test files. Each source change is a few lines: (1) seat a live StackCheck in Formatter::new(); (2) move the stack-check in print_as_prelude above the can_have_circular_references gate and add Tag::JSX to that set (both files); (3) add a StackCheck field/guard to the jest diff formatter; (4) clear the pending JSC exception when advanced-mode IPC deserialize throws and map to InvalidFormat; (5) own the YAML anchor property-name string via OwnedString::new(prop_name.dupe_ref()).

Security risks

None introduced. All five are defensive: they remove a DoS surface (native stack overflow reachable from console.log/throw/expect().toEqual() on adversarial input), close a peer-controlled dangling-exception path in IPC, and fix a GC-triggerable UAF. No new parsing, no auth/crypto, no user-facing API surface.

Level of scrutiny

High — REVIEW.md flags native memory safety as the most-blocked category, and this touches recursion bounds, exception-scope discipline, and WTF string lifetime in one PR. The individual diffs are small and each has a fail-before/pass-after test, but the combination spans four subsystems and the author has explicitly left open whether to land this combined or split across the four sibling PRs (#34883#34887). That coordination decision belongs to a maintainer.

Other factors

  • All three prior review rounds (undrained stdout, exact-empty-stderr asserts, O(N²) piped output for deep-JSX and then for the throw/reject siblings) were addressed promptly in 63e92e6 / 968388a / 520f3b5; the current diff reflects every suggestion.
  • The bug-hunting system found nothing this run.
  • The StackCheck::init()-in-new() change alters behaviour for every Formatter::new() caller; I checked that the callers named in the PR description (error printer, Bun__inspect, expect matchers) previously relied on the always-true default and now get a real bound, and that the paths that already reseated (format2) are unaffected.
  • Tests follow harness conventions after the fix-ups (pipes drained or ignored, no exact-empty stderr, ASAN gate on the UAF repro with Malloc=1, signalCode-based crash assertion for the stderr-heavy cases).
  • CI was still building at the time of the last timeline entry; the IPC test is noted as platform-deferred.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant