Decode stream bodies once instead of per chunk - #10877
Conversation
streamToString called toString() on every chunk, so a UTF-8 character split across a chunk boundary decoded as replacement characters. Buffer the chunks and decode once at the end.
There was a problem hiding this comment.
Code Review
This pull request fixes an issue in streamToString where multi-byte UTF-8 characters spanning across stream chunk boundaries were corrupted during decoding. The fix buffers raw chunks and decodes them all at once at the end. A unit test was also added to verify this behavior. The reviewer pointed out a potential issue where Uint8Array chunks (common in modern Node.js) would fail the Buffer.isBuffer check and be incorrectly converted to a comma-separated string of numbers via String(d), leading to corruption. A robust type-checking approach was suggested to handle Uint8Array, string, and other types safely.
| const chunks: Buffer[] = []; | ||
| s.on("error", reject); | ||
| s.on("data", (d: Buffer | string) => { | ||
| b += d.toString(); | ||
| // Buffer the raw chunks and decode once at the end. Decoding each chunk | ||
| // on its own corrupts any multi-byte character that straddles a chunk | ||
| // boundary, turning it into replacement characters. | ||
| chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d), "utf8")); | ||
| }); |
There was a problem hiding this comment.
If the stream emits Uint8Array chunks (which is common in modern Node.js and web-compatible streams), Buffer.isBuffer(d) will return false. This falls back to Buffer.from(String(d), "utf8"). Calling String() on a Uint8Array converts it to a comma-separated string of numbers (e.g., "104,101,108,108,111"), which will corrupt the decoded string.
Since Buffer.concat natively accepts both Buffer and Uint8Array instances, we can safely push them directly. We should explicitly check for Uint8Array (which also covers Buffer since Buffer inherits from Uint8Array in Node.js) and string to handle all cases correctly.
const chunks: Uint8Array[] = [];
s.on("error", reject);
s.on("data", (d: Buffer | string | Uint8Array) => {
// Buffer the raw chunks and decode once at the end. Decoding each chunk
// on its own corrupts any multi-byte character that straddles a chunk
// boundary, turning it into replacement characters.
if (d instanceof Uint8Array) {
chunks.push(d);
} else if (typeof d === "string") {
chunks.push(Buffer.from(d, "utf8"));
} else {
chunks.push(Buffer.from(String(d), "utf8"));
}
});
Description
streamToStringdecodes each chunk on its own:A UTF-8 character that straddles a chunk boundary is therefore split across two independent
toString()calls, and each half decodes to replacement characters. Code points, so the difference is unambiguous:caféé(2 bytes)U+FFFD U+FFFDinstead ofU+00E9a中b中(3 bytes)U+FFFD U+FFFD U+FFFDinstead ofU+4E2Dhi😀thereU+FFFD U+FFFD U+FFFDinstead ofU+1F600Input that happens to be split on a character boundary, or that is pure ASCII, is unaffected — which is why the existing tests (
"hello world",undefined,null) pass.This buffers the chunks and decodes once at the end.
No known user-visible impact today
Worth being explicit, since it affects how you'd prioritise this. I could not reach the bug through a real command, and I looked:
responseType: "json"path callsres.text()→streamToString, but only when the undici branch is taken, which requirescompress: false.compress: false—hosting/initMiddleware.tsandhosting/proxy.ts— useresponseType: "stream"and pipe the body, so they never calltext().database-get.ts,downloadUtils.ts,profiler.tsandemulator/hubExport.tsall read the body only behindstatus >= 400, and error bodies are normally small enough to arrive in one chunk.So this is a latent bug in a shared helper (also re-exported from
utils.ts) rather than something failing in the field. It would bite the first caller that reads a large non-ASCII body.Verification
Beyond the new unit test, I checked the behaviour against a real undici response: a local server that writes a body in two
write()calls split insideé. undici delivered two data events ([20, 15]), andstreamToStringreturned the corrupted string before this change and the correct one after.Also covered, all passing: an empty stream, a single chunk, a stream with
setEncoding("utf8")(so chunks arrive as strings), every byte pushed as its own chunk, anderrorevents still rejecting. I ran the split-character case 20 times to confirm it isn't timing dependent.Buffer.isBuffer(d) ? d : Buffer.from(String(d), "utf8")keeps the previous behaviour for anything that isn't a Buffer, so an object-mode stream still stringifies rather than throwing inBuffer.concat.That one failure is pre-existing and unrelated — a timezone-dependent assertion (
expected '2020-02-23 00:05:45' to match /^2020-02-22 \d\d:35:45$/); I'm on UTC+5:30. It fails identically on an unmodified checkout. Withsrc/streamUtils.tsreverted the new test fails, so it does pin the fix.npm run test:compileis clean, andeslintreports no errors on either changed file.