Summary
Unawaited writer.write() chunks on a TransformStream are silently dropped when writer.close() is called in the same synchronous run: the close path closes the readable side before the queued transform jobs deliver their chunks, so the chunks hit a Closed readable and are discarded. Node delivers them.
Reproduces identically on a pristine main build (found while validating #6602 — unrelated to id recycling).
Repro
async function main() {
const ts = new TransformStream();
const writer = ts.writable.getWriter();
writer.write("hello");
writer.write(" world");
writer.close();
let out = "";
for await (const chunk of ts.readable) out += chunk;
console.log("transform:", out);
}
main();
- Node:
transform: hello world
- Perry:
transform: (empty)
Mechanism
transform_write (streams/transform.rs) queues the transformer invocation as a two-hop microtask job (transform_write_job → transform_write_job2) but bypasses the writable's write_queue. writer.close() → js_writer_close → transform_close runs synchronously, and with no user flush() promise to defer on, finish_transform_close calls js_readable_stream_controller_close(readable) immediately — before the queued write jobs run. When transform_write_job2 finally executes, controller_enqueue sees state == Closed and drops the chunk (the write promise still resolves, so the loss is silent).
Per WHATWG, TransformStreamDefaultSinkCloseAlgorithm only runs after queued writes complete — the sink close should chain behind the pending write jobs (or the transform write path should flow through the writable queue that finish_writable_close already waits on).
Awaiting each writer.write() avoids the drop, which is why pipeTo-driven paths (Next.js SSR) don't hit this.
Summary
Unawaited
writer.write()chunks on aTransformStreamare silently dropped whenwriter.close()is called in the same synchronous run: the close path closes the readable side before the queued transform jobs deliver their chunks, so the chunks hit aClosedreadable and are discarded. Node delivers them.Reproduces identically on a pristine
mainbuild (found while validating #6602 — unrelated to id recycling).Repro
transform: hello worldtransform:(empty)Mechanism
transform_write(streams/transform.rs) queues the transformer invocation as a two-hop microtask job (transform_write_job→transform_write_job2) but bypasses the writable'swrite_queue.writer.close()→js_writer_close→transform_closeruns synchronously, and with no userflush()promise to defer on,finish_transform_closecallsjs_readable_stream_controller_close(readable)immediately — before the queued write jobs run. Whentransform_write_job2finally executes,controller_enqueueseesstate == Closedand drops the chunk (the write promise still resolves, so the loss is silent).Per WHATWG,
TransformStreamDefaultSinkCloseAlgorithmonly runs after queued writes complete — the sink close should chain behind the pending write jobs (or the transform write path should flow through the writable queue thatfinish_writable_closealready waits on).Awaiting each
writer.write()avoids the drop, which is why pipeTo-driven paths (Next.js SSR) don't hit this.