Repository navigation
PassThrough/Transform emits 'end' (and re-emits buffered data) once its buffer drains, although end() was never called #10449
Description
Activity
- addedpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 17, 2026 This is now the blocker for mongodb 7.5.0 end-to-end. With #11111 and #11106 fixed (PR incoming, stacked on #11105) and #11113 applied, the driver gets past
hello. The probe connects andpingsucceeds, but the first CRUD command fails withMongoBulkWriteError: write after end.Mechanism:
Connectiondoessocket.pipe(new SizedMessageTransform(...))and waits for each reply on the Transform's'data'. The second reply arrives after an event-loop gap. By then Perry has re-delivered the first chunk and ended the Transform, so the nextwrite()to it fails.Reduced repro (no sockets, no mongodb):
import { Transform } from "node:stream"; class Framer extends Transform { constructor() { super({ readableObjectMode: true }); } _transform(chunk: Buffer, _enc: string, cb: (e?: Error | null) => void) { this.push("re:" + chunk.toString()); cb(); } } const framer = new Framer(); framer.on("error", (e) => console.log("framer error", e.message)); (async () => { for (const m of ["a", "b", "c"]) { const got = new Promise((r) => framer.once("data", r)); setTimeout(() => framer.write(Buffer.from(m)), 5); console.log("got", await got); } })();
Node 26.5.1:
got re:a got re:b got re:cPerry (#11105 head
4f806f15c+ the net-write fix, perry-dev, Linux x64):got re:a got re:a framer error write after end framer error write after endA synchronous write (no
setTimeout) passes, and so doeswritableObjectMode: false. The event-loop gap is the trigger, which matches this issue's "once its buffer drains". Same output on the #11105 base without the net fix, so it is pre-existing.Correction to my comment above: that run was on d8f24f1, which is older than merge train 264. Train 264 contains #11096 ("a live-push readable ends only at EOF").
On current
main(784ed8e, v0.5.1649), plus #11130, perry-dev, Linux x64:- The 15-line
Transformrepro above printsgot re:a/got re:b/got re:c, identical to Node 26.5.1. Nowrite after end. - The
socket.pipe(new Transform())request/response variant (three rounds against a local echo server) also matches Node.
So the shape that blocked mongodb no longer reproduces on
main. I did not test #11041, because the failure no longer occurs without it.I could not re-confirm through the mongodb fixture itself: on current
mainit now fails earlier, before any socket I/O, withTypeError: 'get protocol' called on an object that is not a valid instance of URL.(tracked separately).- The 15-line
- added 3 commits that reference this issue
on Sep 29, 2026
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. A flowing
PassThrough(or options-objectTransform) emits a buffered chunktwice, then
'end', and marks itselfwritableEnded/readableEndedas soon as its buffer drains, although.end()was never called. Every later
write()/end()fails withERR_STREAM_WRITE_AFTER_END.Reproduction
main.tsExpected (Node 26.5.1)
Actual (Perry)
Without an
'error'listener, the laterend("c\n")throwsError [ERR_STREAM_WRITE_AFTER_END]: write after endand the process exits 1.
Impact
MimeNode.createReadStream()(lib/mime-node/index.js:618) creates aPassThroughandMimeNode.stream()writes each MIME part into it on a latersetImmediatetick (lib/mime-node/index.js:711-720),calling
.end()only after the last part. Under Perry the stream ends after the first header block, and everylater part fails with
ERR_STREAM_WRITE_AFTER_END, so the message body is truncated after the first boundary.a network relay, or a queue that drains in batches.
Notes
setImmediate,setTimeoutorqueueMicrotask. Every variant ends early, and the listener-first and microtaskvariants also duplicate a chunk (
"a|a|","a|b|b|").new Transform({ transform })behaves the same.s.write("x"); s.end("y")) works.'data'listener is attached) printsend "a\n"plus 2xERR_STREAM_WRITE_AFTER_END(Node:end "a\nb\nc\n").drain_readable_from_events(
crates/perry-runtime/src/node_stream_readwrite.rs:863-925, scheduled fromns_readable_from_draininnode_stream.rs:292) emits the hidden buffered chunks and then callsemit_readable_end_once(:777) unconditionally. That call setsendEmitted, callsmark_stream_endedand emits'end'without checking whether the writable side of a Duplex/Transform has ended orpush(null)has run. Theduplicated chunk suggests the same drain replays a chunk that was already delivered through the flowing
emit_readable_datapath.const { Transform } = require('stream'),const T = Transform) #10448 and Transform flush output is dropped when the Transform is a pipe() destination (_flush / options.flush) #10450.