Skip to content

YAML, JSON5, XML stringify: throw instead of aborting when the output passes the string length limit - #42312

Open
robobun wants to merge 3 commits into
mainfrom
robobun/021f6577/yaml-stringify-overflow-abort
Open

robobun wants to merge 3 commits into
mainfrom
robobun/021f6577/yaml-stringify-overflow-abort

Conversation

@robobun

@robobun robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.YAML.stringify, Bun.JSON5.stringify and Bun.XML.stringify abort the process when the output does not fit in a string: panic(main thread): abort() called, exit code 134. An assertions build prints ASSERTION FAILED: !hasOverflowed() in WTF::StringBuilder::length() (wtf/text/StringBuilder.h(288)). JSON.stringify throws RangeError: Out of memory.
  • length() release-asserts on an overflowed builder, and two functions in src/jsc/bindings/StringBuilderBinding.cpp reach it. StringBuilder__ensureUnusedCapacity (:78) reads it. Block-style YAML calls that before each indentation run. StringBuilder__appendString (:43) reaches it when a 16-bit key or name goes into an 8-bit builder.

Fix

  • Both functions return early for an overflowed builder. It drops every append, and StringBuilder__toString already throws the error.
  • Not in this PR: the text sink of a direct ReadableStream (BunStandaloneTextSink.h:35) aborts the same way after a write() error that the caller caught. See Notes.
  • Verified: test/js/bun/util/stringify-string-limit.test.ts (new, seven tests). With src/ at main the six ASAN tests abort with the assertion above. The release test fails on bun 1.4.3-canary. Also ran the YAML, TOML, JSON5 and XML suites.
  • Self-reviewed: 8 concerns raised, 6 addressed. Rejected: deleting YAML's four capacity hints. It is an unmeasured performance change in code Implement the replacer argument of YAML, TOML and JSON5 stringify #39925 rewrites.

Background

  • wtf::StringBuilder (src/jsc/StringBuilder.rs) is the Rust handle for a C++ WTF::StringBuilder. The YAML, JSON5, TOML and XML stringifiers write into it.
  • It uses OverflowPolicy::RecordOverflow: an append past 2^31 - 1 characters, or a failed allocation, makes hasOverflowed() true. The failed reallocation has already freed the buffer.
  • A builder is 8-bit until it gets a character above U+00FF. Then it copies itself into a 16-bit buffer. That copy reads capacity(), which is length() once the buffer is gone.
Notes

Reproduction on bun 1.4.3-canary (5f55496). Each exits with code 134.

// Block-style YAML, the reservation. About 3 GB and 7 s.
let o = new Array(108000).fill(1);
for (let i = 0; i < 2000; i++) o = { a: o };
Bun.YAML.stringify(o, null, 10);

// The 16-bit append. No space argument, so no reservation is involved. About 2.2 GB.
Bun.YAML.stringify({ a: "x".repeat(2 ** 31 - 1), "日本": 1 });

Where the second abort comes from. BunString::appendToBuilder passes a StringImpl* to the variadic StringBuilder::append. appendFromAdapters does not check hasOverflowed(). For a 16-bit string and an 8-bit builder it calls extendBufferForAppendingWithUpconvert, which computes expandedCapacity(capacity(), requiredLength). capacity() is m_buffer ? m_buffer->length() : length(), and a failed tryReallocate has already released m_buffer. The append(std::span<...>) overloads, append(char16_t), append(Latin1Character), the number adapters (8-bit, so they take the checked extendBufferForAppending path), appendQuotedJSONString and reserveCapacity all check first. Bun.TOML.stringify writes non-ASCII keys and values one character at a time, so it did not abort.

The tests.

  • A run that appends 2^31 characters is too slow for a debug build. The quoting check reads each character of a string value at about 80 ns there (about 3 minutes for 2 GiB), and the deep-indentation value above needs about 2e8 append calls at about 10 µs each.
  • The six ASAN tests record the overflow through a failed allocation. max_allocation_size_mb=4 with Malloc=1 makes the builder's next doubling fail, which calls the same didOverflow() and leaves the same state. text-encoder-stream.test.ts, buffer-oom.test.ts and zstd.test.ts use the same ASAN options. bun boots with a cap as low as 1 MiB. Each child takes about 1 s on a debug build, and the six run concurrently.
  • One row covers the reservation. Five rows put a 16-bit string after the overflow: a key and an indent in block-style YAML, a key in flow-style YAML, a key in JSON5, an element name in XML. With only the ensureUnusedCapacity check, those five still abort.
  • The release test passes the real limit with a string of 2^31 - 1 characters, then a 16-bit key, so it needs both checks. It needs 2 GiB, so it skips below 10 GB of RAM, and it skips debug and ASAN builds. The same fixture prints threw RangeError Out of memory on the fixed debug build when run by hand (220 s).
  • The tests have their own file because the builder is shared by four APIs, like streams-string-limit.test.ts and source-too-large.test.ts. The file takes about 4 s on a debug build.
  • Two existing tests go past the 5 s default timeout on my loaded debug build: XML.stringify > deep values are a catchable error (7.7 s) and stack overflow protection in the write pass in yaml.test.ts (4.4 to 5.8 s). The XML one does the same with src/ at main. Both build a 1,000,000-deep value and neither reaches an overflowed builder. Everything else in the four suites passes.

Self-review, by concern.

  • The first version of this change only fixed ensureUnusedCapacity. The review found that a 16-bit key or indent after the overflow still aborted, in YAML with and without a space argument, in JSON5 and in XML. That is the appendString check and the five rows (2 concerns).
  • The first version also made block-style YAML stop at the first value after an overflow, through a new has_overflowed() accessor. It had no test of its own and it touched the same lines as Implement the replacer argument of YAML, TOML and JSON5 stringify #39925. It is gone. The diff is now the two checks and nothing on the Rust side (2 concerns).
  • The four ensure_unused_capacity calls in YAMLObject.rs stay as they are. WTF::StringBuilder::reserveCapacity reallocates to the exact size, so they probably do not help, and JSON5 and XML have the same newline() without them. Removing them is a performance change with no measurement behind it, and Implement the replacer argument of YAML, TOML and JSON5 stringify #39925 rewrites those functions and keeps the calls, so it is not part of this crash fix (rejected). The first version also refused a reservation above String::MaxLength. That is gone too: such a request makes the builder record an overflow, and the result is the same catchable error (2 concerns).
  • The stream text sink, below (2 concerns).

The stream text sink (not fixed here). BunTextAccumulator::rope is the only other RecordOverflow builder in src/. writeToTextSink (JSDirectStreamController.cpp:405) throws RangeError: Out of memory when an append overflows it, but leaves it overflowed. If the caller catches that and writes a typed array, line 431 calls rope.toString(), which asserts in reifyString(). BunStreamConsumers.cpp:679, :709 and :744 read rope.length() the same way. Under Malloc=1 and max_allocation_size_mb=4 on a debug build:

const s = "x".repeat(1024 * 1024);
const rs = new ReadableStream({
  type: "direct",
  pull(c) {
    c.write(s);
    c.write(s);
    try { c.write(s); } catch {}      // RangeError: Out of memory
    c.write(new Uint8Array(1));       // ASSERTION FAILED: !hasOverflowed()  StringBuilder.cpp(52) reifyString()
    c.end();
  },
});
await Bun.readableStreamToText(rs);

The fix has to decide what a write after a failed write does (throw again, or error the stream), in two write arms and two end arms. It belongs with the stream code and its tests.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 6 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/util/stringify-string-limit.test.ts
bun test v1.4.3 (5f554969b)

test/js/bun/util/stringify-string-limit.test.ts:
56 |       stderr: "pipe",
57 |     });
58 | 
59 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
60 | 
61 |     expect(stdout, `the child exited with ${exitCode}\nstderr:\n${stderr}`).toBe(threw);
                                                                                 ^
error: the child exited with 134
stderr:
==255766==WARNING: AddressSanitizer failed to allocate 0x600018 bytes
ASSERTION FAILED: !hasOverflowed()
/root/.bun/build-cache/webkit-dfd696443b9ba87c-debug-asan/include/wtf/text/StringBuilder.h(288) : unsigned int WTF::StringBuilder::length() const
no stacktrace available

- "threw RangeError Out of memory
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/util/stringify-string-limit.test.ts:61:77)
(fail) stringify throws when the string builder cannot grow > block-style YAML reserves capac
... (truncated)

release without fix: 1 failed, 6 skipped
bun test v1.4.3-canary.1 (5f554969b)

test/js/bun/util/stringify-string-limit.test.ts:
(skip) stringify throws when the string builder cannot grow > block-style YAML reserves capacity
(skip) stringify throws when the string builder cannot grow > block-style YAML appends a UTF-16 key
(skip) stringify throws when the string builder cannot grow > block-style YAML appends a UTF-16 indent
(skip) stringify throws when the string builder cannot grow > flow-style YAML appends a UTF-16 key
(skip) stringify throws when the string builder cannot grow > JSON5 appends a UTF-16 key
(skip) stringify throws when the string builder cannot grow > XML appends a UTF-16 element name
77 |       stderr: "pipe",
78 |     });
79 | 
80 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
81 | 
82 |     expect({ stdout, stderr, exitCode }).toEqual({ stdout: threw, stderr: "", exitCode: 0 });
                                              ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "stderr": "",
-   "stdout": 
- "threw RangeError Out of memory
+   "exitCode": 134,
+   "stderr": 
+ "========================
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/util/stringify-string-limit.test.ts
bun test v1.4.3 (5f554969b)

test/js/bun/util/stringify-string-limit.test.ts:
(pass) stringify throws when the string builder cannot grow > block-style YAML appends a UTF-16 indent [719.70ms]
(pass) stringify throws when the string builder cannot grow > flow-style YAML appends a UTF-16 key [979.51ms]
(pass) stringify throws when the string builder cannot grow > block-style YAML appends a UTF-16 key [1020.19ms]
(pass) stringify throws when the string builder cannot grow > block-style YAML reserves capacity [1155.71ms]
(pass) stringify throws when the string builder cannot grow > JSON5 appends a UTF-16 key [1575.14ms]
(pass) stringify throws when the string builder cannot grow > XML appends a UTF-16 element name [1096.21ms]
(skip) stringify throws when the output passes the maximum string length

 6 pass
 1 skip
 0 fail
 12 expect() calls
Ran 7 tests across 1 file. [4.86s]
__F:0:S:1

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 931ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen cpp.rs (cppbind)
[1/7] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�
... (truncated)
diff hotspot
src/jsc/bindings/StringBuilderBinding.cpp       |  6 ++
 test/js/bun/util/stringify-string-limit.test.ts | 85 +++++++++++++++++++++++++
 2 files changed, 91 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/jsc/bindings/StringBuilderBinding.cpp            3      7     15
test/js/bun/util/stringify-string-limit.test.ts      2      5     12

…gify output does not fit in a string

The Rust `wtf::StringBuilder` records an overflow instead of crashing, and
`StringBuilder__toString` throws an out-of-memory error for it. Two of the
functions between those asserted on an overflowed builder and aborted the
process:

- `StringBuilder__ensureUnusedCapacity` read `length()`, which release-asserts
  `!hasOverflowed()`. Block-style `Bun.YAML.stringify` reserves capacity
  before every indentation run.
- `StringBuilder__appendString` with a 16-bit string makes an 8-bit builder
  upconvert. That path reads `capacity()`, which calls `length()` when the
  buffer is gone. Every stringifier on this builder appends keys, names and
  indentation this way.

Both now return early for an overflowed builder.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 9 days. After that, they cost $0.25 per reviewed file.

Or wait 23 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 144c6841-9576-4895-a20c-8f63440e36fc

📥 Commits

Reviewing files that changed from the base of the PR and between 6b394bf and 25bb379.

📒 Files selected for processing (2)
  • src/jsc/bindings/StringBuilderBinding.cpp
  • test/js/bun/util/stringify-string-limit.test.ts

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

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary (5f55496), release build. Each of these exits with code 134 (panic(main thread): abort() called):

let o = new Array(108000).fill(1);
for (let i = 0; i < 2000; i++) o = { a: o };
Bun.YAML.stringify(o, null, 10);

Bun.YAML.stringify({ a: "x".repeat(2 ** 31 - 1), "日本": 1 });
Bun.JSON5.stringify({ a: "x".repeat(2 ** 31 - 1), "日本": 1 });

On a debug ASAN build with src/ at main, bun bd test test/js/bun/util/stringify-string-limit.test.ts fails six of six: each child stops with ASSERTION FAILED: !hasOverflowed() in WTF::StringBuilder::length(). With this branch the six tests pass.

Fix: this PR, #42312.

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/util/stringify-string-limit.test.ts Outdated
Comment thread test/js/bun/util/stringify-string-limit.test.ts Outdated
Comment thread src/jsc/bindings/StringBuilderBinding.cpp Outdated
@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:56 AM PT - Sep 11th, 2026

✅ @robobun, your commit 25bb379ad8c14095fddf94b7d09446287c261220 passed in Build #114247! 🎉


🧪   To try this PR locally:

bunx bun-pr 42312

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

bun-42312 --bun

@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.

LGTM — both earlier nits are addressed (stderr is now drained in the Promise.all for both subprocess tests, and the ASAN fixture builds its filler with Buffer.alloc(...).toString("latin1")).

What was reviewed:

  • The two hasOverflowed() guards mirror the existing check in StringBuilder__toString (same file, line 72) and protect exactly the accessors the PR description names; the other append* wrappers route through overloads that already check overflow, per the description's audit.
  • Test file: test.each under describe.concurrent, bunEnv spread, pipes drained concurrently, stdout asserted before exit code, positive contract (threw RangeError Out of memory + exit 0) rather than absence-of-crash — conventions match test/CLAUDE.md.
  • The release-only test's "x".repeat(2**31-1) is gated behind skipIf(isDebug), so the debug-JSC .repeat slowdown doesn't apply; the 120 s per-test timeout is the "rare outlier" case CLAUDE.md allows for a 2 GiB allocation.
Extended reasoning...

Overview

The PR adds two three-line hasOverflowed() early-return guards to src/jsc/bindings/StringBuilderBinding.cpp — in StringBuilder__appendString (before the upconvert path reads capacity()) and StringBuilder__ensureUnusedCapacity (before it reads length()). Both accessors release-assert !hasOverflowed() in WTF, so an overflowed builder previously aborted the process; now the wrappers become no-ops and StringBuilder__toString (which already checks overflow at line 72 of the same file) surfaces the catchable RangeError: Out of memory. A new test file spawns child processes that force overflow via ASAN's max_allocation_size_mb cap (six concurrent cases covering both guarded calls across YAML/JSON5/XML) plus one release-only case that overflows by real length.

Security risks

None. This converts a user-reachable abort() into a catchable error, which is strictly a robustness improvement. No parsing of untrusted input, no auth/crypto/permissions surface, no new allocation or pointer handling — the guards only read a boolean and return early.

Level of scrutiny

Low-to-moderate. The native change is six lines following an established pattern already present a few lines up in the same file, with [[unlikely]] hints matching the existing guard. The PR description's audit of which StringBuilder entry points already check overflow (span/char/number/appendQuotedJSONString/reserveCapacity) versus which don't (the variadic adapter path via StringImpl*) lines up with the two functions guarded. CODEOWNERS covers only *.d.ts, so neither changed path is owned.

Other factors

Since the prior review, commits 04758eb and 25bb379 addressed both optional inline comments: stderr is now included in Promise.all for both subprocess spawns, and the ASAN fixture uses Buffer.alloc(size, "x").toString("latin1") instead of .repeat(). The "latin1" encoding is a nice touch — it keeps the builder 8-bit so the UTF-16 key still triggers upconversion. The github-actions bot thread on StringBuilderBinding.cpp:10 was replied to and resolved after the final commit; the file as it stands is clean. No outstanding CHANGES_REQUESTED reviews. The test file justifies its own existence (shared builder across four APIs, precedent cited), runs concurrently, and asserts the positive contract rather than absence of a crash.

This branch has not been deployed

No deployments
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