Repository navigation
PassThrough/Transform emits 'end' (and re-emits buffered data) once its buffer drains, although end() was never called #10449
Copy link
Copy link
Closed
Labels
package-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
Description
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-object Transform) emits a buffered chunk
twice, then 'end', and marks itself writableEnded/readableEnded as soon as its buffer drains, although .end()
was never called. Every later write()/end() fails with ERR_STREAM_WRITE_AFTER_END.
Reproduction
main.ts
import { PassThrough } from "stream";
const s = new PassThrough();
s.on("data", (c: any) => console.log("data", JSON.stringify(String(c))));
s.on("end", () => console.log("end"));
s.on("error", (e: any) => console.log("error", e.code));
s.write("a\n");
setTimeout(() => {
console.log("before 2nd write: writableEnded =", s.writableEnded, "readableEnded =", s.readableEnded);
s.write("b\n");
s.end("c\n");
}, 10);node main.ts
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.ts -o out && ./out # same output with default auto-optimizeExpected (Node 26.5.1)
data "a\n"
before 2nd write: writableEnded = false readableEnded = false
data "b\n"
data "c\n"
end
Actual (Perry)
data "a\n"
data "a\n"
end
before 2nd write: writableEnded = true readableEnded = true
error ERR_STREAM_WRITE_AFTER_END
error ERR_STREAM_WRITE_AFTER_END
Without an 'error' listener, the later end("c\n") throws Error [ERR_STREAM_WRITE_AFTER_END]: write after end
and the process exits 1.
Impact
- nodemailer 9.0.3:
MimeNode.createReadStream()(lib/mime-node/index.js:618) creates aPassThroughand
MimeNode.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 every
later part fails withERR_STREAM_WRITE_AFTER_END, so the message body is truncated after the first boundary. - Any producer that writes into a PassThrough/Transform across ticks is likely affected: a progressive writer,
a network relay, or a queue that drains in batches.
Notes
- It does not matter whether the listener is attached before or after the first write, or whether the gap is
setImmediate,setTimeoutorqueueMicrotask. Every variant ends early, and the listener-first and microtask
variants also duplicate a chunk ("a|a|","a|b|b|").new Transform({ transform })behaves the same. - Writing and ending in the same tick (
s.write("x"); s.end("y")) works. - The audit's original 8-line shape (writes before the
'data'listener is attached) printsend "a\n"plus 2x
ERR_STREAM_WRITE_AFTER_END(Node:end "a\nb\nc\n"). - Suspected location (inferred from reading the code, not instrumented):
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 calls
emit_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. The
duplicated chunk suggests the same drain replays a chunk that was already delivered through the flowing
emit_readable_datapath. - No existing issue found. The closest is closed node:stream — events don't fire (data/end/finish on Readable/Writable/Transform/PassThrough) #1532 (node:stream events did not fire at all).
- nodemailer also needs node:stream subclass overrides (_transform/_write/_read) ignored when the heritage is a local binding (
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.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
package-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
Activity
proggeramlug commentedon Sep 23, 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):
Node 26.5.1:
Perry (#11105 head
4f806f15c+ the net-write fix, perry-dev, Linux x64):A 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.proggeramlug commentedon Sep 23, 2026
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:Transformrepro above printsgot re:a/got re:b/got re:c, identical to Node 26.5.1. Nowrite after end.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).fix(runtime): stdio writable state, ServerResponse/Readable inherits-…
fix(runtime): stdio writable state, ServerResponse/Readable inherits-…
fix(runtime): reach the stream subclass-init shim through the stream …
perf: this is a parameter of every JS body; the implicit-this cell is…
perf: function objects point to a static JsFunctionInfo; delete the c…