Skip to content

Bun.wrapAnsi: throw a RangeError instead of aborting when a row or the row list cannot grow - #42833

Merged
Jarred-Sumner merged 5 commits into
mainfrom
robobun/a2075aec/wrapansi-row-vector-limit
Sep 16, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
robobun/a2075aec/wrapansi-row-vector-limit

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.wrapAnsi("a ".repeat(2 ** 26), 1) aborts: panic(main thread): abort() called, exit 134. Top frames: WTF::VectorBufferBase::allocateBuffer<FailureAction::Crash> (Vector.h:228) <- Vector::appendSlowCase<Bun::Row<unsigned char>>. The output (2^27 characters) is below the string length limit.
  • processLine keeps one 32-byte Row for each wrapped row in a WTF::Vector (src/jsc/bindings/wrapAnsi.cpp:676). Vector::append calls CRASH() when it cannot grow: at the 51,821,029th row of one input line.
  • Two more Vectors in the file abort the same way: the text of one row, and the tail copy in trimRowTrailingSpaces.

Fix

  • Every Vector append in the file is fallible: tryAppend behind Bun::maxVectorSize<T>(), as streams: throw instead of aborting when a script-sized container cannot grow #42649 does. A failure returns false up to the binding, which throws RangeError: Out of memory.
  • trimRowTrailingSpaces trims in place, as trimLeadingSpaces does. It allocates nothing.
  • Outputs that fit do not change: 1.9 million seeded random inputs match.
  • Verified: test/js/bun/util/wrapAnsi.test.ts. Its 2 new tests hold 15 inputs that throw (10 of the 11 statements that grow a Vector) and 5 that return exactly at a bound. Both fail without the fix. Also the wrapAnsi.npm, sliceAnsi, stringWidth, stripANSI suites.

Background

Notes

Repros, release builds on Linux x64: Bun 1.4.2 and canary 782c402 against this branch

// 1. The row list. Before: exit 134 after 4.2 s, 3.2 GB peak. After: RangeError in 4.3 s, 3.0 GB.
Bun.wrapAnsi("a ".repeat(2 ** 26), 1);
// 51,821,028 words return 103,642,055 characters before and after. One more word is the abort.

// 2. One UTF-16 row. Before: exit 134, 5.4 GB. After: RangeError, 5.1 GB.
Bun.wrapAnsi("\u2603".repeat(9e8) + " b", 2 ** 31);

// 3. One Latin-1 row. Before: exit 134, 5.4 GB. After: RangeError, 5.1 GB.
Bun.wrapAnsi("a".repeat(1.8e9) + " b", 2 ** 31);

// 4. The tail copy of the trailing trim. Before: exit 134, 9.7 GB. After: returns 900,000,001 characters, 6.7 GB.
Bun.wrapAnsi("a " + "\u200b".repeat(9e8), 80);

Where each number comes from

  • sizeof(Row<Char>) is 32, so the largest legal capacity of the row list is 67,108,863. FastMalloc::nextCapacity grows a full Vector from 51,821,028 to 77,731,542, which passes it. tryAppend refuses at the same step, so the function now throws at the 51,821,029th row. maxVectorSize binds only when a test lowers the limit. streams: throw instead of aborting when a script-sized container cannot grow #42649 has the same property.
  • Repros 2 and 3: the first word fills the row with one exact-size allocation. The separator space of the next word asks for 1.5 times that, which passes INT32_MAX bytes.
  • Repro 4: the row itself fits. trimRowTrailingSpaces copied the zero-width tail into a second Vector one character at a time, and that Vector failed to grow past 885,410,839 characters.

A failed call

Not changed here

The tests

  • Each child runs with BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT=65536, the knob the streams: throw instead of aborting when a script-sized container cannot grow #42649 tests use. The bounds become 2048 rows, and 65,536 Latin-1 or 32,768 UTF-16 characters in a row.
  • Five inputs sit exactly at a bound and return: 2048 rows (Latin-1 and UTF-16), 65,536 Latin-1 characters, 32,768 UTF-16 characters, and a separator space as character 65,536. The same input one element longer throws.
  • Without the fix every input returns its normal length, so the failure is an assertion diff and not an abort.
  • A debugger breakpoint on every return false confirmed which statement fails in each case. Every one is covered except the first row of a line. That one fails only when the limit is below the 32 bytes of one Row.

No change for outputs that fit

  • A seeded generator builds inputs from escapes (SGR, OSC 8 hyperlinks, C1, unterminated and malformed sequences), tabs, line breaks, wide, zero-width, combining and surrogate characters, with columns 1 to 10 and every option set. A hash of 1.9 million outputs is equal on the canary (09bb546 and 782c402) and on the debug ASAN build of this branch.
  • Wrapping 20 MB of text, best of 3, release builds: 225 ms before and 205 ms after at 80 columns, 302 ms and 258 ms for UTF-16 text, 189 ms and 194 ms at 1 column with hard.

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

fails on main (without fix)
ASAN without fix: 2 FAILED
$ 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/wrapAnsi.test.ts
bun test v1.4.3 (09bb54630)

test/js/bun/util/wrapAnsi.test.ts:
(pass) Bun.wrapAnsi > basic wrapping > wraps text at word boundaries [3.23ms]
(pass) Bun.wrapAnsi > basic wrapping > handles empty string [2.39ms]
(pass) Bun.wrapAnsi > basic wrapping > no wrapping needed [1.87ms]
(pass) Bun.wrapAnsi > basic wrapping > wraps multiple words [2.51ms]
(pass) Bun.wrapAnsi > basic wrapping > handles single long word [2.45ms]
(pass) Bun.wrapAnsi > basic wrapping > handles columns = 0 [1.82ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks long words in middle [2.98ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks very long word [3.53ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap false disables wrapping [2.53ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: undefined keeps word wrapping on [2.86ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: null keeps word wrapping on [0.62ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: 0 keeps word wrapping on [0.45ms]
(pass) Bun.wrapAnsi > wordWrap opti
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (7606b00fb)

test/js/bun/util/wrapAnsi.test.ts:
(pass) Bun.wrapAnsi > basic wrapping > wraps text at word boundaries [0.05ms]
(pass) Bun.wrapAnsi > basic wrapping > handles empty string [0.02ms]
(pass) Bun.wrapAnsi > basic wrapping > no wrapping needed [0.01ms]
(pass) Bun.wrapAnsi > basic wrapping > wraps multiple words [0.01ms]
(pass) Bun.wrapAnsi > basic wrapping > handles single long word [0.01ms]
(pass) Bun.wrapAnsi > basic wrapping > handles columns = 0 [0.01ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks long words in middle [0.02ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks very long word [0.04ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap false disables wrapping [0.04ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: undefined keeps word wrapping on [0.02ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: null keeps word wrapping on [0.01ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: 0 keeps word wrapping on
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: "" keeps word wrapping on
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: false breaks words character-by-character [0.02ms]
(pass) Bun.wrapAnsi > tr
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ 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/wrapAnsi.test.ts
bun test v1.4.3 (09bb54630)

test/js/bun/util/wrapAnsi.test.ts:
(pass) Bun.wrapAnsi > basic wrapping > wraps text at word boundaries [2.58ms]
(pass) Bun.wrapAnsi > basic wrapping > handles empty string [1.66ms]
(pass) Bun.wrapAnsi > basic wrapping > no wrapping needed [1.31ms]
(pass) Bun.wrapAnsi > basic wrapping > wraps multiple words [1.64ms]
(pass) Bun.wrapAnsi > basic wrapping > handles single long word [1.59ms]
(pass) Bun.wrapAnsi > basic wrapping > handles columns = 0 [1.24ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks long words in middle [2.03ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks very long word [1.91ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap false disables wrapping [1.66ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: undefined keeps word wrapping on [1.79ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: null keeps word wrapping on [0.47ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap: 0 keeps word wrapping on [0.30ms]
(pass) Bun.wrapAnsi > wordWrap opti
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 907ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/133] gen ErrorCode+*.h
[2/133] esbuild bun-error

  ../../build/release/codegen/bun-error/index.js       34.9kb
  ../../build/release/codegen/bun-error/bun-error.css  12.8kb

⚡ Done in 23ms
[3/133] gen compressed/codegen/bun-error/bun-error.css.zst
[4/133] gen compressed/codegen/bun-error/index.js.zst
[5/133] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[6/133] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[7/133] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 
... (truncated)
diff hotspot
src/jsc/bindings/wrapAnsi.cpp     | 115 +++++++++++++++++++++++++-------------
 test/js/bun/util/wrapAnsi.test.ts | 106 +++++++++++++++++++++++++++++++++++
 2 files changed, 181 insertions(+), 40 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                               reads  edits  tests
src/jsc/bindings/wrapAnsi.cpp          4     10     15
test/js/bun/util/wrapAnsi.test.ts      7      4     15

… grow

Bun.wrapAnsi() keeps one Row per wrapped line in a WTF::Vector and each
Row's text in another. Vector::append() calls CRASH() when the buffer
cannot grow past INT32_MAX bytes, and the input sizes both: 128 MB of
one-letter words at columns 1 is 2^26 rows of 32 bytes, and one UTF-16
row of 900 million characters passes the bound too.

Every append now goes through tryAppend() and Bun::maxVectorSize<T>(),
and a failure travels up to the binding, which throws RangeError: Out of
memory. trimRowTrailingSpaces() compacts the row in place, so it no
longer builds a second Vector for the tail.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

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

Or wait 7 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: 2475d90a-034e-4095-b8b4-2efe710c8868

📥 Commits

Reviewing files that changed from the base of the PR and between b841a68 and efdb09f.

📒 Files selected for processing (2)
  • src/jsc/bindings/wrapAnsi.cpp
  • test/js/bun/util/wrapAnsi.test.ts

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:35 PM PT - Sep 15th, 2026

❌ @robobun, your commit efdb09f has 1 failures in Build #116194 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42833

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

bun-42833 --bun

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: merged as a105560.

How I reproduced it (Linux x64, release builds of Bun 1.4.2 and canary 782c402):

bun -e 'Bun.wrapAnsi("a ".repeat(2 ** 26), 1)'

It exits 134 with panic(main thread): abort() called after about 4 s at 3.2 GB. With this change the same call throws RangeError: Out of memory.

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

Beyond the inline findings, I also traced the in-place rewrite of trimRowTrailingSpaces (src/jsc/bindings/wrapAnsi.cpp:307-323): write starts at lastVisibleEnd alongside it and advances by at most as much per iteration, so it never passes it, memmove covers the overlapping escape copy, and the final shrink(write - data) matches the old shrink + appendVector length. The new OOM throw in jsFunctionBunWrapAnsi runs under the existing DECLARE_THROW_SCOPE at :730, so it is not a missing-scope issue.

Extended reasoning...

Findings were reported inline, so this note only records what else was examined. I read the full C++ diff: every Vector::append site in the file now goes through Row::append/appendRow and propagates false up through wrapWord, the placeWord lambda, processLine, and wrapAnsiImpl to the host function. The in-place trimRowTrailingSpaces compaction was checked for overlap and final-length correctness (write cursor never overtakes the read cursor; memmove used for multi-char escape moves; self-assignment on the no-gap path is benign). The throw path uses the throw scope already declared in the host function and returns {}. Nothing here contradicts the inline findings; the residual StringBuilder abort and the boundary/comment nits stand as posted.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/jsc/bindings/wrapAnsi.cpp — Callers wrapping a UTF-16 input of roughly 976 million characters or more still get a process abort with exit 134 after this merges, although the title promises a catchable RangeError for growth failures. The row Vectors now fail soft, but joinRowsWithAnsiPreservation at src/jsc/bindings/wrapAnsi.cpp:711 and result.toString() at :720 append into a StringBuilder that CRASH()es on overflow and is never checked. Fix: check result.hasOverflowed() after each join and return nullopt from wrapAnsiImpl, so every growth failure in the function reaches the throw at :792, or record the exclusion with a todo test.

    Extended reasoning...

    wrapAnsiImpl at :681-682 reserves input.size()*1.1 in an 8-bit StringBuilder. The first 16-bit append inside joinRowsWithAnsiPreservation (:711) reallocates to a 16-bit buffer, doubling bytes, and StringBuilder's non-overflow-recording mode calls CRASH() when the capacity passes the string limit. The PR description names this case explicitly and defers it to #42225. On the base the same input aborts at the same site, so the dismissal is factually right that the diff does not widen it. Why it still ranks: the merged behaviour is a partial contract. The host function at :791-794 throws only for nullopt, and nullopt is produced only by processLine. A user reading the release note tries Bun.wrapAnsi(bigUtf16, 2**31) in try/catch and still loses the process. Population: anyone wrapping very large non-Latin-1 text (CJK logs) in a long-running server. Rate: once per such call. Remedy: switch the builder to OverflowHandler::RecordOverflow, check hasOverflowed() after :711 and before :720, and return std::nullopt.

    Verification: pre-existing. Trigger: a UTF-16 input large enough that the output StringBuilder must grow past the string length limit (PR says ~976M UTF-16 chars). Mechanism verified: /home/claude/bun/src/jsc/bindings/wrapAnsi.cpp:681-682 builds a default StringBuilder result; result.reserveCapacity(input.size() + input.size() / 10); (CrashOnOverflow mode, no hasOverflowed() check anywhere in the file —…

Comment thread test/js/bun/util/wrapAnsi.test.ts
Comment thread test/js/bun/util/wrapAnsi.test.ts Outdated
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the review above.

  • The two test nits are done in efdb09f. Each of the three bound checks has a pair of inputs at its exact bound, and the comment above the describe block is one line.
  • The StringBuilder abort for a UTF-16 input of about 976 million characters or more is real. It is the same before and after this PR: joinRowsWithAnsiPreservation -> StringBuilder::extendBufferForAppendingWithUpconvert -> didOverflow. I left it out on purpose. Throw ERR_STRING_TOO_LONG from Bun.wrapAnsi instead of aborting when the output passes the string length limit #42225 already changes both builders in wrapAnsiImpl to OverflowPolicy::RecordOverflow and adds the hasOverflowed() check, so the same edit here would conflict with it line for line.
  • That check alone is not the full fix for this input. wrapAnsiImpl reserves 1.1 times the input length while the builder is still 8-bit, and the first 16-bit append doubles the reserve past the string limit. With RecordOverflow the call would throw ERR_STRING_TOO_LONG for an output that fits. The reserve needs a change too, so this gets its own follow-up on top of Throw ERR_STRING_TOO_LONG from Bun.wrapAnsi instead of aborting when the output passes the string length limit #42225.
  • I did not add a test.todo for it. The repro needs a 2 GB input string and 6.4 GB of memory, so CI could not run it. The title of this PR names the rows and the row list only, and the Notes in the description name the case that stays.

@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 — the latest push adds the at-bound cases and trims the comments, and the fallible-append threading is consistent across every Row/row-list growth site.

What was reviewed:

  • Every .append/appendVector on Row::m_data and rows in src/jsc/bindings/wrapAnsi.cpp now goes through [[nodiscard]] bool paths and each call site checks the result; the only raw appends left are on the output StringBuilder, which the PR explicitly leaves to #42225.
  • trimRowTrailingSpaces in-place compaction: write starts equal to it and advances by at most what it advances, so consumeANSI only reads bytes not yet overwritten; memmove handles the overlap.
  • throwOutOfMemoryError is under the existing ThrowScope and returns {}; the moved-out lastRow in the fold-back path is dropped by RAII on failure and lineRows is discarded before nullopt propagates.
  • Tests are hermetic (-e child, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT is read in src/jsc/VirtualMachine.rs), drain pipes concurrently, and pin the exact 2048-row / 65,536 / 32,768-char bounds on both sides.
Extended reasoning...

Overview

The PR touches one native file, src/jsc/bindings/wrapAnsi.cpp, and its test file. It converts every growth of a Row<Char>::m_data Vector or the per-line Vector<Row<Char>> from append (which calls CRASH() on capacity overflow) into tryAppend guarded by Bun::maxVectorSize<T>(), threads a bool return through wrapWord, processLine and the placeWord lambda, changes wrapAnsiImpl to return std::optional<WTF::String>, and throws a RangeError: Out of memory from the host function on nullopt. trimRowTrailingSpaces is rewritten to compact in place instead of copying the tail into a second Vector. Since my prior review, commit efdb09f added at-bound cases (2048 rows / 65,536 Latin-1 / 32,768 UTF-16 chars fit, one more throws) and cut the describe-block comment to the single load-bearing fact, which addresses both of my earlier nits.

Security risks

None specific to this change. The only user-controlled input is the string and columns; the change turns a process abort on very large inputs into a catchable RangeError, which is strictly an improvement for DoS resistance. The size arithmetic m_data.size() + chars.size() <= maxVectorSize<Char>() cannot overflow because both operands are bounded by UINT_MAX >> 1. The in-place compaction in trimRowTrailingSpaces never reads past end and never writes ahead of its read cursor.

Level of scrutiny

Moderate. It is native C++ with a refactor of a buffer-manipulating routine, so I read the compaction loop carefully rather than trusting the description: write begins at lastVisibleEnd == it, escape sequences advance both by seqLen, spaces advance only it, so write <= it holds throughout and consumeANSI(it, end) only inspects bytes not yet overwritten; memmove is the correct primitive for the overlapping case. The bool threading is mechanical and I confirmed every call site consumes the result ([[nodiscard]] would also fail the build otherwise). The lastRowWidthDirty = true reorder before return wrapWord(...) is behavior-preserving since wrapWord does not read that flag. Both branches of the host function (is8Bit and UTF-16) go through the same optional check, and the error is thrown under the existing ThrowScope.

Other factors

The bug hunt ran dry with no findings, no CODEOWNER covers these paths, and no third-party objections are outstanding. The tests follow harness conventions (bunEnv spread, test.concurrent, Promise.all over stdout/stderr/exited, combined object assertion, exact error string), the env knob they rely on is read in src/jsc/VirtualMachine.rs and already used by other suites, and the repeat() sizes are small enough not to be slow in debug builds. The remaining StringBuilder appends are explicitly scoped out to a separate PR, which is a reasonable boundary.

@Jarred-Sumner
Jarred-Sumner merged commit a105560 into main Sep 16, 2026
6 of 7 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/a2075aec/wrapansi-row-vector-limit branch September 16, 2026 04:21
Jarred-Sumner pushed a commit that referenced this pull request Sep 30, 2026
…tor cannot grow (#42929)

### Problem
- `Bun.sliceAnsi` and `Bun.stripANSI` each abort on a legal input:
`panic(main thread): abort() called`, exit 134. Fuzzing found both, with
no user report.
- `sliceAnsi` keeps one 24-byte `Vector` entry (`pending`,
`src/jsc/bindings/sliceAnsi.cpp:1257`) for each escape sequence that
waits for the next visible character. `Vector::append` calls `CRASH()`
at the 77,731,543rd: 78 MB of lone `\x9c` bytes.
- `stripANSI` writes into a `Vector<Char>` as long as the input
(`src/jsc/bindings/stripANSI.cpp:48`). A 16-bit string of 2^30 code
units passes `INT32_MAX` bytes, so `Vector::grow` calls `CRASH()`. Bun
1.3.12 returns it (Notes).

### Fix
- `sliceAnsi`: both lists grow with `tryAppend` behind
`Bun::maxVectorSize<T>()`. On failure the binding throws `RangeError:
Out of memory`, as #42833 and #42649 do.
- `stripANSI`: the buffer is a `String` from
`String::tryCreateUninitialized`, which holds every legal string, so the
2^30 input returns again. A failed allocation throws the same
`RangeError`.
- Outputs that fit do not change: 1.8 million seeded random calls match.
The `String` buffer adds no copy (Notes).
- Verified: `test/js/bun/util/sliceAnsi.test.ts` (1 new test),
`test/js/bun/util/stripANSI.test.ts` (2 new tests). All 3 fail without
the fix. The other ANSI suites pass (Notes).

### Background
- `WTF::Vector<T>` holds `INT32_MAX / sizeof(T)` elements and grows by
half. Past that, `append` and `grow` call `CRASH()` and `tryAppend`
returns false.
- A `WTF::String` holds 2^31 - 1 characters, 4 GiB when 16-bit.
- A sequence waits in `pending` until the next visible character shows
whether it is inside a grapheme cluster or past the range.
- `Bun::maxVectorSize<T>()` is the `Vector` bound, lowered by
`Bun__stringSyntheticAllocationLimit`. The tests set that to 64 KiB in a
child.

<details><summary>Notes</summary>

**Repros, release builds on Linux x64: `main` against this branch**

```js
// 1. Before: exit 134 at 4.7 GB peak. After: RangeError in 3.3 s, 4.6 GB.
Bun.sliceAnsi("a" + "\x9c".repeat(77731543) + "b", 0, 2);
// 77,731,542 sequences return the whole string before and after. "\x1b[m" gives the same counts (233 MB, 5.4 GB).

// 2. Before: exit 134 after 2.3 s, 4.3 GB peak. After: returns 2^30 characters in 5.7 s, 8.4 GB peak.
Bun.stripANSI("\x1b[31m" + "\u3042".repeat(2 ** 30));
```

- In repro 2 the input takes 4 GiB (the repeated string and the flat
copy of the rope). The buffer and the result take 2 GiB each.

**Where each number comes from**
- A `Pending` entry is 24 bytes, so the largest legal capacity is
89,478,485. `FastMalloc::nextCapacity` grows a full `Vector` from
77,731,542 to 116,597,313, which passes it. `tryAppend` refuses at the
same step. `maxVectorSize` binds only when a test lowers the limit.
#42833 has the same property.
- `pendingHl` has one 24-byte tuple for each waiting hyperlink. It is
never longer than `pending`, so the `pending` check covers its bound.
Its `tryAppend` covers a failed allocation.
- `SgrStyleState::entries` stays as it is. It holds one entry for each
attribute slot, and `parseSgrParams` clamps a parameter below 1,000,000.

**When stripANSI started to abort**
- #28767 (in 1.3.12) replaced the `StringBuilder` with
`Vector<Char>::grow(input.size())`. At that time
`isValidCapacityForVector` accepted `UINT_MAX / sizeof(T)` elements, so
the `Vector` held every legal string.
- The WebKit upgrade #29161 (in 1.3.13) halved that bound to `(UINT_MAX
>> 1) / sizeof(T)`. Bun 1.3.12 returns repro 2 and Bun 1.3.13 aborts.
- `sliceAnsi` has had `pending` since #26963 added the function. The old
bound only moved its abort to a higher count, so that half was never
correct. No issue exists for either, so the tests are in the module test
files.

**The String buffer adds no copy**
- `StringImpl::adopt(Vector&&)` moves the buffer only when the `Vector`
allocator is `StringImplMalloc` (`wtf/text/StringImpl.h`). That is
`FastCompactMalloc`, and a plain `Vector<Char>` uses `FastMalloc`, so it
takes the `create(vector.span())` branch and copies.
- The old path was `fastMalloc`, a `fastRealloc` in `shrinkToFit()` when
the output was under half of the input, then that copy. The new path is
`tryCreateUninitialized` for the buffer, then a copy into a second
`tryCreateUninitialized` string of the exact length. Both allocations
are fallible, so a failure of the second one also throws.
- I also measured `StringImpl::tryReallocate` in place of the copy. It
saves the copy for a large input with few sequences, and it keeps up to
a third of slack on a small result. The copy keeps the memory of each
result as it is today, so this PR uses the copy.

**Benchmark of stripANSI**: release builds of `main` (b841a68) and of
this change on it, LLVM 21, minimum of 6 interleaved runs. The runs are
from before a293e34, which replaced `String(span)` with the same
allocation and copy in a fallible form.

| input (characters in, out) | `main` | this PR |
|---|---|---|
| SGR, 8-bit (19, 9) | 90.5 ns | 73.7 ns |
| SGR, 16-bit (19, 9) | 87.2 ns | 69.4 ns |
| hyperlink (44, 9) | 87.6 ns | 71.6 ns |
| mixed SGR (69, 41) | 131.9 ns | 122.6 ns |
| one escape (1,005, 1,000) | 286.1 ns | 258.5 ns |
| one escape (64,005, 64,000) | 7.98 us | 7.72 us |
| dense SGR (5,600, 1,600) | 14.22 us | 14.30 us |
| dense SGR (212,992, 49,152) | 404.6 us | 408.9 us |
| dense SGR, 16-bit (212,992, 49,152) | 421.2 us | 408.2 us |
| no escape (16,384) | 175.5 ns | 176.1 ns |

- A `sliceAnsi` benchmark (dense SGR, hyperlinks, 100 waiting sequences
between characters) is within 2% of `main`.

**No change for outputs that fit**
- A seeded generator builds inputs from SGR, OSC 8, C1, unterminated and
malformed sequences, control bytes, wide, zero-width, combining and
surrogate characters. It calls `stripANSI` once and `sliceAnsi` three
times for each input, with random ranges and ellipses.
- A hash of 1.8 million results is equal on the two release builds. The
debug ASAN build of this branch gives the same hash for the first
600,000.

**The tests**
- Two tests run a child with
`BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT=65536`, the knob the #42649 and
#42833 tests use. The bounds become 2730 waiting sequences and a buffer
of 65,536 characters.
- Each bound has an input that sits exactly at it and returns, and the
same input one element longer, which throws: SGR sequences (Latin-1 and
UTF-16), lone C1 ST bytes, hyperlinks, and the `stripANSI` buffer
(Latin-1 and UTF-16). More cases: sequences at the end of the input,
runs under the bound with visible characters between them, sequences
before the range, and an input over the limit with nothing to strip.
- Without the fix every input returns its normal length, so the failure
is an assertion diff and not an abort.
- The third test runs `stripANSI` at the real size: a 16-bit string of
1,073,741,826 code units. Every code unit is inside an `ESC ( x`
sequence, so the output is empty and the child never writes to the
buffer. It needs 2.2 GB (2.4 GB and 6 s in a debug ASAN build), and it
skips under 8 GiB of memory, as `utf8-conversion-limit.test.ts` does.
Without the fix the child exits with SIGABRT.
- No test runs `sliceAnsi` at its real size (4.6 GB).
- Other suites run on the debug ASAN build: `sliceAnsi-fuzz`,
`wrapAnsi`, `wrapAnsi.npm`, `stringWidth`, and the rest of `sliceAnsi`
and `stripANSI`.

**Not changed here: `Bun.sliceAnsi` aborts that are not a `Vector`**

#42931 tracks them. All five exit 134 on a release build of this branch.
- The result fits, and a `StringBuilder` doubles its capacity past the
longest 16-bit string. oven-sh/WebKit#631 (open) fixes the builder.
- `Bun.sliceAnsi("\u3042".repeat(2 ** 30), 0, 4)`:
`result.reserveCapacity(input.size())` (`sliceAnsi.cpp:876`) reserves
2^30 8-bit characters, and the first 16-bit append converts the buffer.
2.2 GB, for a result of 2 characters.
- `Bun.sliceAnsi("\x1b[31m" + "a".repeat(2 ** 30), 0, 4, "\u2026")`: the
same, and the ellipsis is the first 16-bit append. 2.2 GB, for a result
of 14 characters.
- `Bun.sliceAnsi("\x1b]8;;" + "\u3042".repeat(2 ** 30) + "\x07x", 0,
1)`: the builder of one hyperlink in `parseHyperlink`
(`sliceAnsi.cpp:572`). 8.6 GB.
- The result passes the string length limit. This is the `sliceAnsi`
sibling of #42225 (`wrapAnsi`, open).
- `Bun.sliceAnsi("\x1b[31m" + "a".repeat(2 ** 31 - 6), 1)`:
`StringBuilder result`. 6.5 GB.
- `Bun.sliceAnsi("a".repeat(2 ** 31 - 2), 1, undefined, "\u2026" +
"\u200b".repeat(10))`: `makeString` on the ASCII fast path
(`sliceAnsi.cpp:1400`). 2.2 GB.

**Self-review**
- Changes made after it: exact-bound test cases, the real-size
`stripANSI` test, one-line comments, the list above, and the 1.3.12 to
1.3.13 account.
- After the bot reviews: the exact-length copy in `stripANSI` is
fallible too (a293e34).
- Deferred: a `sliceAnsi` that needs no list (it scans the waiting run
again when the next visible character arrives). That removes the throw.
It touches the hot loop, so it is a PR of its own.
- #42908 (open) edits other lines of `stripANSI.cpp`. Whichever lands
second needs a small rebase.
</details>

<!-- robobun:evidence:begin -->

---

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

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 3 FAILED
$ 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/sliceAnsi.test.ts test/js/bun/util/stripANSI.test.ts
bun test v1.4.3 (09bb546)

test/js/bun/util/stripANSI.test.ts:
(pass) Bun.stripANSI > returns same string object when no ANSI sequences present [171.84ms]
(pass) Bun.stripANSI > returns new string when ANSI sequences are removed [2.47ms]
(pass) Bun.stripANSI > "\u001b[31mred\u001b[39m" [2.01ms]
(pass) Bun.stripANSI > "\u001b[32mgreen\u001b[39m" [0.85ms]
(pass) Bun.stripANSI > "\u001b[33myellow\u001b[39m" [0.29ms]
(pass) Bun.stripANSI > "\u001b[34mblue\u001b[39m" [0.30ms]
(pass) Bun.stripANSI > "\u001b[35mmagenta\u001b[39m" [0.37ms]
(pass) Bun.stripANSI > "\u001b[36mcyan\u001b[39m" [0.26ms]
(pass) Bun.stripANSI > "\u001b[37mwhite\u001b[39m" [0.28ms]
(pass) Bun.stripANSI > "\u001b[41mred background\u001b[49m" [0.30ms]
(pass) Bun.stripANSI > "\u001b[42mgreen background\u001b[49m" [0.26ms]
(pass) Bun.stripANSI > "\u001b[1mbold\u001b[22m" [0.26ms]
(pass) Bun.stripANSI > "\u001b[2mdim\u001b[22m" [0.27ms]
(pass) Bun.stripANSI > "\u001b[3mitalic\u001b[23m" [0.26ms]
(pass) B
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (ee97718)

test/js/bun/util/stripANSI.test.ts:
(pass) Bun.stripANSI > returns same string object when no ANSI sequences present [2.25ms]
(pass) Bun.stripANSI > returns new string when ANSI sequences are removed [0.05ms]
(pass) Bun.stripANSI > "\u001b[31mred\u001b[39m" [0.02ms]
(pass) Bun.stripANSI > "\u001b[32mgreen\u001b[39m"
(pass) Bun.stripANSI > "\u001b[33myellow\u001b[39m"
(pass) Bun.stripANSI > "\u001b[34mblue\u001b[39m"
(pass) Bun.stripANSI > "\u001b[35mmagenta\u001b[39m"
(pass) Bun.stripANSI > "\u001b[36mcyan\u001b[39m"
(pass) Bun.stripANSI > "\u001b[37mwhite\u001b[39m"
(pass) Bun.stripANSI > "\u001b[41mred background\u001b[49m"
(pass) Bun.stripANSI > "\u001b[42mgreen background\u001b[49m"
(pass) Bun.stripANSI > "\u001b[1mbold\u001b[22m"
(pass) Bun.stripANSI > "\u001b[2mdim\u001b[22m"
(pass) Bun.stripANSI > "\u001b[3mitalic\u001b[23m"
(pass) Bun.stripANSI > "\u001b[4munderline\u001b[24m"
(pass) Bun.stripANSI > "\u001b[5mblink\u001b[25m"
(pass) Bun.stripANSI > "\u001b[7mreverse\u001b[27m"
(pass) Bun.stripANSI > "\u001b[8mhidden\u001b[28m"
(pass) Bun.stripANSI > "\u001b[9mstrikethrough\u001b[29m"
(pass) Bun.stripANSI > "\u001b[38;5;1
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ 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/sliceAnsi.test.ts test/js/bun/util/stripANSI.test.ts
bun test v1.4.3 (09bb546)

test/js/bun/util/stripANSI.test.ts:
(pass) Bun.stripANSI > returns same string object when no ANSI sequences present [188.15ms]
(pass) Bun.stripANSI > returns new string when ANSI sequences are removed [2.74ms]
(pass) Bun.stripANSI > "\u001b[31mred\u001b[39m" [2.55ms]
(pass) Bun.stripANSI > "\u001b[32mgreen\u001b[39m" [0.48ms]
(pass) Bun.stripANSI > "\u001b[33myellow\u001b[39m" [0.32ms]
(pass) Bun.stripANSI > "\u001b[34mblue\u001b[39m" [0.28ms]
(pass) Bun.stripANSI > "\u001b[35mmagenta\u001b[39m" [0.36ms]
(pass) Bun.stripANSI > "\u001b[36mcyan\u001b[39m" [0.28ms]
(pass) Bun.stripANSI > "\u001b[37mwhite\u001b[39m" [0.26ms]
(pass) Bun.stripANSI > "\u001b[41mred background\u001b[49m" [0.30ms]
(pass) Bun.stripANSI > "\u001b[42mgreen background\u001b[49m" [0.28ms]
(pass) Bun.stripANSI > "\u001b[1mbold\u001b[22m" [0.62ms]
(pass) Bun.stripANSI > "\u001b[2mdim\u001b[22m" [0.28ms]
(pass) Bun.stripANSI > "\u001b[3mitalic\u001b[23m" [0.25ms]
(pass) B
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 709ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] gen cpp.rs (cppbind)
[1/8] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[1m�[33mwarning�[0m�[1m: `feature(generic_const_exprs)` is not supported with the next-generation trait solver�[0m
 �[1m�[94m--> �[0msrc/shell_parser/lib.rs:1:30
  �[1m�[94m|�[0m
�[1m�[94m1�[0m
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/jsc/bindings/sliceAnsi.cpp     | 30 ++++++++------
 src/jsc/bindings/stripANSI.cpp     | 42 +++++++++++---------
 test/js/bun/util/sliceAnsi.test.ts | 73 ++++++++++++++++++++++++++++++++++
 test/js/bun/util/stripANSI.test.ts | 81 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 196 insertions(+), 30 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                reads  edits  tests
src/jsc/bindings/sliceAnsi.cpp          8      6     26
src/jsc/bindings/stripANSI.cpp          5     12     26
test/js/bun/util/sliceAnsi.test.ts      2      2     17
test/js/bun/util/stripANSI.test.ts      1      1     17
```

</details>

<!-- robobun:evidence:end -->
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.

2 participants