Skip to content

Report UTF-16 to UTF-8 encode counts through size_t so exactly 2^32 written bytes doesn't wrap to 0 - #37252

Open
robobun wants to merge 6 commits into
mainfrom
farm/c72a078b/widen-encode-into-counts
Open

Report UTF-16 to UTF-8 encode counts through size_t so exactly 2^32 written bytes doesn't wrap to 0#37252
robobun wants to merge 6 commits into
mainfrom
farm/c72a078b/widen-encode-into-counts

Conversation

@robobun

@robobun robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

Needs ~10 GiB RAM. A UTF-16 string whose UTF-8 encoding is exactly 2^32 bytes, written into a MAX_LENGTH (2^32) buffer:

const str = "\u0800".repeat(1431655765) + "a"; // 3*1431655765 + 1 = 2**32 UTF-8 bytes
const buf = Buffer.alloc(4294967296);

buf.write(str, 0, buf.length, "utf8");        // bun: 0        expected: 4294967296
new TextEncoder().encodeInto(str, buf);       // bun: { read: 1431655766, written: 0 }
new TextEncoder().encode(str);                // bun: aborts (panic: int cast: TryFromIntError)

For write/encodeInto, every byte is actually written (the buffer contents are correct); only the reported count is wrong, so callers that trust it (advance offsets, slice results, loop on written) misbehave. encode() is worse: the wrapped count fails encode16's completeness check, so the already-correct fast-path result is discarded and the 2^32-byte fallback hits the u32::try_from(len).expect("int cast") in ArrayBuffer::from_bytes, aborting the process.

Cause

EncodeIntoResult in bun_core declared read: u32, written: u32, and every producer (copy_utf16_into_utf8*, copy_latin1_into_utf8*, copy_latin1_into_utf16, copy_cp1252_into_utf16) cast its usize count with as u32. The C ABI wrappers Bun__encoding__writeUTF16/writeLatin1 take and return size_t, so the count silently truncated on the round-trip through the helper. Exactly 2^32 is reachable only for UTF-16 sources (Latin-1 caps at 2*(2^31-1) = 2^32-2), and JSC permits ArrayBuffers of exactly 2^32 bytes.

TextEncoder__encodeInto8/16 additionally packed read/written into two u32 halves of a u64 across the FFI, so TextEncoder.encodeInto (and the V8 String::WriteUtf8V2 shim) could not receive 2^32 even with the helper fixed.

Fix

  • EncodeIntoResult.read/written are usize (now repr(C)), all producers return exact counts, all consumers updated (no behavior change below 2^32).
  • TextEncoder__encodeInto8/16 return the struct by value (declared as TextEncoderEncodeIntoResult in headers-handwritten.h) instead of the packed u64; encodeInto's result object is built from the full counts (jsNumber yields 4294967296.0 as a double, which is exact).
  • V8String.cpp WriteUtf8V2 drops the chunked-encode workaround it carried specifically because the packed 32-bit counts could wrap; a single call now returns exact size_t counts.
  • TextEncoder.encode() stops aborting at exactly 2^32 output bytes: with exact counts the completeness check passes and the fast path returns the already-filled array, so the panicking fallback is no longer reached for any input that fits in an ArrayBuffer.

This is the second layer of the bug family from #34274 (which carried size_t through Buffer.toString/write argument handling; the returned count still wrapped). The open #37239 works around this wrap in the stream consumer concatenation and can drop that once this lands.

Related but deliberately not included here: the pre-existing detached-destination case for encodeInto (null vector from a detached view) is fixed by the open #36531, which touches the same wrappers; and ArrayBuffer::from_bytes's u32 length ceiling remains for its other callers (streams/Blob), tracked separately.

Verification

New test in test/js/node/buffer.test.js next to the existing MAX_LENGTH test, gated on os.totalmem() >= 16 GiB (peak RSS ~10.5 GiB per spawn, two sequential spawns), covering Buffer.write, TextEncoder.encodeInto, Buffer.byteLength, buffer contents at the 2^32 boundary, and TextEncoder.encode (which aborted pre-fix).

  • fails on unfixed bun: write_ret: 0, encode_written: 0 (with correct buffer contents), and the encode() spawn aborts with the int-cast panic
  • passes with this change: counts report 4294967296, encode() returns a 4 GiB Uint8Array
  • bun run rust:check-all: all 10 target/platform combos pass
  • full test/js/node/buffer.test.js (618 tests), test/js/web/encoding/ (197 tests), and test/v8/v8.test.ts (74 tests, exercising the WriteUtf8V2 path) pass with the debug build

[review] gate passed · iteration 1 · 16 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/buffer.test.js
bun test v1.4.0 (501e79610)

test/js/node/buffer.test.js:
(pass) with native Buffer.write > #9120 fill [90.37ms]
(pass) with native Buffer.write > #9120 alloc [69.34ms]
(pass) with native Buffer.write > isAscii [63.10ms]
(pass) with native Buffer.write > isUtf8 [65.67ms]
(pass) with native Buffer.write > Buffer global is settable [62.10ms]
(pass) with native Buffer.write > length overflow [63.22ms]
(pass) with native Buffer.write > truncate input values [189.39ms]
(pass) with native Buffer.write > Buffer.allocUnsafe() [68.47ms]
(pass) with native Buffer.write > Buffer.from() [68.39ms]
(pass) with native Buffer.write > offset properties [66.13ms]
(pass) with native Buffer.write > creating a Buffer from a Uint32Array [71.05ms]
(pass) with native Buffer.write > creating a Buffer from a Uint32Array (old constructor) [67.99ms]
(pass) with native Buffer.write > invalid encoding [77.48ms]
(pass) with native Buffer.write > create 0-length buffers [76.20ms]
(pass) with native Buffer.write > write() beyond end of buff
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (f4df733b7)

test/js/node/buffer.test.js:
(pass) with native Buffer.write > #9120 fill [2.47ms]
(pass) with native Buffer.write > #9120 alloc [2.09ms]
(pass) with native Buffer.write > isAscii [2.07ms]
(pass) with native Buffer.write > isUtf8 [1.99ms]
(pass) with native Buffer.write > Buffer global is settable [2.00ms]
(pass) with native Buffer.write > length overflow [2.03ms]
(pass) with native Buffer.write > truncate input values [2.28ms]
(pass) with native Buffer.write > Buffer.allocUnsafe() [1.94ms]
(pass) with native Buffer.write > Buffer.from() [2.08ms]
(pass) with native Buffer.write > offset properties [1.87ms]
(pass) with native Buffer.write > creating a Buffer from a Uint32Array [2.23ms]
(pass) with native Buffer.write > creating a Buffer from a Uint32Array (old constructor) [2.15ms]
(pass) with native Buffer.write > invalid encoding [2.15ms]
(pass) with native Buffer.write > create 0-length buffers [2.10ms]
(pass) with native Buffer.write > write() beyond end of buffer [2.19ms]
(pass) with native Buffer.write > write BigInt beyond 64-bit range [2.34ms]
(pass) with native Buffer.write > write BigInt64 with insufficient buffer space
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/buffer.test.js
bun test v1.4.0 (501e79610)

test/js/node/buffer.test.js:
(pass) with native Buffer.write > #9120 fill [95.84ms]
(pass) with native Buffer.write > #9120 alloc [73.40ms]
(pass) with native Buffer.write > isAscii [71.25ms]
(pass) with native Buffer.write > isUtf8 [76.18ms]
(pass) with native Buffer.write > Buffer global is settable [73.91ms]
(pass) with native Buffer.write > length overflow [76.24ms]
(pass) with native Buffer.write > truncate input values [229.90ms]
(pass) with native Buffer.write > Buffer.allocUnsafe() [70.29ms]
(pass) with native Buffer.write > Buffer.from() [65.38ms]
(pass) with native Buffer.write > offset properties [74.41ms]
(pass) with native Buffer.write > creating a Buffer from a Uint32Array [79.66ms]
(pass) with native Buffer.write > creating a Buffer from a Uint32Array (old constructor) [79.48ms]
(pass) with native Buffer.write > invalid encoding [79.98ms]
(pass) with native Buffer.write > create 0-length buffers [66.46ms]
(pass) with native Buffer.write > write() beyond end of buff
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 783ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/125] gen cpp.rs (cppbind)
[2/125] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�
... (truncated)
diff hotspot
src/bun_core/fmt.rs                             | 12 ++---
 src/bun_core/lib.rs                             | 27 +++++-----
 src/bun_core/string/immutable/unicode.rs        | 13 +++--
 src/http_jsc/websocket_client.rs                | 18 +++----
 src/install/PackageInstall.rs                   |  2 +-
 src/jsc/bindings/headers-handwritten.h          |  9 ++++
 src/jsc/bindings/v8/V8String.cpp                | 47 +++++------------
 src/jsc/bindings/webcore/JSTextEncoder.cpp      |  9 ++--
 src/paths/string_paths.rs                       |  2 +-
 src/runtime/node/node_process.rs                |  4 +-
 src/runtime/webcore/TextDecoder.rs              |  2 +-
 src/runtime/webcore/TextEncoder.rs              | 34 +++++--------
 src/runtime/webcore/TextEncoderStreamEncoder.rs |  4 +-
 src/runtime/webcore/encoding.rs                 | 12 ++---
 src/watcher/WindowsWatcher.rs                   |  2 +-
 test/js/node/buffer.test.js                     | 67 +++++++++++++++++++++++++
 16 files changed, 152 insertions(+), 112 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                             reads  edits  tests
src/bun_core/fmt.rs                                  1      2      0
src/bun_core/lib.rs                                  1      4      0
src/bun_core/string/immutable/unicode.rs             2      1      0
src/http_jsc/websocket_client.rs                     1      1      0
src/install/PackageInstall.rs                        1      2      0
src/jsc/bindings/headers-handwritten.h               1      3      0
src/jsc/bindings/v8/V8String.cpp                     1      3      0
src/jsc/bindings/webcore/JSTextEncoder.cpp           1      4      0
src/paths/string_paths.rs                            1      2      0
src/runtime/node/node_process.rs                     1      2      0
src/runtime/webcore/TextDecoder.rs                   1      2      0
src/runtime/webcore/TextEncoder.rs                   1      2      0
src/runtime/webcore/TextEncoderStreamEncoder.rs      1      2      0
src/runtime/webcore/encoding.rs                      1      1      0
src/watcher/WindowsWatcher.rs                        1      1      0
test/js/node/buffer.test.js                          1      3      0

A UTF-16 source can encode to exactly 2^32 UTF-8 bytes, and JSC allows
2^32-byte ArrayBuffers, so EncodeIntoResult's u32 counts wrapped to 0
even though every byte was written: Buffer.write returned 0 and
TextEncoder.encodeInto returned {written: 0} for a full 4 GiB
destination.

EncodeIntoResult.read/written are now usize end to end, the
TextEncoder__encodeInto8/16 FFI returns the struct by value instead of
packing two u32 halves into a u64, and V8String's WriteUtf8V2 drops the
chunking workaround it carried for those packed 32-bit counts.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The encoding result contract now uses native-size read and written fields. Rust, C++, JavaScript bindings, and encoding consumers use these fields directly. A regression test covers encoding exactly 2**32 bytes.

Native encode count propagation

Layer / File(s) Summary
Encode result contract and implementations
src/bun_core/lib.rs, src/bun_core/string/immutable/unicode.rs
EncodeIntoResult now uses usize fields. Encoding paths return native-size counts.
TextEncoder binding bridge
src/jsc/bindings/headers-handwritten.h, src/jsc/bindings/v8/V8String.cpp, src/jsc/bindings/webcore/JSTextEncoder.cpp, src/runtime/webcore/TextEncoder.rs
Bindings use structured results with separate read and written fields. WriteUtf8V2 passes the full UTF-16 span to the encoder.
Native count consumers
src/bun_core/fmt.rs, src/http_jsc/websocket_client.rs, src/install/PackageInstall.rs, src/paths/string_paths.rs, src/runtime/node/node_process.rs, src/runtime/webcore/{TextDecoder.rs,TextEncoderStreamEncoder.rs,encoding.rs}, src/watcher/WindowsWatcher.rs
Consumers use native-size conversion counts without narrowing casts.
Large-count regression coverage
test/js/node/buffer.test.js
A resource-gated test validates Buffer and TextEncoder behavior for exactly 2**32 encoded bytes.

Suggested reviewers: cirospaciari

🚥 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 identifies the main fix: reporting UTF-16 to UTF-8 encode counts through size_t to prevent 2^32-byte count wrapping.
Description check ✅ Passed The description explains the reproduction, cause, fix, scope, and verification results in sufficient detail, despite using different headings than the template.

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

@github-actions github-actions Bot added the claude label Aug 9, 2026

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

Beyond the inline nit, I grepped for other TextEncoder__encodeInto8/16 callers — all three C++ sites (JSTextEncoder.cpp, V8String.cpp ×2) are updated to the new struct-return ABI, and no other .read as usize/.written as usize casts remain. Given the FFI return-type change and the V8String chunked-encode removal, a human look is still worthwhile.

Extended reasoning...

Verified via grep that the FFI signature change is complete on both sides (Rust producer + all C++ consumers updated; #[repr(C)] on the Rust struct matches the TextEncoderEncodeIntoResult typedef in headers-handwritten.h), and that the one flagged leftover cast in fmt.rs:587 is the only remaining as usize on the widened fields. Deferring rather than approving because the change spans 16 files including an FFI ABI change (u64 packed → struct-by-value) and deletes a non-trivial chunked-encode workaround in V8String::WriteUtf8V2.

Comment thread src/bun_core/fmt.rs
Comment thread src/bun_core/lib.rs Outdated
Comment thread src/jsc/bindings/headers-handwritten.h Outdated
Comment thread src/bun_core/lib.rs
Comment thread src/jsc/bindings/headers-handwritten.h
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:19 AM PT - Aug 9th, 2026

@robobun, your commit 501e796 has 1 failures in Build #90922 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37252

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

bun-37252 --bun

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

I reviewed this PR and found no bugs (the earlier redundant-cast nit was fixed in 9255fac). Because it changes an FFI ABI — TextEncoder__encodeInto8/16 now return a repr(C) struct by value instead of a packed u64 — across Rust and three C++ call sites, and removes the chunked-encode workaround in V8String::WriteUtf8V2, a human look would still be worthwhile. The comment-cop lint on the new doc comments in lib.rs/headers-handwritten.h is also still outstanding.

Checked: all TextEncoder__encodeInto call sites are covered by the diff; both <true> (STOP) callers of copy_latin1_into_utf8_stop_on_non_ascii were updated to the new usize::MAX sentinel; jsNumber(size_t) yields exact 4294967296.0 for the encodeInto result.

Extended reasoning...

Overview

Widens EncodeIntoResult.{read,written} from u32 to usize so a UTF-16→UTF-8 encode that writes exactly 2^32 bytes reports the correct count instead of wrapping to 0. 16 files: the core struct definition (bun_core/lib.rs), all producers in bun_core/string/immutable/unicode.rs, ~10 consumer files that mechanically drop as usize casts, an FFI ABI change (TextEncoder__encodeInto8/16 return the struct by value instead of packing two u32s into a u64), the C++ mirror struct in headers-handwritten.h, updates to the two C++ callers (JSTextEncoder.cpp, V8String.cpp — the latter also drops a chunked-encode workaround that existed specifically because of the 32-bit wrap), and a new memory-gated test.

Security risks

None identified. This is an integer-width fix in encoding count reporting; no auth, crypto, or untrusted-input parsing changes. The buffer contents were already correct — only the reported count was wrong.

Level of scrutiny

Medium-high. Most of the diff is mechanical (as usize cast removal, identity behavior below 2^32), but the FFI ABI change — returning a 16-byte {size_t, size_t} struct by value across the Rust↔C++ boundary on all five platform/arch combos — is the kind of change where a layout or calling-convention mismatch would silently corrupt results. The PR reports rust:check-all passing on all 10 targets and the buffer/encoding/v8 test suites passing, which is good evidence, but this is core hot-path encoding infrastructure that a maintainer should sign off on.

Other factors

  • My prior review's one finding (leftover as usize cast at fmt.rs:587) was fixed in 9255fac and the thread is resolved.
  • Verified all TextEncoder__encodeInto references are in the diff (5 files); no stale callers with the old u64 signature.
  • Verified the usize::MAX sentinel change: only two STOP=true callers exist (both in RopeStringEncoder), both updated; no lingering u32::MAX checks on EncodeIntoResult fields.
  • The comment-cop bot flagged the new 3–4 line doc comments on EncodeIntoResult and TextEncoderEncodeIntoResult. These document the FFI mirror relationship (which REVIEW.md says to comment when ownership/layout is non-local across FFI), so they read as false positives to me — but they're outstanding and the author should either shorten them or dismiss the lint.
  • The V8String::WriteUtf8V2 chunked-encode deletion is a net simplification, but it removes surrogate-pair-boundary handling that the single-call path must now get right implicitly; the v8.test.ts pass gives some confidence.

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

I reviewed this PR and didn't find any bugs (the earlier redundant-cast nit was fixed in 9255fac). Because it changes the extern "C" return ABI of TextEncoder__encodeInto8/16 (packed u64 → struct-by-value across Rust/C++) and removes the chunked-encode workaround in V8String::WriteUtf8V2, a human look would still be worthwhile.

What was reviewed:

  • FFI layout: #[repr(C)] EncodeIntoResult { usize, usize } matches TextEncoderEncodeIntoResult { size_t, size_t }; all three C++ call sites updated, no stale declarations remain.
  • usize::MAX sentinel: both copy_latin1_into_utf8_stop_on_non_ascii::<true> callers (RopeStringEncoder append8/write8) updated in lockstep with the producer.
  • jsNumber(size_t) at 2^32 yields an exact double; WriteUtf8 (int-returning) narrowing is bounded by INT_MAX.
Extended reasoning...

Overview

The PR widens EncodeIntoResult.{read,written} from u32 to usize, marks it #[repr(C)], and switches the TextEncoder__encodeInto8/16 FFI from a packed-u64 return to returning the struct by value (mirrored as TextEncoderEncodeIntoResult in headers-handwritten.h). Sixteen files are touched: two producers in bun_core, four FFI-boundary files (Rust exporter + C++ header + two C++ callers), ~10 mechanical as usize cast removals across consumers (fmt, websocket_client, PackageInstall, paths, node_process, TextDecoder/Encoder/EncoderStream, encoding, WindowsWatcher), plus a memory-gated regression test in buffer.test.js. V8String::WriteUtf8V2 also drops its chunked-encode loop, which existed only to work around the packed-u32 wrap.

Security risks

None identified. No user-controlled data flows into new trust boundaries; the change widens an integer type and adjusts an internal FFI signature. The one place that previously relied on a value fitting in 32 bits (WriteUtf8's static_cast<uint32_t>) is bounded by unsigned_length <= INT_MAX, so no new truncation is introduced.

Level of scrutiny

Moderate-to-high. The mechanical cast removals are low-risk (Rust would fail to compile on a type mismatch), but two aspects warrant a maintainer's eye: (1) the extern "C" return-type change from u64 to a 16-byte POD struct — correct on all Bun targets (SysV rax:rdx, Win64 hidden-pointer, AAPCS x0:x1) since Rust's extern "C" follows the platform C ABI, but it's an ABI-boundary change in a core encoding path and rust:check-all only type-checks, it doesn't link or run; (2) deleting the 20-line chunked-encode workaround in WriteUtf8V2 is a real behavior-preserving refactor whose correctness depends on the FFI change being right.

Other factors

  • The u32::MAXusize::MAX sentinel change is consistent: the sole <STOP=true> producer in bun_core/lib.rs and both RopeStringEncoder consumers were updated together; no other <true> callers exist.
  • All prior review threads (my redundant-cast nit, the comment-cop verbosity flags) are resolved; the doc comments were trimmed in 4ed8c69.
  • The new regression test is gated on totalmem() >= 16 GiB (peak ~10.5 GiB RSS), so it will skip on most CI runners — the fix's coverage in CI depends on at least one lane having that much memory.
  • The PR touches bun_core string encoding, which is on the hot path for essentially every string→bytes operation in the runtime; that breadth is why I'm deferring rather than approving.

Pre-fix the wrapped count failed encode16's completeness check and the
2^32-byte result fell into the allocating fallback, whose ArrayBuffer
length cast panics, so encode() aborted the process rather than
miscounting.

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

I reviewed this PR and didn't find any bugs. Because it changes an FFI return signature (packed u64 → struct-by-value) across the Rust/C++ boundary and deletes the chunked-encode workaround in V8String::WriteUtf8V2, a human look would still be worthwhile.

What was reviewed:

  • EncodeIntoResult widening: all producers/consumers updated; the u32::MAX → usize::MAX sentinel in copy_latin1_into_utf8_stop_on_non_ascii::<true> has exactly two callers, both updated.
  • TextEncoder__encodeInto8/16 callers: grepped repo-wide — only the three declared sites (JSTextEncoder.cpp, V8String.cpp ×2) exist; no stale declarations or DOMJIT paths.
  • ABI: Rust #[repr(C)] { usize, usize } matches C++ { size_t, size_t } on all supported 64-bit targets; jsNumber(size_t) picks the correct overload for 2^32.
Extended reasoning...

Overview

Widens EncodeIntoResult.{read,written} from u32 to usize (now #[repr(C)]) so a UTF-16→UTF-8 encode that writes exactly 2^32 bytes reports the correct count instead of wrapping to 0. Twelve of the sixteen files are mechanical as usize cast removals (fmt.rs, unicode.rs, websocket_client.rs, PackageInstall.rs, string_paths.rs, node_process.rs, TextDecoder.rs, TextEncoderStreamEncoder.rs, encoding.rs, WindowsWatcher.rs). The substantive changes are: the struct definition in bun_core/lib.rs; TextEncoder__encodeInto8/16 now returning the struct by value instead of a packed u64 (TextEncoder.rs, headers-handwritten.h, JSTextEncoder.cpp, V8String.cpp); the sentinel u32::MAX → usize::MAX in the STOP-on-non-ASCII path; and V8String::WriteUtf8V2 dropping its chunked-encode loop that existed only to work around the 32-bit-count wrap. A memory-gated regression test is added to buffer.test.js.

Security risks

None identified. No untrusted-input parsing changed, no new allocations sized from external data, no auth/crypto/permission code touched. The change widens integer types, which strictly reduces truncation risk.

Level of scrutiny

Medium-high. The mechanical cast removals are trivial, but the FFI return-type change from u64 (packed) to a 16-byte struct-by-value crosses the Rust/C++ ABI boundary in a hot path used by Buffer.write, TextEncoder.encodeInto, and the V8 String::WriteUtf8 shim. Struct-by-value return is well-defined on SysV x86-64 / Win64 / AArch64 for a two-size_t POD, and the Rust #[repr(C)] layout matches the C++ declaration, but this is the kind of change where a second pair of eyes on the ABI contract is cheap insurance. The WriteUtf8V2 chunked-loop deletion also removes surrogate-pair-boundary handling that is now unnecessary (a single call handles the whole string), which a maintainer should confirm they're comfortable with.

Other factors

  • The one nit from the earlier automated pass (leftover as usize at fmt.rs:587) was fixed in 9255fac, and the comment-cop doc-comment length flags were addressed in 4ed8c69; all threads are resolved.
  • Verified no other callers of TextEncoder__encodeInto8/16 exist (grepped repo-wide) and no other consumers of the usize::MAX sentinel exist beyond the two updated in TextEncoder.rs.
  • The author reports rust:check-all (10 targets), the full buffer/encoding/v8 test suites, and a targeted regression test all pass; the test is properly gated on os.totalmem() >= 16 GiB, drains stdout/stderr concurrently, and asserts a combined object per REVIEW.md conventions.

@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: 3

🤖 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 `@src/jsc/bindings/v8/V8String.cpp`:
- Around line 242-249: Update the UTF-16 path in the V8 string encoding flow
around TextEncoder__encodeInto16 to honor kReplaceInvalidUtf8: retain
replacement-character encoding when set, but emit WTF-8 bytes for unpaired
surrogates when unset. Add coverage for both flag states, including an unpaired
U+D800 case.

In `@test/js/node/buffer.test.js`:
- Around line 4699-4704: Replace the multi-line regression explanation above the
test with a single comment containing the relevant issue URL, preserving the
regression test unchanged.
- Around line 4764-4766: Remove the explicit ten-minute timeout argument from
the resource-heavy test near the spawn/4.3 GB encode comments. Let the test use
the default test timeout, and leave any special scheduling or external timeout
handling to the repository’s resource-test harness.
🪄 Autofix

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: cc438b2e-2191-4ad9-b416-df42a46865d3

📥 Commits

Reviewing files that changed from the base of the PR and between 9008ae7 and 3e38026.

📒 Files selected for processing (16)
  • src/bun_core/fmt.rs
  • src/bun_core/lib.rs
  • src/bun_core/string/immutable/unicode.rs
  • src/http_jsc/websocket_client.rs
  • src/install/PackageInstall.rs
  • src/jsc/bindings/headers-handwritten.h
  • src/jsc/bindings/v8/V8String.cpp
  • src/jsc/bindings/webcore/JSTextEncoder.cpp
  • src/paths/string_paths.rs
  • src/runtime/node/node_process.rs
  • src/runtime/webcore/TextDecoder.rs
  • src/runtime/webcore/TextEncoder.rs
  • src/runtime/webcore/TextEncoderStreamEncoder.rs
  • src/runtime/webcore/encoding.rs
  • src/watcher/WindowsWatcher.rs
  • test/js/node/buffer.test.js

Comment thread src/jsc/bindings/v8/V8String.cpp
Comment thread test/js/node/buffer.test.js Outdated
Comment thread test/js/node/buffer.test.js

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

I reviewed this PR and didn't find any bugs. Because it changes an FFI ABI boundary (TextEncoder__encodeInto8/16 now return a #[repr(C)] struct by value instead of a packed u64) across Rust↔C++ in a hot encoding path, a human look would still be worthwhile.

What was reviewed:

  • EncodeIntoResult widening to usize and all producer/consumer sites — the usize::MAX sentinel change in copy_latin1_into_utf8_stop_on_non_ascii::<true> has exactly two callers (both updated in RopeStringEncoder).
  • Struct-by-value return ABI: Rust #[repr(C)] { usize, usize } matches C++ { size_t, size_t }; all three C++ call sites updated, no stale extern "C" size_t declarations remain.
  • V8String.cpp chunk-loop removal — same encoder call, surrogate handling unchanged (chunking is gone so the split-pair guard is no longer needed).
  • jsNumber(size_t) for encodeInto result — 2^32 is exact as a double.
Extended reasoning...

Overview

This PR widens EncodeIntoResult.{read, written} from u32 to usize and makes the struct #[repr(C)] so TextEncoder__encodeInto8/16 can return it by value across the Rust↔C++ FFI instead of packing two u32s into a u64. The motivation is that a UTF-16→UTF-8 encode can produce exactly 2^32 bytes (JSC's max ArrayBuffer size), which wraps a u32 to 0 — causing Buffer.write/encodeInto to report 0 bytes written and TextEncoder.encode to abort. Sixteen files are touched: the core type change (lib.rs, unicode.rs), the FFI wrappers (TextEncoder.rs, headers-handwritten.h), three C++ callers (JSTextEncoder.cpp, V8String.cpp), nine mechanical as usize cast removals, and a new memory-gated regression test.

Security risks

None identified. This is a count-width fix in encoding output reporting; no input validation, auth, or trust boundary is touched. The widening is strictly more permissive on the return side and cannot introduce truncation.

Level of scrutiny

Medium-high. The mechanical cast removals are trivially safe (Rust would reject a wrong-typed slice index). The substantive risk is the FFI ABI change: extern "C" functions now return a 16-byte POD struct by value instead of a u64. This is a well-established pattern in the codebase (e.g. BunString, SystemError are passed/returned by value), the struct is #[repr(C)] with matching field types/order on both sides, and rust:check-all passed all 10 target/platform combos. But it is a hot path (TextEncoder is used everywhere), and struct-return ABI differs across SysV/Win64/AArch64, so a maintainer glance at the ABI shape is warranted.

Other factors

  • The V8String.cpp WriteUtf8V2 refactor removes a ~20-line chunking loop that existed solely to work around the u32 wrap. The replacement is a single call with identical semantics; the surrogate-pair-split guard is correctly dropped since there's no chunk boundary anymore. CodeRabbit raised and withdrew a kReplaceInvalidUtf8 concern here (pre-existing behavior, unchanged).
  • The usize::MAX sentinel change for copy_latin1_into_utf8_stop_on_non_ascii::<true> was checked: only two callers exist (RopeStringEncoder::append8/write8), both updated.
  • My prior nit (leftover as usize cast at fmt.rs:587) was fixed in 9255fac; comment-cop's verbosity flags were addressed in 4ed8c69.
  • CodeRabbit's two remaining resolved comments (issue-URL link, per-test timeout) are style nits; the 10-minute timeout is justified by the comment (two sequential ~4 GiB encode passes under debug+ASAN) and follows the pattern of the neighboring MAX_LENGTH test in the same file.
  • Test coverage is thorough: fails-without/passes-with evidence in the description, plus full buffer.test.js/encoding/v8.test.ts runs.

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. The remaining failures in build 90922 are unrelated to this change: test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts is a pre-existing failure also present on main (unchecked exception in JSC__JSModuleLoader__loadAndEvaluateModule on the x64-asan lane), and the other seven entries are marked flaky (each passed on retry or when run alone). The new 2^32 count test passes on the lanes with enough memory to run it, and the targeted fail-before/pass-after evidence is in the PR description. Ready for review.

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.

1 participant