fetch, streams: drop one leading UTF-8 BOM in text() and json() of string and stream bodies
Problem
text()keeps a leading byte order mark (U+FEFF) for some carriers.json()then rejects withSyntaxError: JSON Parse error: Unrecognized token.- Carriers: a string, an async iterable, a
node:streamReadable, a directReadableStream. AUint8Arrayor aBlobof the same bytes parses. Any::to_string/Any::to_json(Blob.rs) return a string body with no decode.finishTextSink(JSDirectStreamController.cpp) keeps the mark since 70e57a7da0 (#7391, 1.0.15).
Fix
- A string body that starts with U+FEFF gives
text()andjson()the code units after it. finishTextAccumulatoris the finisher of both text sinks.finishTextSink,withoutUTF8BOMandstripTextResultBOMare removed.- Each byte exit ends in
utf8Decode. It skips one leading EF BB BF, then decodes as before. - Verified:
test/js/web/fetch/utf8-bom.test.ts(68 of 142 new tests fail on main),streams.test.js. Self-reviewed: 10 concerns raised, 9 addressed. Not done: a restack on #44269 (Notes).
Background
- The text of a body is the UTF-8 decode of its bytes, which drops one leading EF BB BF.
- A direct stream gives each
controller.write()chunk to a sink. Async iterables and node Readables use one. - Considered a check at construction (#33656): a pass over each 16-bit string body, and 1 of 5 carriers.
Downsides
- String chunks change:
["\uFEFF", "\uFEFFabc"]gives"\uFEFFabc"(was"abc"), and a mark after bytes stays. - Per call (gdb
stepi): string bodytext()+3 instructions (8-bit) or +9 (16-bit), direct streamtext()+37..textis 3,072 bytes smaller. - Lone surrogates do not change. A string body with a leading U+FEFF is copied: 1.5 ms per 8M code units.
Notes
Repro. The bytes are EF BB BF and {"ok":true}.
import { Readable } from "node:stream";
const bytes = () => new Uint8Array([0xef, 0xbb, 0xbf, ...Buffer.from('{"ok":true}')]);
const carriers = [
"\uFEFF" + '{"ok":true}',
(async function* () { yield bytes(); })(),
{ async *[Symbol.asyncIterator]() { yield bytes(); } },
Readable.from([bytes()]),
new ReadableStream({ type: "direct", pull(c) { c.write(bytes()); c.close(); } }),
];
for (const body of carriers) console.log(await new Response(body).json().catch(e => e.name));
// main: SyntaxError x5. This PR and node v26.3.0 (first four): { ok: true }
Found by a differential run against Node. No user issue reports these carriers. The one report in this area is #4876 (a fetched byte body), which #7391 answered.
The rule. text() equals new TextDecoder().decode(bytes of the same body), and json() parses that text. Release builds, linux x64, main f14a5a684f against this branch:
| Check | main | this PR |
|---|---|---|
| the five carriers above that keep the mark | 5 of 5 | 0 of 5 |
25 body shapes where text() differs from one decode of bytes() | 15 | 4 (lone surrogates) |
| 19 public readers of a marked byte body that keep the mark | 13 | 0 |
| 896 cells of 8 carriers, 14 documents and up to 11 readers | 324 wrong | 12 wrong |
The 12 cells are textStream() of a default stream with string chunks. It rejects with TypeError on both builds and is not a BOM matter.
Readers that change: Response / Request text() and json() over a string, an async iterable, a node Readable or a direct stream, textStream() of a string body, ReadableStream.prototype.text() / json() of a direct stream, Bun.readableStreamToText / Bun.readableStreamToJSON of a direct stream, and node:stream/consumers text() / json() of a direct web stream. A marked string body already gave the right text after clone(), .body or new Request(request). It now gives it before them too.
Behavior changes for string chunks of a stream. String chunks are a Bun extension (Node throws TypeError). Two rows of streams.test.js from the C++ streams rewrite (#33193) pinned the old text. They move:
| Chunks | main | this PR |
|---|---|---|
default stream: "\uFEFF", "\uFEFFabc" | "abc" | "\uFEFFabc" |
bytes ab, "\uFEFFcd" | "abcd" | "ab\uFEFFcd" |
default stream: bytes EF BB BF, "\uFEFFb" | "b" | "\uFEFFb" |
TextDecoderStream("utf-8", { ignoreBOM: true }) over EF BB BF EF BB BF ab, then .text() | "ab" | "\uFEFFab" |
Both extra strips came from 70e57a7da0: a strip in the string arm, a second strip after the default-stream sink, and a strip of the trailing string run wherever it was. A program that reads text() and serves it again no longer passes a leading mark through. A test that asserts a leading U+FEFF from an in-process res.text() of these carriers now fails.
Not covered.
- Lone surrogates. A string body and a string chunk keep them, with or without a mark.
bytes()gives U+FFFD. A surrogate pair split across two string chunks is joined. The tests hold rows namedexception:for them. To replace them needs a pass over every 16-bit text that is zero-copy today. - A
Blobbody that starts with FF FE is read as UTF-16LE (#35190). formData()drops the mark where the Fetch standard keeps it (#33631).node:stream/consumerstext()of a web stream with string chunks: Node appends a string chunk as it is. 3 of 9 probed rows differ from node v26.3.0 (5 of 9 on main). The new tests compare this reader for byte chunks only.- A producer that writes the same buffer again before
close(): the direct text sink reads byte chunks at the end, as on main. Bun.$.json()of output that starts with a mark.- The two write functions of the text sinks (
writeToTextSink,textAccumulatorWrite) stay two copies.
Open PRs in the same functions. None has the same change. Each needs a rebase when it lands second.
- #44269 replaces the all-strings join of
convertChunksToTextand keeps the two-mark strip of main. Four of its new test rows pin"abc"for two marks. The one that lands second makes the join skip at most one mark and pins those rows again. This is the one behavior decision a maintainer must make. - #44181 edits
finishTextSink, which this PR removes, and both write functions. Its changes move tofinishTextAccumulator. - #40584 edits
appendChunkBytesand the chunk loop offinishTextSink. - #33631 and #35190 overlap in
utf8-bom.test.tsonly.
Measurements. Release builds of f14a5a684f and of this branch. perf, valgrind, bloaty and strace are not installed here. Per-function counts are gdb single steps from entry to return, callees included, after 20 warm calls.
| Call | main | this PR |
|---|---|---|
string body text(), 8-bit, at 64 / 1,024 / 65,536 units | 545 | 548 |
string body text(), 16-bit, same sizes | 545 | 554 |
string body json(), 8-bit / 16-bit, 1,024 units | 2,160 / 3,238 | 2,163 / 3,247 |
new Response(str16) (Value::from_js), 1,024 and 65,536 units | 132 | 132 |
bytes() of a 16-bit string body, 1,024 units | 4,034 | 4,034 |
| default stream, one string chunk, 8-bit / 16-bit | 122 / 128 | 97 / 102 |
| default stream, 8 string chunks, 8-bit / 16-bit | 1,903 / 2,812 | 1,890 / 2,790 |
| default stream, one 1 KiB buffer | 643 | 651 |
| default stream, mixed strings and bytes, 8-bit / 16-bit | 2,791 / 41,486 | 2,763 / 41,413 |
| direct stream close, ASCII bytes 64 B / 1 KiB / 64 KiB | 1,058 / 1,440 / 18,650 | 1,095 / 1,477 / 18,687 |
| direct stream close, 4 KiB of CJK bytes | 72,757 | 72,794 |
| direct stream close, string chunks only, 8-bit / 16-bit | 831 / 834 | 844 / 849 |
| marked: direct stream, 1 KiB ASCII bytes after the mark | 20,816 | 1,560 |
| marked: default stream, one 1 KiB buffer | 20,354 | 734 |
marked: 16-bit string body text(), 1,024 / 65,536 units | 545 (mark kept) | 978 / 7,680 |
- The direct rows are
JSDirectStreamController::onClose, because the compiler inlinesendTextSinkin this build. - Allocation calls inside the measured call: unmarked string body
text()0 and 0, direct byte body 3 and 3, default single buffer 2 and 2, marked 16-bit string body 0 and 4. - A whole
new Response(directStream).text()call is about 14,000 instructions, so +37 is 0.3%. - Binary:
.text106,386,211 to 106,383,139 bytes (size).endTextSink(3,087 bytes) andstripTextResultBOM(188) are gone.convertChunksToTextgoes from 8,150 to 4,575. New:finishTextAccumulator1,976,utf8Decode604,textOfString242. - Microtask turns until
text()settles (string, default stream, direct stream, async generator): 1, 1, 4, 6 on both builds. - Ill-formed UTF-8: 333,450 byte sequences through a direct stream and a default stream, alone and after a string chunk. The text is the same on both builds and equal to one
TextDecoderdecode. - A one-chunk body over a
SharedArrayBufferthat a worker rewrites: both builds survive 3 of 3 runs. - Wall clock, a local form of
bench/snippets/webstreams-consumers.mjswith 16-bit and direct rows added, 36 cells, 25 interleaved pairs: the A/A noise floor is 7.3%, the A/B deltas are -7.4% to +16.6% with a median of +0.5%. The three cells at the edge of the floor execute the same instruction count on both builds. bench/snippets/react-dom-render.bun.jsdid not run:mitatais not installed here and the installedreact-domhas no Bun server build. A local loop overreact-dom/server.browserandnew Response(stream).text()executes 114,000 to 130,000 instructions per call on main and 115,000 to 118,000 on this branch.- A marked string body under an address-space limit:
text()of 192M code units under a 3 GB limit rejects withRangeError: Failed to allocate memory. main resolves with the mark kept.
Designs weighed. A review compared six designs before the code was written.
- A check when the body is constructed (#33656, my earlier attempt, closed by a stale sweep): one pass over each 16-bit string body, also for bodies that nothing reads as text, and 1 of 5 carriers.
- A strip in
finishTextSinkonly: the two finishers then give different text for bytes followed by a marked string, and 8 of 25 rows stay wrong. - Every chunk converted to bytes at write time: correct for each row, but each 16-bit string loses the zero-copy path.
- A UTF-16 validity pass to replace lone surrogates: it adds a pass to each 16-bit
text(). - The decoder of
textStream()for the byte exits: about 3 times faster for valid non-ASCII bytes, but it reads its input twice. With it, a one-chunk body over aSharedArrayBufferthat a worker rewrites crashed 5 of 5 runs. The review also measured a body with one invalid byte about 5 times slower. I did not repeat that run. This PR keeps the decoder that main uses. #41574 owns the shared-memory problem of that decoder.
Self-review. Ten concerns. Nine are addressed:
- The byte exits used a decoder that reads its input twice. Now: the decoder of main behind a three-byte skip.
- A marked string was encoded and decoded again (12 ms per 8M units, and an abort under a memory limit). Now: it drops one code unit.
- The decoder change was a second change with its own speed profile. Removed.
- Out-of-memory behavior changed without a statement. Now: the stream exits do not change, and the one new copy reports an error.
- Comments and the title said more than the code does. Narrowed.
- The test matrix had no documents with invalid bytes and did not read
bytes(). Added. - The tests pinned
node:stream/consumersfor string chunks, where Node differs. Now byte chunks only. - The
BlobFF FE rule is named as not covered. - The body names the open PRs in the same functions and the missing user report.
- Not done: a restack on top of #44269 before this PR opens. #44269 and #44181 have had no maintainer review since October 2. The section above says what the second to land must change.
Verification. bun bd test (debug, ASAN): utf8-bom.test.ts 165 pass, streams.test.js 700 pass and 2 skip, body.test.ts, blob.test.ts, client-fetch.test.ts, streams-string-limit.test.ts, readablestream-helpers.test.ts, and test/js/node/test/parallel/test-stream-consumers.js. The two changed test files also pass with BUN_JSC_validateExceptionChecks=1. On the release build of main, 68 of the 142 new tests of utf8-bom.test.ts fail, and 4 of the 14 new or changed tests of streams.test.js.
no test proof · iteration 12 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js