Repository navigation
TextDecoder: throw when the result cannot be allocated or is too long - #47278
Conversation
`decode()` of all-ASCII bytes aborted the process when the string could not
be allocated (released versions returned "" instead), and returned "" for
2^31 bytes or more. It now throws ERR_MEMORY_ALLOCATION_FAILED or
ERR_STRING_TOO_LONG, as it already did for input with a non-ASCII byte.
The other allocations in `decode()` aborted on the same refusal and now throw
too: the join with bytes carried from a `{stream: true}` chunk, windows-1252
with a non-ASCII byte, and UTF-16.
A `{stream: true}` chunk that fails this way keeps what UTF-8 and UTF-16
carried from earlier chunks, so it can be decoded again. A failed flush still
ends the stream.
Co-authored-by: dylanc <dylanc@anthropic.com>
|
Updated 1:53 AM PT - Oct 29th, 2026
✅ @claude, your commit b6b0de92cb2fd43c1456ccc7049312ce4dc22bb3 passed in 🧪 To try this PR locally: bunx bun-pr 47278That installs a local version of the PR into your bun-47278 --bun |
|
Important Review skippedReview was skipped as selected files did not have any reviewable changes.
|
| Layer / File(s) | Summary |
|---|---|
Fallible Latin-1 string creation src/jsc/JSString.rs, src/jsc/bindings/bindings.cpp, src/jsc/bindgen.rs, docs/project/bindgen.mdx |
JavaScriptCore adds single-character creation and fallible Latin-1 allocation. Bindgen adds try_create_latin1_string, which returns errors for excessive length or allocation failure. The documentation describes the distinction between fallible and process-ending allocation paths. |
Fallible decoding and decoder state src/runtime/webcore/TextDecoder.rs |
TextDecoder uses fallible reservations and string creation across UTF-8, UTF-16, Latin-1, and CP1252 decoding. It updates buffered bytes, surrogate, and BOM state around errors and stream boundaries. |
Allocation-failure and size-limit tests test/js/node/buffer-oom.test.ts, test/js/web/encoding/text-decoder.test.js |
Tests cover allocation failures during decoding and streaming, plus oversized decode results across encodings. They also check decoder behavior after failures and at the configured size limit. |
Suggested reviewers: jarred-sumner, robobun
Priority:
Merge Risk: 
Minimal · previous review 0e999
-
No concrete merge-blocking issue is established by the supplied changes. Merge readiness remains subject to normal build and test checks.
Pre-merge checks: 
4
-
Full details: Title check
-
Explanation
The title names the main change: TextDecoder.decode() now throws when the result cannot be allocated or exceeds the maximum length, instead of returning an empty string or aborting. The changeset matches this. The title is specific and accurate, and it does not cover the UTF-16, stream-join, and windows-1252 changes, which is acceptable for a title.
Full details: Description check
-
Explanation
The description follows the required template. It has the 'What does this PR do?' section, which explains the problem, the root cause, and the fix. It also has the 'How did you verify your code works?' section, which lists new tests, existing test suites, manual checks, and performance measurements. It adds a root-cause section, a note on decoder state after errors, and a list of items out of scope. These extra sections are useful for review. The description is complete and specific.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
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 @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the UTF-8 arm's new unconditional self.buffered = pending write (the old code gated it on !FLUSH): to_utf16_alloc_maybe_buffered::<_, true> only ever returns a leftover length of 0, so a flush still carries nothing into the next stream. The UTF-8 fatal-error-after-OOM-on-stream concern was also examined and ruled out, since buffered is left untouched on the allocation error paths by design.
Extended reasoning...
The change replaces an abort in TextDecoder's Latin1/UTF-8/UTF-16 allocation paths with catchable ERR_MEMORY_ALLOCATION_FAILED / ERR_STRING_TOO_LONG errors, adds fallible string-creation helpers in bindgen.rs/JSString.rs/bindings.cpp, and reorders when stream state is committed; it touches no auth, injection, or data-exposure surface. Three inline findings (utf-16 error-code inconsistency at the 2^31 boundary, a measured utf-16 short-input regression, and stale lead_surrogate state after a fatal utf-16 error) already signal that a human look is needed, so this note only records what else was checked.
|
The three failures in build 134770 so far are not from this change. All are in
This diff touches nothing under There is no fix to bring in: none of the commits on Generated by Claude Code |
…cii-alloc-bug-wm16yt Co-authored-by: dylanc <dylanc@anthropic.com>
…cii-alloc-bug-wm16yt Co-authored-by: dylanc <dylanc@anthropic.com>
|
Build 134878 is red only because its eight macOS All 180 jobs that ran passed: Linux glibc and musl on x64 and aarch64, ASAN, hardened, Generated by Claude Code |
What does this PR do?
new TextDecoder().decode(bytes)loses data or kills the process when the result has no room, if the bytes are all ASCII. With one non-ASCII byte in the input the same call already throws.main""abort()ERR_MEMORY_ALLOCATION_FAILED""""ERR_STRING_TOO_LONGA reader that joins decoded chunks loses a piece in silence. Repro (Linux,
sh -c 'ulimit -v 4194304; bun min.js'):Root cause
JsHeap::create_latin1_stringis infallible by its type, and both ASCII roads ofTextDecoder(utf-8 and latin1/windows-1252) use it.JSC__JSString__createUninitializedLatin1, which hadRELEASE_ASSERT(text)on atryCreateUninitializedthat can return null.EncodedSlice::to_js, where a nullWTF::Stringbecomes"".The fix
bindings.cpp: theRELEASE_ASSERTis gone. The function, nowJSC__JSString__tryCreateUninitializedLatin1, returns an empty value when the allocation is refused.bindgen.rs: newJsHeap::try_create_latin1_string, whose error isERR_STRING_TOO_LONGorERR_MEMORY_ALLOCATION_FAILED. It shares one copy routine withcreate_latin1_string, which stays infallible for its other callers (out of memory there now goes throughbun_core::out_of_memory()rather than an assert).TextDecoder.rs: the two ASCII call sites use it. These two lines are the fix for the report.The same crash in the rest of
decode()Three more allocations in the same function aborted on the same refusal (
memory allocation of N bytes failed, on 1.4.4 too). They now throwERR_MEMORY_ALLOCATION_FAILED:{stream: true}chunk,The decoder after such an error
These error paths are new, so what they leave behind is new too:
{stream: true}chunk that throws keeps what UTF-8 and UTF-16 carried from earlier chunks (partial character, BOM seen), so the same chunk can be decoded again.encoding_rsarm is unchanged.#[inline(never)]ondecode_utf16decode_with_encoding_rsalready has it. Inlined, the UTF-16 loop and the UTF-8 arm are one function of about 17 KB, and while working on this, edits to one kept changing the speed of the other (measured without LTO): a change to the UTF-16 capacity arithmetic alone cost a 16-byte UTF-8 decode 9%, and the UTF-16 loop ran 11 to 28% slower per unit depending on the code around it. Out of line, both effects are gone.Not in this PR
new TextDecoderStream()(utf-8, not fatal) has its own C++ road,streamingUTF8Decode. It already throws in these cases, but for an over-long chunk or a failed join it is a plainRangeError: Out of memorywith nocode, where the other streams now give the two codes above.node:pathpasses strings of any length to the infalliblecreate_latin1_string, so it still ends the process when out of memory.fatalUTF-16 decoder that rejects a{stream: true}chunk goes on from that chunk's last odd byte or lead surrogate, so it can garble or reject a valid next chunk:[00 dc 41]throws, then[42 00]gives U+4241. UTF-8 and theencoding_rsarm drop what was pending.mainand 1.4.4 do the same as this PR here.How did you verify your code works?
New tests. All three fail on
mainand on 1.4.4, and pass with this PR on a debug+ASAN build and on a release build.test/js/node/buffer-oom.test.ts, "TextDecoder reports an allocation that fails": every encoding arm,TextDecoderStream, and the decoder's state afterwards. What refuses the allocation depends on the build, so that each CI lane runs what it can:ulimit -v. Address space is used up with pages that are never written, so the child takes no memory to speak of and well under a second.max_allocation_size_mb, for what Rust allocates. The input is aWebAssembly.Memory, which is mapped rather than malloc'ed, so it can be longer than the cap.BUN_JSC_maxSingleAllocationSize, for what WTF allocates.test/js/web/encoding/text-decoder.test.js, "throws for more characters than a string can hold…": the synthetic limit, in a child process.Existing tests. On both builds:
test/js/web/encoding,test/js/node/fs/fs-oom.test.ts. Debug only, where it is not skipped:test/internal/bindgen.test.ts. Release only:test/js/node/path,test/js/node/string_decoder,test/js/web/streams/streams.test.js.By hand
{ stream: true }, latin1 and UTF-16: all end incatch. 2^31 − 1 bytes still decode.BUN_JSC_validateExceptionChecks=1is clean on the new error paths.decode()calls in 96,000 rounds (91,337 of them distinct) on random chunkings of BOMs, split surrogates and partial sequences (4 encodings ×fatal×ignoreBOM× randomstream) answer the same onmain, 1.4.4 and this PR, round for round. The debug build asserts that the UTF-16 reservation is never outgrown, and it did not fire in that run.Performance.
mainagainst this PR at the same commit, both built from source with ThinLTO (no PGO). ns perdecode(), median of 10 runs in random order, pinned to one core,useConcurrentGC=0to take GC thread scheduling out of the measurement.mainVec::with_capacityput back in the same structure the cost is the same, andVec::try_with_capacityrecovers 1.4 ns. Nor is it the size of the allocation.main.main.bench/snippets/text-decoder.mjsshows no consistent difference: two runs of the same binary differ by more than the two builds do.node:pathis unchanged, except that an answer of one character (path.normalize("/a/..")) is about 35% faster (measured without LTO).Generated by Claude Code