Skip to content

Bun.wrapAnsi: don't re-open SGR close codes after line breaks - #34376

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/1a45d0a7/wrapansi-close-code-reopen
Jul 16, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
farm/1a45d0a7/wrapansi-close-code-reopen

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

for (const code of [49, 22, 24, 27]) {
  console.log(code, JSON.stringify(Bun.wrapAnsi(`\x1b[${code}mabc def`, 3)));
}

Before:

49 "\^[[49mabc\n\^[[49mdef"
22 "\^[[22mabc\n\^[[22mdef"
24 "\^[[24mabc\n\^[[24mdef"
27 "\^[[27mabc\n\^[[27mdef"

npm wrap-ansi@9:

49 "\^[[49mabc\ndef"
22 "\^[[22mabc\ndef"
24 "\^[[24mabc\ndef"
27 "\^[[27mabc\ndef"

Cause

joinRowsWithAnsiPreservation tracks the most recent single-number SGR code and closes/reopens it around each inserted newline. The close-before-newline block already gates on getCloseCode(*escapeCode) (only emit a close if the stored code is a known open code), but the reopen-after-newline block only checked if (escapeCode), so a close code like 49 or 22 (stored because it is neither 39 nor 0) was re-emitted at the start of every subsequent wrapped line. npm wrap-ansi gates both sides on ansiStyles.codes.get(escapeCode), which is undefined for close and unknown codes.

Fix

Gate the reopen on the same getCloseCode() check as the close block. Close codes (22-29, 49) and unknown codes now pass through once without being carried as an active style; open codes (1-9, 30-37, 40-47, 90-97, 100-107) continue to be closed and reopened across line breaks as before.

Verification

New test.each cases in test/js/bun/util/wrapAnsi.test.ts cover every close code, an unknown code, the existing 39/0 controls, and the open-code set. 10 of the new cases fail with USE_SYSTEM_BUN=1 and all 166 tests in the file plus the 23 ported npm tests pass with the fix.


[stamp-90s] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 12 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/wrapAnsi.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (005c007ee)

test/js/bun/util/wrapAnsi.test.ts:
(pass) Bun.wrapAnsi > basic wrapping > wraps text at word boundaries [2.80ms]
(pass) Bun.wrapAnsi > basic wrapping > handles empty string [1.18ms]
(pass) Bun.wrapAnsi > basic wrapping > no wrapping needed [1.54ms]
(pass) Bun.wrapAnsi > basic wrapping > wraps multiple words [1.63ms]
(pass) Bun.wrapAnsi > basic wrapping > handles single long word [1.18ms]
(pass) Bun.wrapAnsi > basic wrapping > handles columns = 0 [1.49ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks long words in middle [1.97ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks very long word [1.62ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap false disables wrapping [2.04ms]
(pass) Bun.wrapAnsi > trim option > tri
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (5ee704809)

test/js/bun/util/wrapAnsi.test.ts:
(pass) Bun.wrapAnsi > basic wrapping > wraps text at word boundaries [0.04ms]
(pass) Bun.wrapAnsi > basic wrapping > handles empty string [0.01ms]
(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
(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.02ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap false disables wrapping [0.02ms]
(pass) Bun.wrapAnsi > trim option > trims leading whitespace by default [0.01ms]
(pass) Bun.wrapAnsi > trim option > trim false preserves leading whitespace [0.01ms]
(pass) Bun.wrapAnsi > ANSI escape codes > preserves simple color code [0.02ms]
(pass) Bun.wrapAnsi > ANSI escape codes > preserves color across line break [0.02ms]
(pass) Bun.wrapAnsi > ANSI escape codes > handles multiple colors [0.01ms]
(pass) Bun.wrapAnsi > ANSI escape codes > handles bold and 
... (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/mechgate.xml" test/js/bun/util/wrapAnsi.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (005c007ee)

test/js/bun/util/wrapAnsi.test.ts:
(pass) Bun.wrapAnsi > basic wrapping > wraps text at word boundaries [2.33ms]
(pass) Bun.wrapAnsi > basic wrapping > handles empty string [1.22ms]
(pass) Bun.wrapAnsi > basic wrapping > no wrapping needed [1.58ms]
(pass) Bun.wrapAnsi > basic wrapping > wraps multiple words [1.63ms]
(pass) Bun.wrapAnsi > basic wrapping > handles single long word [1.18ms]
(pass) Bun.wrapAnsi > basic wrapping > handles columns = 0 [1.50ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks long words in middle [2.01ms]
(pass) Bun.wrapAnsi > hard wrap option > breaks very long word [1.44ms]
(pass) Bun.wrapAnsi > wordWrap option > wordWrap false disables wrapping [1.98ms]
(pass) Bun.wrapAnsi > trim option > tri
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 719ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 28.97s
[3/6] link bun-profile
[4/6] bun-profile --re
... (truncated)
diff hotspot
src/jsc/bindings/wrapAnsi.cpp     |  7 +++++--
 test/js/bun/util/wrapAnsi.test.ts | 25 +++++++++++++++++++++++++
 2 files changed, 30 insertions(+), 2 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                               reads  edits  tests
src/jsc/bindings/wrapAnsi.cpp          2      2      0
test/js/bun/util/wrapAnsi.test.ts      1      2      0

joinRowsWithAnsiPreservation gated the close-before-newline emission on
getCloseCode() but not the reopen-after-newline emission, so close codes
(22-29, 49) and unknown codes were stored as the active style and
re-emitted at the start of every wrapped line. npm wrap-ansi gates both
sides on ansiStyles.codes.get(code), which is undefined for close/unknown
codes. Gate the reopen on the same getCloseCode() check.
@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with USE_SYSTEM_BUN=1 bun test test/js/bun/util/wrapAnsi.test.ts -t SGR (close-code and overline cases fail); all 168 + 23 npm-ported tests pass under bun bd test. Added SGR 53 (overline) to getCloseCode() per review so it is closed/reopened across wraps like npm wrap-ansi.

CI on 005c007 (build 74009): all 74 individual lane checks pass and the wrapAnsi tests are green on every platform. The aggregate is red only because one :darwin: 14 aarch64 shard hit a tart VM limit before any tests ran (The number of VMs exceeds the system limit); its sibling shard passed and the darwin-14-aarch64-test-bun check itself is green. All other reported test failures are unrelated flakes that passed on retry. Ready for review.

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:12 PM PT - Jul 16th, 2026

❌ @robobun, your commit 005c007 has some failures in Build #74009 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 34376

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

bun-34376 --bun

@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

ANSI SGR restoration

Layer / File(s) Summary
SGR line-break handling and coverage
src/jsc/bindings/wrapAnsi.cpp, test/js/bun/util/wrapAnsi.test.ts
joinRowsWithAnsiPreservation now reopens only SGR codes with known close mappings, with tests covering open, close, unknown, and adjacent SGR codes across wrapped lines.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the change: preventing SGR close codes from being reopened after line breaks.
Description check ✅ Passed The description includes the needed purpose and verification details, though it uses custom headings instead of the template sections.

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

@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 — one-line symmetry fix making the reopen-after-newline gate match the existing close-before-newline gate.

What was reviewed:

  • Verified getCloseCode() returns nullopt for 22-29/49/200 and a value for 1-9/30-37/40-47/90-97/100-107, so the new condition partitions codes exactly as the tests assert.
  • Checked the close-before-newline block already uses the same getCloseCode() gate — this change just brings the reopen block in line, matching npm wrap-ansi's ansiStyles.codes.get() gating on both sides.
  • Traced the third test (\x1b[42mab\x1b[49mcd ef at width 4): 49 overwrites 42 in escapeCode, has no close mapping, so neither close nor reopen is emitted around the break.
Extended reasoning...

Overview

Single-line change to src/jsc/bindings/wrapAnsi.cpp in joinRowsWithAnsiPreservation: the reopen-after-newline branch now checks escapeCode && getCloseCode(*escapeCode) instead of just escapeCode. This mirrors the close-before-newline branch immediately above it, which already gates on getCloseCode(). Three new test blocks in wrapAnsi.test.ts cover close codes (22-29, 49), unknown codes (200), the existing 39/0 reset path, open codes that must still reopen, and a close-following-open sequence.

Security risks

None. Pure output-formatting logic on already-parsed SGR codes; no allocation, indexing, or external input handling changes.

Level of scrutiny

Low. The fix is mechanical symmetry — the close block and reopen block are meant to be paired (close style before \n, reopen after), and the close block was already correctly gated on getCloseCode(). The bug was simply that the reopen block wasn't. The PR description correctly cites npm wrap-ansi's equivalent gating (ansiStyles.codes.get(escapeCode) is undefined for close/unknown codes on both sides). getCloseCode() is a pure switch/range lookup with no side effects, so calling it twice is safe.

Other factors

Tests are well-constructed: test.each over the full close-code set plus an unknown code, a positive test.each confirming open codes still close-and-reopen with the correct close code, and a mixed open→close case verifying the close code overwrites the tracked state and is then dropped. robobun confirmed 10 new cases fail under USE_SYSTEM_BUN=1 and all 166 + 23 npm-ported tests pass under the debug build. No memory-safety, GC, or exception-scope surface is touched.

@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 reopen gate is now symmetric with the close-before-newline gate, matching npm wrap-ansi's ansiStyles.codes.get() check. The inline nit about SGR 53 (overline) is a pre-existing gap in the local getCloseCode() table, not a defect in this PR's logic — worth a one-line follow-up but not blocking.

Extended reasoning...

Overview

One-line change in src/jsc/bindings/wrapAnsi.cpp: the reopen-after-newline block in joinRowsWithAnsiPreservation now gates on escapeCode && getCloseCode(*escapeCode) instead of just escapeCode, making it symmetric with the close-before-newline block ~15 lines above that already used the same guard. 24 lines of new test.each cases in wrapAnsi.test.ts cover close codes (22-29, 49), unknown codes (200), the 39/0 controls, representative open codes, and a close-following-open sequence.

Security risks

None. Pure string-formatting logic on already-parsed SGR codes; no allocation, no user-controlled sizing, no I/O.

Level of scrutiny

Low. This is a cosmetic-output utility (Bun.wrapAnsi) where the fix mirrors an existing guard on the sibling branch, and the reference implementation (npm wrap-ansi@9) gates both sides on the same predicate. The change is mechanical symmetry with a well-understood rationale.

Other factors

  • Tests fail on main (10 cases) and pass with the fix, per the PR's evidence block and robobun's confirmation.
  • The one finding (SGR 53 / overline) is a pre-existing drift between the local getCloseCode() and ANSIHelpers.h — the close-before-newline block already had this gap before the PR. The PR moves overline from "reopened but not closed" to "neither closed nor reopened"; both diverge from npm, and in a continuous terminal stream the visual result is identical since SGR state persists across \n. Marked as a nit; a one-line case 53: return 55; addition would fully close it but isn't required to land this fix.
  • No prior reviews from me on this PR.

Comment thread src/jsc/bindings/wrapAnsi.cpp
… wraps

ansi-styles@6 maps 53->55; without it the reopen gate now drops overline
at line breaks instead of closing and reopening it like npm wrap-ansi.

@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 SGR 53 (overline) mapping was added in 005c007 and both test.each blocks now cover it, so the reopen gate matches ansi-styles' open-code set exactly.

Checked that the close-before-newline and reopen-after-newline branches now use the same getCloseCode() predicate, so behavior is symmetric.
Verified the local getCloseCode() table now matches ansi-styles@6's codes map (1-9, 30-37, 40-47, 53, 90-97, 100-107); no other open codes are missing.
The new close/unknown test.each covers 22-29/49/55/39/0/200 and the open-then-close case, all with exact-string toBe assertions.

Extended reasoning...

Overview

Two-file change to Bun.wrapAnsi: a one-line guard in joinRowsWithAnsiPreservation (src/jsc/bindings/wrapAnsi.cpp) so the reopen-after-newline block only re-emits SGR codes that have a known close mapping, plus case 53: return 55; added to the local getCloseCode() switch. test/js/bun/util/wrapAnsi.test.ts gains three test.each-driven cases covering close codes (22-29, 49, 55), the 39/0 controls, an unknown code (200), representative open codes (1, 4, 31, 42, 53, 100), and an open-followed-by-close sequence.

Security risks

None. Pure output-formatting change on a string utility; no I/O, allocation sizing, or user-controlled indexing was touched. getCloseCode() is a pure lookup on a uint32_t.

Level of scrutiny

Low. The functional diff is a single added && getCloseCode(*escapeCode) conjunct that mirrors the guard already present on the sibling close-before-newline branch three lines above, plus one switch case. The reference implementation (npm wrap-ansi@9 gating both sides on ansiStyles.codes.get(escapeCode)) is cited in the PR body and the new behavior byte-matches it.

Other factors

My earlier review on this PR flagged that the new gate would regress SGR 53 (overline) because the local getCloseCode() was missing the 53 → 55 entry that both ansi-styles@6 and Bun's own ANSIHelpers.h include. Commit 005c007 added the case and extended both test tables to cover 53/55; the thread is resolved. I re-checked the local table against ansi-styles' modifier/color/bgColor maps — 53 was the only gap, so the table is now complete. The PR's evidence block shows the new tests fail on the pre-fix build and pass on both debug+ASAN and release with the fix. No outstanding reviewer comments remain.

@Jarred-Sumner
Jarred-Sumner merged commit 000407f into main Jul 16, 2026
77 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/1a45d0a7/wrapansi-close-code-reopen branch July 16, 2026 20:30
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