Skip to content

node:stream / string_decoder: multi-byte characters split across chunks are corrupted, 'drain' never fires, ReadableStream is not async-iterable #51

Closed

Description

@hassidan

Test I ran

node streams.ts
slice run streams.ts
streams.ts
import { StringDecoder } from "node:string_decoder";
import { Readable, Writable } from "node:stream";
const show = (k: string, v: unknown) => console.log(k.padEnd(46) + JSON.stringify(v));
const withTimeout = <T>(p: Promise<T>, ms = 1500) => Promise.race([p, new Promise<string>((r) => setTimeout(() => r("NEVER SETTLED (" + ms + " ms)"), ms))]);
(async () => {
  const euro = Buffer.from("€"); // e2 82 ac
  const d = new StringDecoder("utf8");
  show("StringDecoder split '€' over 2 writes", [d.write(euro.subarray(0, 1)), d.write(euro.subarray(1)), d.end()]);
  const r = new Readable({ read() {} }); r.setEncoding("utf8"); r.push(euro.subarray(0, 2)); r.push(euro.subarray(2)); r.push(null);
  let s = ""; for await (const c of r) s += c; show("Readable.setEncoding('utf8') split '€'", s);
  show("Writable 'drain' after write() returned false", await withTimeout(new Promise((res) => { const w = new Writable({ highWaterMark: 2, write(_c, _e, cb) { setTimeout(cb, 1); } }); const ret = w.write("abc"); w.once("drain", () => res("drain fired; write() returned " + ret)); })));
  show("for await (chunk of ReadableStream)", await withTimeout((async () => { const rs = new ReadableStream({ start(c) { c.enqueue("a"); c.enqueue("b"); c.close(); } }); const out: string[] = []; try { for await (const ch of rs as any) out.push(ch); return out; } catch (e: any) { return "THROWS " + e.name + ": " + e.message; } })()));
  show("ReadableStream.tee() destructuring", await withTimeout((async () => { try { const [x, y] = new ReadableStream({ start(c) { c.enqueue(1); c.close(); } }).tee(); return [(await x.getReader().read()).value, (await y.getReader().read()).value]; } catch (e: any) { return "THROWS " + e.name + ": " + e.message; } })()));
  show("Readable.toWeb(Readable.from([buf])) text", await withTimeout((async () => { try { return await new Response(Readable.toWeb(Readable.from([Buffer.from("w")])) as any).text(); } catch (e: any) { return "THROWS " + e.name + ": " + e.message; } })()));
})();

Result

Node v24.22.2:

StringDecoder split '€' over 2 writes         ["","€",""]
Readable.setEncoding('utf8') split '€'        "€"
Writable 'drain' after write() returned false "drain fired; write() returned false"
for await (chunk of ReadableStream)           ["a","b"]
ReadableStream.tee() destructuring            [1,1]
Readable.toWeb(Readable.from([buf])) text     "w"

Slice v0.10.7:

StringDecoder split '€' over 2 writes         ["�","��",""]
Readable.setEncoding('utf8') split '€'        "��"
Writable 'drain' after write() returned false "NEVER SETTLED (1500 ms)"
for await (chunk of ReadableStream)           "THROWS TypeError: value with type `object` is not iterable"
ReadableStream.tee() destructuring            [1,1]
Readable.toWeb(Readable.from([buf])) text     ""

What is wrong

  1. StringDecoder does not buffer incomplete UTF-8 sequences. € (e2 82 ac) written as two chunks yields U+FFFD characters instead of "" then "€". Readable#setEncoding("utf8") has the same defect, so any text stream whose chunk boundary falls inside a multi-byte character is corrupted (HTTP bodies, file streams, child process output).
  2. Writable never emits 'drain' after write() returned false. Code following the standard back-pressure pattern (if (!w.write(x)) await once(w, "drain")) waits forever; under slice run the process then exits 0 with no output because the event loop is empty.
  3. for await (const chunk of readableStream) throws TypeError: value with type 'object' is not iterable. COMPATIBILITY.md lists "for await...of async iteration" for Streams.
  4. Readable.toWeb() produces a stream that yields no data ("" instead of "w"). Readable.toWeb is listed as supported.

StringDecoder (write, end) and the Readable / Writable classes are listed as supported in the matrix.

Environment

  • Slice v0.10.7, commit ea7c70c, built with cargo +1.96.0 build --release --locked --bin slice
  • Reference: Node.js v24.22.2 (same script, same machine)
  • Ubuntu 26.04, Linux 7.0, x86_64
  • The same output is produced by the v0.10.5 tag, so this is not a regression in 0.10.6/0.10.7.

Activity

  1. siktec-lab commented on Nov 11, 2026

    @siktec-lab
    Collaborator

    Addressed in #66 (v0.10.10):

    • StringDecoder (node:string_decoder) and Readable.prototype.setEncoding now buffer incomplete multi-byte UTF-8 (2/3/4-byte codepoints), UTF-16LE (odd byte boundaries and surrogate pairs), and Base64 chunks across .write() calls and flush remaining bytes on .end().
    • Writable tracks writableLength against writableHighWaterMark, returns false from .write() when the buffer reaches highWaterMark, and emits 'drain' once pending writes complete.
    • Web ReadableStream now implements [Symbol.asyncIterator] and .values(), and queues pending reader.read() / Response.prototype.text() consumers for async push streams (including Readable.toWeb(Readable.from(...))).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions