Skip to content

TextEncoder: avoid double Uint8Array allocation on large UTF-16 with lone surrogates - #32383

Closed
robobun wants to merge 3 commits into
mainfrom
farm/a668f61d/textencoder-encode16-double-alloc
Closed

robobun wants to merge 3 commits into
mainfrom
farm/a668f61d/textencoder-encode16-double-alloc

Conversation

@robobun

@robobun robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

const { heapStats } = require("bun:jsc");
const s = "x".repeat(100000) + "\uD800" + "y".repeat(100000);
Bun.gc(true);
const before = heapStats().objectTypeCounts.Uint8Array || 0;
const keep = [];
for (let i = 0; i < 20; i++) keep.push(new TextEncoder().encode(s));
console.log(heapStats().objectTypeCounts.Uint8Array - before);
// before: 40
// after:  20

Cause

encode16_impl in src/runtime/webcore/TextEncoder.rs pre-sized a JSC Uint8Array via simdutf::length::utf8::from::utf16::le(). That function counts an unpaired surrogate as 2 output bytes (surrogate-pair cost / 2), but the U+FFFD replacement we actually emit is 3 bytes. When the input contains a lone surrogate the buffer is undersized, simdutf__convert_utf16le_to_utf8_with_errors fails, the scalar fallback runs out of space before consuming the whole input, the result.read != slice.len() check fails, and the code falls through to to_utf8_alloc_with_type(slice).leak() wrapped in a second external Uint8Array. The first JSC Uint8Array (potentially MB-sized) is orphaned until GC.

The Rust port had also lowered the stack-buffer fast-path threshold from 512 code units (Zig: 2048 / 4) to 64 (192 / 3), so strings in the 65 to 512 range that previously hit the stack path now hit this slow path too.

Output bytes were always correct; the regression is purely the doubled Uint8Array allocation count and the associated GC pressure.

Fix

Match the Zig TextEncoder__encode16: encode inputs up to 512 code units into a 2048-byte stack buffer and copy into a single JSC Uint8Array; for anything larger, allocate once via to_utf8_alloc_with_type and wrap the result as an external Uint8Array. No pre-sized JSC buffer, no fallback allocation.

Verification

New test in test/js/web/encoding/text-encoder.test.js spawns a subprocess that encodes a 200,001-unit string and a 401-unit string (both containing \uD800) 20 times each, and asserts heapStats().objectTypeCounts.Uint8Array grows by at least 20 and less than 30 (never 40). Also asserts the output byte lengths are correct (units + 3 + units).

USE_SYSTEM_BUN=1 bun test text-encoder.test.js -t "allocates one"
  -> fail: Received: 40
bun bd test text-encoder.test.js -t "allocates one"
  -> pass
bun bd test text-encoder.test.js
  -> 43 pass, 0 fail

…lone surrogates

encode16_impl pre-sized a JSC Uint8Array via simdutf's utf16->utf8 length,
which counts an unpaired surrogate as 2 bytes. The U+FFFD replacement is
3 bytes, so the buffer was undersized, the in-place convert failed, and the
code fell through to a second allocation via to_utf8_alloc_with_type,
orphaning the first Uint8Array until GC.

Match the original Zig path: stack-encode inputs up to 512 code units (2048
byte buffer / 4), and for larger inputs go straight to to_utf8_alloc_with_type
wrapped as an external Uint8Array. Also restores the 512-unit fast-path
threshold that the port had lowered to 64.
@robobun

robobun commented Jun 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:54 AM PT - Jun 16th, 2026

❌ @robobun, your commit fc46a5c has 2 failures in Build #62768 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32383

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

bun-32383 --bun

Comment thread src/runtime/webcore/TextEncoder.rs Resolved
The only cross-module caller was encode16_impl in TextEncoder.rs,
removed in the previous commit. The remaining call site is in the same
strings_impl module.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3c03864e-e7fa-4773-ac31-edf6f4beb40c

📥 Commits

Reviewing files that changed from the base of the PR and between 94c91fc and fc46a5c.

📒 Files selected for processing (1)
  • test/js/web/encoding/text-encoder.test.js

Walkthrough

encode16_impl in TextEncoder.rs is updated to raise the small-buffer threshold and replace the large-input pre-sizing/copy flow with strings::to_utf8_alloc_with_type + ArrayBuffer::from_bytes. copy_utf16_into_utf8_with_utf8_len in lib.rs is made private. A regression test is added to verify exactly one Uint8Array is allocated per encode() call.

Changes

TextEncoder UTF-16 Encoding Fix

Layer / File(s) Summary
encode16_impl large-input path and helper visibility
src/runtime/webcore/TextEncoder.rs, src/bun_core/lib.rs
Raises SMALL_BUF_LEN and adjusts the small-path conditional threshold; replaces the large-input need/copy/validation block with strings::to_utf8_alloc_with_type + leaked buffer wrapped via ArrayBuffer::from_bytes(..., JSType::Uint8Array); removes pub from copy_utf16_into_utf8_with_utf8_len.
Regression test for single Uint8Array per encode() call
test/js/web/encoding/text-encoder.test.js
Adds bunEnv, bunExe, and GC helpers to imports; adds a subprocess-based test that runs GC + heapStats measurements across repeated encode() calls on large UTF-16 strings with a lone surrogate, asserting one Uint8Array live-count delta per call and correct UTF-8 output lengths.

Possibly related PRs

  • oven-sh/bun#31385: Both PRs touch src/bun_core/lib.rs's UTF-16→UTF-8 helper copy_utf16_into_utf8_with_utf8_len (the retrieved PR adds/uses it, and the main PR changes its visibility from pub fn to private).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'TextEncoder: avoid double Uint8Array allocation on large UTF-16 with lone surrogates' accurately and specifically describes the main change—fixing a performance regression where large UTF-16 strings with lone surrogates caused duplicate Uint8Array allocations.
Description check ✅ Passed The PR description comprehensively addresses both required sections: 'What does this PR do?' (detailed root cause analysis and fix explanation) and 'How did you verify your code works?' (reproduction steps and new test details).
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/TextEncoder.rs (1)

96-99: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Convert the Vec into a boxed slice before handing it to ArrayBuffer::from_bytes.

ArrayBuffer::from_bytes takes ownership by reconstructing a Box<[u8]> from the slice, but bytes.leak() leaks a Vec<u8> whose capacity is not guaranteed to equal its length. to_utf8_alloc_with_type() returns a Vec, so this can deallocate with the wrong layout when JSC later frees the Uint8Array backing store.

Proposed fix
-    let bytes = strings::to_utf8_alloc_with_type(slice);
-    ArrayBuffer::from_bytes(bytes.leak(), JSType::Uint8Array)
+    let bytes = strings::to_utf8_alloc_with_type(slice).into_boxed_slice();
+    ArrayBuffer::from_bytes(Box::leak(bytes), JSType::Uint8Array)
         .to_js_unchecked(global_this)
         .unwrap_or(JSValue::ZERO)
🤖 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 `@src/runtime/webcore/TextEncoder.rs` around lines 96 - 99, The current code
calls bytes.leak() on a Vec<u8> returned from to_utf8_alloc_with_type(), but
this creates a memory safety issue because leak() creates a slice whose capacity
may not equal its length. When ArrayBuffer::from_bytes reconstructs a Box<[u8]>
and JSC later deallocates it, it uses the wrong layout, causing corruption. Fix
this by converting the Vec to a Box<[u8]> before passing it to
ArrayBuffer::from_bytes. Replace bytes.leak() with bytes.into_boxed_slice() in
the ArrayBuffer::from_bytes call to ensure the capacity equals the length and
JSC deallocates with the correct layout.

Source: Coding guidelines

🤖 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 `@test/js/web/encoding/text-encoder.test.js`:
- Around line 715-729: Reorder the assertions in this test to follow the
guideline of asserting the subprocess exit code last. Currently, the expect
statement checking stderr and exitCode appears at line 715 before the output
validation. Move the exitCode assertion to the end of the test, after all the
allocation delta and byte-length checks (results.large.delta, results.mid.delta,
etc.). You may keep the stderr assertion early as an output diagnostic, but
defer the exitCode check until after all the parsed results have been validated.

---

Outside diff comments:
In `@src/runtime/webcore/TextEncoder.rs`:
- Around line 96-99: The current code calls bytes.leak() on a Vec<u8> returned
from to_utf8_alloc_with_type(), but this creates a memory safety issue because
leak() creates a slice whose capacity may not equal its length. When
ArrayBuffer::from_bytes reconstructs a Box<[u8]> and JSC later deallocates it,
it uses the wrong layout, causing corruption. Fix this by converting the Vec to
a Box<[u8]> before passing it to ArrayBuffer::from_bytes. Replace bytes.leak()
with bytes.into_boxed_slice() in the ArrayBuffer::from_bytes call to ensure the
capacity equals the length and JSC deallocates with the correct layout.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d26289c-af80-478f-9202-212532945092

📥 Commits

Reviewing files that changed from the base of the PR and between 78f0fff and 94c91fc.

📒 Files selected for processing (3)
  • src/bun_core/lib.rs
  • src/runtime/webcore/TextEncoder.rs
  • test/js/web/encoding/text-encoder.test.js

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the CodeRabbit "critical" finding on TextEncoder.rs:96-99 (bytes.leak() into ArrayBuffer::from_bytes): this does not apply here.

ArrayBuffer::from_bytes(...).to_js_unchecked() does not reconstruct a Box<[u8]>. It stores the raw pointer + length in an ArrayBuffer descriptor and hands it to JSC via make_typed_array_with_bytes_no_copy with MarkedArrayBuffer_deallocator as the finalizer. That deallocator is bun_alloc::c_thunks::mi_free_bytes, which calls default_alloc::free(ptr) → mi_free(ptr) (or libc::free(ptr) under ASAN). Neither needs the allocation layout; they free by pointer from allocator metadata, so a Vec whose capacity exceeds its length is freed correctly.

The Box<[u8]> reconstruction the bot refers to lives in the Self::create entry point, not on the from_bytes + to_js_unchecked path used here. This .leak() call also predates the PR (it was the existing fallback branch) and the same pattern is used elsewhere, e.g. BunObject.rs:2513-2515.

The exitCode-ordering nit is applied in fc46a5c.

@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 — restores the Zig-parity single-allocation path for large UTF-16 encode; both prior nits addressed.

Extended reasoning...

Overview

This PR fixes a performance regression in TextEncoder.encode() for large UTF-16 strings containing lone surrogates. The Rust port had introduced an optimistic pre-sized JSC Uint8Array path in encode16_impl that used simdutf's UTF-16→UTF-8 length estimate; that estimate undercounts unpaired surrogates (2 bytes vs the 3-byte U+FFFD actually emitted), so the buffer came up short and the code fell through to a second allocation. The fix removes that path entirely and goes straight to the pre-existing to_utf8_alloc_with_type(...).leak() → ArrayBuffer::from_bytes fallback (which was already there as the last-resort branch), and restores the stack-buffer threshold from 64 to 512 code units to match the original Zig implementation. Net: −13 lines in TextEncoder.rs, a one-token visibility narrowing in lib.rs, and a new heap-stats regression test.

Security risks

None identified. No new unsafe blocks; the Vec::leak() → ArrayBuffer::from_bytes → MarkedArrayBuffer_deallocator (→ mi_free) ownership-transfer pattern was already present on this exact line before the PR (it was the fallback) and is used identically in streams.rs, ReadableStream.rs, and Blob.rs. The author's reply on the thread correctly explains why mi_free handles the capacity-vs-length case without needing the original layout. Output bytes were already correct before this change — this is purely an allocation-count fix.

Level of scrutiny

Moderate — TextEncoder is a hot web-standard path, but this is a simplification that reverts to the known-good Zig strategy rather than introducing new logic. The removed code was the buggy optimization; what remains was already exercised as the fallback. The 2 KB stack buffer matches the prior Zig constant. Both review nits (visibility of copy_utf16_into_utf8_with_utf8_len, and exitCode assertion ordering in the test) have been addressed in follow-up commits.

Other factors

  • The new subprocess test asserts both correctness (output byte length) and the allocation-count invariant (≥20, <30 Uint8Arrays for 20 calls — the regressed behavior produced 40), covering both the >512-unit and the 200–512-unit threshold band.
  • The musl CI failures are LTO bitcode data-layout linker errors ("regular-lto-flag-stub.bc is ''") — a toolchain/build-infra issue unrelated to this Rust change.
  • The full text-encoder.test.js suite (43 tests) passes per the PR description.

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 62768: the only failing lane is darwin-14-x64-test-bun, with two timeouts unrelated to this diff:

  • test/js/bun/terminal/terminal.test.ts "creates subprocess with terminal attached" (90s timeout)
  • test/js/node/http/node-http-backpressure-max.test.ts "should handle backpressure" (60s timeout)

Both are pre-existing on other branches: terminal.test.ts hard-fails on builds 62732 and 62692; node-http-backpressure-max shows as flaky on 62763, 62732 and 62692. None of those branches touch encoding.

text-encoder.test.js (including the new allocation-count test) passed on every lane, and debian-13-x64-asan-test-bun is green. This diff is ready.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #32784 fixes element_length_utf16_into_utf8 itself, routing it through simdutf's utf8_length_from_utf16le_with_replacement (which charges 3 bytes per unpaired surrogate, matching what the encoder writes). That is needed because Buffer.byteLength returns the helper's value directly to users and currently disagrees with Buffer.from(s).length.

That is the same under-count this PR works around at encode16_impl's call site, and the root-cause fix makes the pre-sized fast path here correct again. Verified on #32784's branch with this PR's repro shape: 20 encode() calls on a 100,000-unit string containing a lone \uD800 allocate 20 Uint8Arrays (one per call) instead of 40, with no change to TextEncoder.rs.

Concretely:

  • There is no textual conflict: the two src/bun_core/lib.rs hunks are in different functions.
  • If node:buffer: fix six Node compatibility divergences #32784 lands, the motivation for replacing the sized fast path here goes away. The SMALL_BUF_LEN bump and the one-Uint8Array-per-encode() regression test in this PR are still independently useful.

Flagging so a maintainer can decide which shape to land rather than both.

Jarred-Sumner pushed a commit that referenced this pull request Jun 26, 2026
Six `node:buffer` behaviors that disagree with Node, found by comparing
Bun and Node output over the same inputs. All are exercised by new or
strengthened tests in `test/js/node/buffer.test.js`; every expected
value was verified against `node v26.3.0`.

### `Buffer.byteLength` under-counts unpaired surrogates

```js
const s = "\ud800a".repeat(17);
Buffer.byteLength(s, "utf8");    // bun: 51   node: 68
Buffer.from(s, "utf8").length;   // bun: 68   node: 68
```

`Bun__encoding__byteLengthUTF16AsUTF8` (which carried a `// TODO(@190n)
handle unpaired surrogates`) went through
`element_length_utf16_into_utf8`, which called simdutf's non-validating
`utf8_length_from_utf16le`. That function charges every code unit in
`0xD800..0xDFFF` two bytes, half of an assumed 4-byte pair. The encoder
replaces each unpaired surrogate with U+FFFD (three bytes), so
`byteLength` disagreed with `Buffer.from(s).length`. That is an
under-allocation footgun for the common
`Buffer.allocUnsafe(Buffer.byteLength(s))` + `buf.write(s)` pattern (the
write is bounds-checked, so the result is truncation, not a memory
error).

simdutf already ships the correct function,
`utf8_length_from_utf16le_with_replacement` (SIMD-accelerated; `.count`
is documented to be valid even when the error field is `SURROGATE`). It
just was not exposed through the FFI shim. This adds the binding and
points `element_length_utf16_into_utf8` at it. The new value is always
greater than or equal to the old one and exactly matches the encoder's
output, so every existing caller that used it as an allocation size or
an expected-written length is either unchanged (valid UTF-16) or
corrected (lone surrogates). As a side effect, `TextEncoder.encode` no
longer falls back to a second allocation for lone-surrogate input.

### `lastIndexOf` with `utf16le` does a raw byte search

```js
// "a" in utf16le is the byte pair [0x61, 0x00]; it occurs here only at byte offset 1
Buffer.from([0x00, 0x61, 0x00, 0x00]).lastIndexOf("a", undefined, "utf16le");
// bun: 1   node: -1
```

Node searches in `uint16_t` units, so a UCS2 match can only start on an
even byte offset. Bun's forward `indexOf`/`includes` already do this via
`indexOf16`, but in `indexOfString`/`indexOfBuffer` the `last` branch
was taken before the `ucs2` branch, so backward searches always fell
through to the byte-level `std::find_end`. This adds a `lastIndexOf16`
mirroring `indexOf16` and moves the `ucs2` branch outward.

### Empty haystack short-circuits before the empty-needle logic

```js
Buffer.alloc(0).indexOf(Buffer.alloc(0)); // bun: -1   node: 0
```

`computeIndexOfRange` already implements Node's semantics here (an empty
needle is found at the clamped byteOffset; a non-empty needle in an
empty haystack is -1), but `if (byteLength == 0) return -1;` in the
dispatcher and three per-type re-checks ran first. They are removed, so
the shared helper is the single source of truth. The byteOffset coercion
(which can run user `valueOf`) is also no longer skipped for an empty
buffer, matching Node.

### `fill()` silently truncates a fractional offset

```js
Buffer.alloc(4, "x").fill("a", 1.5);
// bun:  <Buffer 78 61 61 61>
// node: throws ERR_OUT_OF_RANGE 'The value of "offset" is out of range. It must be an integer. Received 1.5'
```

`offset` and `end` went through `validateNumber` (which has no integer
check) and were then truncated with `toLength`. Node routes both through
`validateOffset`, which is `validateInteger`. `Bun::V::validateInteger`
already exists and is used the same way by `compare()` in the same file.

### `swap16`/`swap32`/`swap64` throw the wrong `code`

```js
try { Buffer.alloc(3).swap16(); } catch (e) { e.code }
// bun: "ERR_OUT_OF_RANGE"   node: "ERR_INVALID_BUFFER_SIZE"
```

The message was already correct, but `throwNodeRangeError` hardcodes
`ERR_OUT_OF_RANGE`. Node uses `ERR_INVALID_BUFFER_SIZE` (a
`RangeError`), which did not exist in `ErrorCode.ts`; it is added there.

### `writeBig[U]Int64LE/BE` with a negative offset throws the wrong code

```js
Buffer.alloc(16).writeBigInt64LE(1n, -1);
// bun:  ERR_BUFFER_OUT_OF_BOUNDS: "offset" is outside of buffer bounds
// node: ERR_OUT_OF_RANGE: The value of "offset" is out of range. It must be >= 0 and <= 8. Received -1
```

Node's `boundsError` puts a negative offset in the same `>= 0 and <=
max` bucket as a too-large one; `ERR_BUFFER_OUT_OF_BOUNDS` is reserved
for a buffer that is too short to hold 8 bytes at all.
`validateOffsetBigInt64`'s two negative branches now issue the same
`OUT_OF_RANGE` the too-large path already used, and the integer-ness
check moves ahead of the sign check so that `-1.5` reports `an integer`,
also matching Node.

During review this grew into a full reordering of
`validateOffsetBigInt64` to match Node's `validateNumber` then
`boundsError` sequence exactly: the offset's type is checked first, then
its integer-ness (using `Math.floor(value) !== value` semantics, so
`NaN` and fractions report `an integer` while `+-Infinity` do not), then
the too-short buffer, then the range. This makes
`Buffer.alloc(7).writeBigInt64LE(1n, "x")` a `TypeError`
`ERR_INVALID_ARG_TYPE` and `Buffer.alloc(7).writeBigInt64LE(1n, 1.5)` an
`ERR_OUT_OF_RANGE`, as in Node, instead of `ERR_BUFFER_OUT_OF_BOUNDS`
for both. Every combination is in the test block and verified against
Node v26.3.0.

### Verification

On the released Bun the 8 new or strengthened test blocks all fail (16
failures across `buffer.test.js`'s two parameterized suites; the other
501 tests are unchanged). With this change:

- `bun bd test test/js/node/buffer.test.js`: 516 pass, 0 fail
- Node's upstream
`test-buffer-{indexof,fill,swap,bytelength,bigint64}.js` all still pass
- `bun bd test test/js/web/encoding/`: 2433 pass, 0 fail
(`element_length_utf16_into_utf8` also feeds `TextEncoder`)
- `bun bd test test/js/node/buffer-utf16.test.ts
test/js/node/buffer-indexOf-detach.test.ts
test/js/node/buffer-copy-fill-detach.test.ts
test/js/node/buffer-compare-bounds.test.ts
test/js/node/buffer-concat.test.ts`: 64 pass, 0 fail



### Related

#32383 works around the same `element_length_utf16_into_utf8`
under-count at `TextEncoder`'s call site (it caused a double
`Uint8Array` allocation per `encode()` for large lone-surrogate input).
The root-cause fix here resolves that too: verified with 20 `encode()`
calls on a 100,000-unit string containing a lone surrogate going from 40
live `Uint8Array`s to 20, with no change to `TextEncoder.rs`.
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