fix(string): re-pair split surrogates on += append (node parity, #6741)#6742
Conversation
`js_string_append` (the `+=`/string-building path) copied operand bytes
verbatim and never called `canonicalize_surrogate_pairs`, unlike every
expression-concat path. So a lone high surrogate at the end of `dest` plus a
lone low surrogate at the start of `src` were stored as two separate 3-byte
WTF-8 sequences instead of the astral code point's 4-byte UTF-8:
const e = "a👋b"; const hi = e[1], lo = e[2];
hi + lo; // 👋 (correct — concat canonicalizes)
let Y = ""; Y += hi; Y += lo; // was: lone high surrogate (cp 55357, len 2)
Node re-pairs adjacent surrogates in both cases; perry only did on concat.
Any string rebuilt one UTF-16 code unit at a time over emoji/astral text
(ANSI stripping, manual parsers, width calc) was silently corrupted —
`codePointAt`/`[...s].length`/grapheme segmentation all disagreed with Node.
Found while testing the pi TUI: pi's `visibleWidth` strips ANSI via
`stripped += clean[i]`, which splits an emoji's surrogate pair across two
`+=` appends → over-counted line width → pi's render invariant aborted on any
emoji in a colored line (a real streamed assistant reply crashed the UI).
Fix: propagate the lone-surrogate flag through append and, when a high→low
pair straddles the dest|src join, canonicalize the result — mirroring
`js_string_concat`. The join is detected in O(1) from the boundary bytes
(both operands are already canonical, so a pair can only newly form there),
so ordinary ASCII appends stay allocation-free and never pay for a scan.
Differential regression test (perry output must byte-match node):
tests/test_string_append_surrogate_repair.sh.
📝 WalkthroughWalkthroughChangesSurrogate-aware string append
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant TypeScriptProgram
participant js_string_append
participant canonicalize_surrogate_pairs
participant OutputComparison
TypeScriptProgram->>js_string_append: append split surrogate strings
js_string_append->>js_string_append: detect boundary pair and propagate flags
js_string_append->>canonicalize_surrogate_pairs: repair formed surrogate pair
canonicalize_surrogate_pairs-->>TypeScriptProgram: return canonicalized string
TypeScriptProgram->>OutputComparison: emit metrics and equality results
OutputComparison->>OutputComparison: compare Perry output with Node.js
Possibly related issues
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/test_string_append_surrogate_repair.sh`:
- Around line 52-56: Extend the surrogate-repair test around the multi-character
append case to include a surrogate pair whose completion forces fresh
allocation, exercising the allocation path in the append implementation near the
referenced boundary handling. Assert that the resulting string preserves the
pair and expected code-point count, while retaining the existing checks for the
capacity-available path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 96321c78-1391-4832-87d4-715cfb87fa83
📒 Files selected for processing (2)
crates/perry-runtime/src/string/append.rstests/test_string_append_surrogate_repair.sh
| // 3. Multiple emoji, some with a leading ASCII run (exercises both branches). | ||
| const t = "x😀y👋z🎉w"; | ||
| let r = ""; | ||
| for (let i = 0; i < t.length; i++) r += t[i]; | ||
| console.log("multi", r === t, [...r].length, r.codePointAt(1)); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Cover surrogate repair in the fresh-allocation path.
multi repairs pairs while capacity remains available; bulk reallocates but has no surrogate boundary. Add a boundary pair that forces allocation, or regressions in lines 156-159 will pass unnoticed.
Proposed regression case
// 3. Multiple emoji, some with a leading ASCII run (exercises both branches).
const t = "x😀y👋z🎉w";
let r = "";
for (let i = 0; i < t.length; i++) r += t[i];
console.log("multi", r === t, [...r].length, r.codePointAt(1));
+// 3b. Fill the 32-byte append buffer, then form a pair across a fresh allocation.
+let fresh = "";
+for (let i = 0; i < 29; i++) fresh += "a";
+fresh += hi; // 29 ASCII bytes + 3-byte WTF-8 high surrogate.
+fresh += lo; // Forces allocation and must re-pair at the boundary.
+console.log("fresh", fresh.length, [...fresh].length, fresh.codePointAt(29));
+
// 4. A genuinely lone surrogate must STAY lone (no false merge).📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 3. Multiple emoji, some with a leading ASCII run (exercises both branches). | |
| const t = "x😀y👋z🎉w"; | |
| let r = ""; | |
| for (let i = 0; i < t.length; i++) r += t[i]; | |
| console.log("multi", r === t, [...r].length, r.codePointAt(1)); | |
| // 3. Multiple emoji, some with a leading ASCII run (exercises both branches). | |
| const t = "x😀y👋z🎉w"; | |
| let r = ""; | |
| for (let i = 0; i < t.length; i++) r += t[i]; | |
| console.log("multi", r === t, [...r].length, r.codePointAt(1)); | |
| // 3b. Fill the 32-byte append buffer, then form a pair across a fresh allocation. | |
| let fresh = ""; | |
| for (let i = 0; i < 29; i++) fresh += "a"; | |
| fresh += hi; // 29 ASCII bytes + 3-byte WTF-8 high surrogate. | |
| fresh += lo; // Forces allocation and must re-pair at the boundary. | |
| console.log("fresh", fresh.length, [...fresh].length, fresh.codePointAt(29)); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/test_string_append_surrogate_repair.sh` around lines 52 - 56, Extend
the surrogate-repair test around the multi-character append case to include a
surrogate pair whose completion forces fresh allocation, exercising the
allocation path in the append implementation near the referenced boundary
handling. Assert that the resulting string preserves the pair and expected
code-point count, while retaining the existing checks for the capacity-available
path.
Fixes #6741.
The bug
js_string_append(the+=/ string-building runtime path) copied operand bytes verbatim and never calledcanonicalize_surrogate_pairs, unlike everyjs_string_concat*path. So a lone high surrogate at the end ofdestplus a lone low surrogate at the start ofsrcwere stored as two separate 3-byte WTF-8 sequences instead of the astral code point's 4-byte UTF-8:Node re-pairs adjacent surrogates in both cases; perry only did on concat. Any string rebuilt one UTF-16 code unit at a time over emoji/astral text (ANSI stripping, manual parsers, width calc) was silently corrupted —
codePointAt/[...s].length/Intl.Segmenterall disagreed with Node.How it surfaced
Running the pi coding-agent TUI under perry: pi's
visibleWidthstrips ANSI viastripped += clean[i], which splits an emoji's surrogate pair across two+=appends → over-counted line width → pi'sRendered line exceeds terminal widthinvariant aborted →uncaughtException, crashing the UI on any emoji in a colored line (a normal streamed assistant reply). Found while validating #6728.The fix
Propagate the lone-surrogate flag through append and, when a high→low pair straddles the
dest|srcjoin, canonicalize the result — mirroringjs_string_concat. The join is detected in O(1) from the boundary bytes (both operands are already canonical, so a pair can only newly form there), so ordinary ASCII appends stay allocation-free and never pay for a whole-string scan. Both the in-place (refcount==1) and fresh-alloc append paths are covered.Validation
tests/test_string_append_surrogate_repair.sh— perry output must byte-match Node's (covers the+=repro, char-by-char rebuild, multiple emoji, genuinely-lone surrogates that must stay lone, the ASCII fast path, and split-by-unrelated-append).visibleWidthon the exact crashing line now returns 114 (== Node), down from 124 — the render invariant no longer trips.Independent of #6740 (that PR fixes the async/codegen blockers for #6728; this fixes a string-subsystem parity bug found while testing the result). Built on current
main.Summary by CodeRabbit
Bug Fixes
Tests