Conversation
|
Updated 2:11 AM PT - May 7th, 2026
❌ @robobun, your commit e576479 has some failures in 🧪 To try this PR locally: bunx bun-pr 28772That installs a local version of the PR into your bun-28772 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughReturn Changes
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
03be8a1 to
d6abcc2
Compare
d6abcc2 to
72d00b2
Compare
72d00b2 to
3a8944e
Compare
There was a problem hiding this comment.
LGTM — all previously raised issues have been addressed.
Extended reasoning...
Overview
This PR modifies src/bun.js/bindings/stripANSI.cpp and its test file to fix two related bugs around false-positive ANSI escape bytes (0x9C): (1) stripANSI unnecessarily allocated a buffer when the while loop exited via start == end after only false positives, and (2) Bun__ANSI__next silently dropped 0x9C bytes by advancing start++ before breaking.
Security risks
None. The changes are purely in string-processing logic with no security-sensitive surfaces.
Level of scrutiny
Low-to-medium. The fix is targeted and the logic is straightforward: a post-loop !foundANSI guard in stripANSI, and a post-skip-loop escPos == start re-scan in Bun__ANSI__next that includes the false-positive byte in the content slice while still advancing the cursor to avoid an infinite loop.
Previous review iterations
Three rounds of prior review feedback were all addressed: (1) test input was corrected to "hello\x9C" to exercise the new post-loop guard, (2) Bun.stripANSI is now cached before the heap baseline, and (3) the Bun__ANSI__next fix correctly avoids both the data-loss and the infinite loop. The remaining bug report is a minor performance nit (consecutive 0x9C bytes cause one extra ArrayList allocation) with no correctness impact and an essentially impossible real-world occurrence.
| if (escPos == start) | ||
| escPos = (start + 1 < end) ? ANSI::findEscapeCharacter(start + 1, end) : nullptr; |
There was a problem hiding this comment.
🟡 The copyToClipboardOSC52 fast path (first.len == text.len) is bypassed for inputs starting with 2+ consecutive \x9C bytes: the new escPos == start re-scan at lines 133-134 immediately finds the second \x9C at start+1, yielding only a 1-byte slice on the first iterator call, so first.len != text.len triggers unnecessary ArrayList allocation even though no real ANSI sequences are present. Output is always correct; this is a minor performance nit affecting an extremely rare input pattern.
Extended reasoning...
Fast path bypassed for consecutive false-positive bytes
What the bug is and how it manifests
The function in uses a heuristic to detect no ANSI sequences stripped and take a zero-copy fast path, avoiding an ArrayList allocation. With the new code in , inputs starting with 2+ consecutive bytes cause the first iterator call to return only a 1-byte slice, defeating this heuristic and triggering the slow path (ArrayList allocation) even though no real ANSI sequences exist in the input.
The specific code path that triggers it
At stripANSI.cpp:133-134, after the skip-loop breaks without advancing (because -- false positive), the post-loop content scan runs . For input , this returns (first ), triggering the branch. immediately returns (the second ), so and . The first call to yields only the 1-byte slice rather than the full string.
Why existing code does not prevent it
explicitly matches in both its SIMD exact mask and the scalar fallback. This means every consecutive byte acts as a new escape candidate boundary, causing the re-scan from to stop immediately at the next . The PR design for avoiding infinite loops (break without advancing + re-scan from start+1) has the side effect of fragmenting slices whenever two false-positive bytes are adjacent.
Step-by-step proof for (6 bytes)
- Call 1 (cursor=0, start points to first ): skip-loop fires findEscapeCharacter -> returns start; consumeANSI returns start (false positive); break without advancing. Post-loop: findEscapeCharacter(start, end) = start; escPos == start guard fires; findEscapeCharacter(start+1, end) = pointer to second . slice_end = start+1, slice_len = 1. Returns slice (1 byte), cursor=1.
- In copyToClipboardOSC52: first.len = 1 != text.len = 6 -> slow path fires, allocates ArrayList clean.
- Subsequent calls yield (1 byte) then (4 bytes); appended to clean.
- clean = (correct). Encoded for clipboard with unnecessary allocation.
For comparison, a single false-positive (5 bytes): findEscapeCharacter(start+1, end) returns nullptr (no more escapes in "text"), so slice_end = end, slice_len = 5, first.len = 5 == text.len = 5 -- fast path fires correctly.
Addressing the refutations
One refutation correctly notes that the old code also triggered the slow path for (first call yielded an empty 0-byte slice, also failing first.len == text.len). This is accurate -- there is no performance regression vs pre-PR behavior for this specific case, and the new code is strictly better overall (correct output with slow path, vs corrupted output with slow path). The nit is that the re-scan design leaves an optimization gap for consecutive false-positives that could theoretically be closed with a different approach (e.g., scanning past all consecutive false-positive bytes before emitting a slice). Given that consecutive � bytes are essentially impossible in real terminal output, and the slow path allocates only a small transient buffer proportional to text.len, this is purely an academic concern about the fast-path heuristic not firing in an edge case.
3a8944e to
5e9ea05
Compare
There was a problem hiding this comment.
LGTM — all previously flagged issues have been addressed.
Extended reasoning...
Overview
This PR touches two functions in src/bun.js/bindings/stripANSI.cpp: the JS-facing stripANSI template and the C iterator Bun__ANSI__next. The test file gains one new test case.
Security Risks
No security-sensitive code is involved. The changes are pure string-processing logic with no I/O, no authentication, and no memory-safety concerns beyond the already-audited WTF::Vector and memcpy patterns.
Level of Scrutiny
This required multiple review rounds due to subtle edge-case interactions (false-positive escape bytes, infinite-loop risk, iterator consistency). After those rounds, all critical issues are resolved: the post-loop !foundANSI guard correctly short-circuits for strings ending with false-positive bytes; the Bun__ANSI__next fix avoids both the data-loss bug (dropped bytes) and the infinite-loop risk (via the escPos == start re-scan from start + 1); and the new test exercises the exact code path with the correct input and caching order.
Other Factors
The one remaining nit — the WTF::Vector buffer is still heap-allocated before the !foundANSI guard fires — is a minor C++ performance cost on an extremely rare input pattern (strings ending with standalone 0x9C). It does not affect correctness or the JS-observable behavior. The PR description and inline documentation are clear about the intent of each change.
5e9ea05 to
32bab0e
Compare
1554630 to
053f7ce
Compare
…ytes When all escape-like bytes found by findEscapeCharacter are false positives (e.g. standalone C1 ST 0x9C), the while loop exits via start == end rather than the !escPos branch. The !foundANSI guard only existed inside the !escPos path, so the function fell through to String::adopt() and returned an allocated copy identical to the input instead of std::nullopt. Add a !foundANSI check after the while loop so the caller reuses the original JSString with zero copies.
053f7ce to
e576479
Compare
| auto escPos = ANSI::findEscapeCharacter(start, end); | ||
| // If the escape candidate is at `start`, it's a false-positive from the | ||
| // skip-loop (e.g. standalone 0x9C — not a real ANSI sequence). Include it | ||
| // in the content by scanning from start + 1 for the actual next escape. | ||
| if (escPos == start) | ||
| escPos = (start + 1 < end) ? ANSI::findEscapeCharacter(start + 1, end) : nullptr; |
There was a problem hiding this comment.
🟡 The behavioral changes to Bun__ANSI__next (preserving standalone 0x9C bytes via break without advancing, plus the escPos == start re-scan to maintain liveness) have no test coverage — the new test only exercises Bun.stripANSI, not the C iterator. Given that an earlier iteration of this exact fix introduced an infinite loop caught only by manual review, it would be worth adding a test that drives ANSIIterator over input containing standalone 0x9C and asserts (a) termination and (b) that the yielded slices concatenate back to the original input.
Extended reasoning...
What the gap is
This PR makes two non-trivial behavioral changes to Bun__ANSI__next in src/jsc/bindings/stripANSI.cpp:
- At line ~128,
start++; break;becomesbreak;so that broad-mask false-positive bytes (standalone0x9C) are no longer silently dropped. - At lines 143–148, an
escPos == startguard is added that re-scans fromstart + 1so the iterator does not infinite-loop on the byte it just declined to advance past.
Neither change is exercised by any test. grep confirms that ANSIIterator, Bun__ANSI__next, and copyToClipboardOSC52 are referenced only in src/string/immutable.zig, src/jsc/bindings/stripANSI.cpp, and src/cli/repl.zig — there are zero hits in test/. The only new test in this PR (stripANSI.test.ts:17–28) calls Bun.stripANSI, which goes through the templated stripANSI<Char> function — a completely separate code path from the C iterator.
Why this matters here specifically
The PR timeline shows that this exact code went through a broken intermediate state during this review cycle: the first attempt at change (1) — removing start++ without adding the re-scan — introduced an infinite loop where Bun__ANSI__next would forever return true with a zero-length slice at an unchanging cursor, hanging copyToClipboardOSC52. That regression was caught only by a manual review comment, not by a failing test. The fix (the re-scan at lines 143–148) is itself subtle enough that a separate nit was filed about it fragmenting slices for consecutive 0x9C bytes. Code that has already regressed once within a single PR cycle is a strong signal that it needs a regression test.
Why existing coverage does not help
The Bun.stripANSI test suite is comprehensive for the templated path, but Bun__ANSI__next shares only findEscapeCharacter/consumeANSI with it — the loop structure, cursor bookkeeping, and slice-boundary logic are entirely independent. A bug in the iterator's liveness or byte-preservation would not be detected by any existing test; the infinite-loop regression in this PR is direct proof of that.
Step-by-step: what a regression test would have caught
For input "\x9C" against the intermediate (broken) commit:
- Call 1,
cursor=0: skip-loop findsescPos == start,consumeANSIreturnsstart,breakwithout advancing. Post-loopfindEscapeCharacter(start, end)returnsstartagain →slice_len = 0,cursor = 0, returnstrue. - Call 2,
cursor=0: identical state → identical result. The Zigwhile (it.next()) |slice|loop incopyToClipboardOSC52never terminates.
A test that simply iterates strings.ANSIIterator over "a\x9Cb" and asserts the concatenated slices equal "a\x9Cb" would have (a) hung on the broken commit and (b) failed on the pre-PR code (which yielded "a" ++ "b", dropping the byte).
Suggested fix
Add a Zig test (e.g. in src/string/immutable.zig alongside ANSIIterator, or a small case in the bindings tests) that iterates over inputs like "\x9C", "a\x9Cb", and "\x9C\x9C", concatenates the yielded slices, and asserts the result equals the input. This pins down both the termination guarantee and the byte-preservation guarantee that this PR establishes.
This is nit severity — the current implementation traces correctly by hand and all verifiers agree it is sound; this is purely a coverage gap, not a correctness bug, and need not block the PR.
|
Closing: this was superseded by #33488 (Consolidate the ANSI escape grammar across stringWidth, stripANSI, sliceAnsi and wrapAnsi), which landed on main with the same fix this PR proposed. Specifically, main now has:
The merge conflict from the grammar consolidation is unresolvable in a useful way: with the fix already on main, this PR's test passes with and without its diff, so there is nothing left for it to prove. |
Problem
stripANSIallocates a buffer and returns aStringcopy when input contains only false-positive escape bytes (e.g. standalone C1 ST0x9C), violating the optimization contract that says "for no-escape input we returnstd::nulloptand the caller reuses the original JSString with zero copies."When all escape-like bytes are false positives and the last one is at the end of input, the while loop exits via
start == endwithout reaching the!foundANSIguard inside theif (!escPos)branch. Execution falls through toString::adopt(), returning an unnecessary copy.Additionally,
Bun__ANSI__next(the C iterator used by the REPL clipboard) silently drops standalone0x9Cbytes: the false-positive branch didstart++; break;, advancing past the byte so it never appeared in any yielded content slice.Fix
if (!foundANSI) return std::nullopt;after the while loop instripANSI, catching the case where the loop exits viastart == endwith only false positives.start++; break;tobreak;inBun__ANSI__nextso false-positive bytes fall into the next content slice instead of being dropped.Bun.stripANSIbefore theheapStatsbaseline in the test to match the established pattern.Verification
USE_SYSTEM_BUN=1 bun test test/js/bun/util/stripANSI.test.ts -t "false-positive"→ FAIL (heapStats +1)bun bd test test/js/bun/util/stripANSI.test.ts→ 266 pass, 0 fail