diff --git a/packages/compiler/src/compat/node-matrix.ts b/packages/compiler/src/compat/node-matrix.ts index 54d060fe7..626ba8422 100644 --- a/packages/compiler/src/compat/node-matrix.ts +++ b/packages/compiler/src/compat/node-matrix.ts @@ -19,6 +19,27 @@ * The primary is the runtime .node-version pins and the one whose label * stamps every shared manifest row; a candidate is equally supported, just * not the label the manifest prose is written against. + * + * ## Semantics the primary DECIDES (not just labels) + * + * Most of the matrix is a census: a member exists on one major and not the + * other, and both answers are recorded. A few rows are different in kind — + * the two majors give DIFFERENT ANSWERS to the same call, and a compiled + * binary can only reproduce one. Those follow the primary, and every one + * of them belongs in this list so a primary promotion is a checklist + * rather than an archaeology dig: + * + * - `Readable.prototype.read()`, bare form (nodejs#60441, semver-major in + * 26.0.0). howMuchToRead(NaN) is `flowing && length ? head : length` on + * 24 and `!decoder ? head : length` on 26, so a PAUSED bare read() + * collapses the whole queue on 24 and hands back one pushed chunk on + * 26. It is one rule, but it is observable through every paused reader: + * read() itself, a 'readable'-handler drain, and the async iterator. + * Implemented at one expression per runtime — scr_stream.c's + * howMuchToRead, island-js/13-stream.js's read(), readable.rs's + * readable_read — each carrying the 26 form in a comment beside it. + * Pinned by tests/corpus/2845-readable-paused-read-boundaries.ts and + * 2846-readable-unshift-order.ts against the primary oracle. */ import type { CompatTargets } from "./profile-schema.js"; diff --git a/packages/runtime-rust/src/island_bootstrap.js b/packages/runtime-rust/src/island_bootstrap.js index ecc3cad20..2fbff3b7b 100644 --- a/packages/runtime-rust/src/island_bootstrap.js +++ b/packages/runtime-rust/src/island_bootstrap.js @@ -2400,10 +2400,7 @@ function makeStream(env) { } if (n === undefined || n === null || (typeof n === "number" && Number.isNaN(n))) { if (this._objectMode) return this._takeChunk(); - if (this._decoder) return this._takeAll(); - const c = this._takeChunk(); - this._maybeEmitEnd(); - return c; + return this._takeAll(); } if (this._objectMode) return this._takeChunk(); if (n <= 0) return null; diff --git a/packages/runtime-rust/src/readable.rs b/packages/runtime-rust/src/readable.rs index 7ba298961..ac02ee6e5 100644 --- a/packages/runtime-rust/src/readable.rs +++ b/packages/runtime-rust/src/readable.rs @@ -524,26 +524,13 @@ where L: Clone + Trace + 'static, R: Clone + Trace + 'static, { - let (available, eof, encoded, head) = readable.with_mut(|data| { + let (available, eof, encoded) = readable.with_mut(|data| { // Node clears emittedReadable for every read except read(0). // The absent read() form arrives as -1 and therefore clears too. if size != 0.0 { data.emitted_readable = false; } - // Node's howMuchToRead() answers a bare read() with - // state.buffer.first().length rather than state.length, so a raw - // Buffer stream hands back exactly ONE queued chunk and the - // boundaries created by push() and unshift() survive the read. - let head = match data.chunks.front() { - Some(ReadableChunk::Bytes(chunk)) => bytes_len(chunk) as usize, - _ => 0, - }; - ( - data.buffered_length, - data.eof, - data.encoding.is_some(), - head, - ) + (data.buffered_length, data.eof, data.encoding.is_some()) }); if encoded { throw_error("read() on a stream with an encoding set is not supported yet (consume 'data' events, which deliver strings)".to_owned()); @@ -551,10 +538,22 @@ where if available == 0 { return None; } + // howMuchToRead(NaN). THE semantics of read() follow + // NODE_COMPAT_MATRIX.primary: a compiled binary reproduces ONE Node, + // and the two majors disagree on the bare form. + // + // Node 24 (primary today): flowing && length ? head : state.length + // Node 26 (nodejs#60441): !decoder ? head : state.length + // + // An encoded stream throws above and flowing delivers through 'data', + // so this is the paused raw-Buffer path and 24 collapses the queue. + // Promoting the primary to 26 means asking for the head chunk's length + // (`data.chunks.front()`) instead of `available`; this expression is + // the single point of change. See nodejs#60441. let requested = if size.is_finite() && size >= 0.0 { size.trunc() as usize } else { - head + available }; if requested == 0 || (requested > available && !eof) { return None; diff --git a/packages/runtime/src/island-js/13-stream.js b/packages/runtime/src/island-js/13-stream.js index 1c30a6f60..f8e1cede3 100644 --- a/packages/runtime/src/island-js/13-stream.js +++ b/packages/runtime/src/island-js/13-stream.js @@ -231,15 +231,19 @@ function makeStream(env) { } if (n === undefined || n === null || (typeof n === "number" && Number.isNaN(n))) { if (this._objectMode) return this._takeChunk(); - /* Node's howMuchToRead() answers a bare read() with - * state.buffer.first().length, not state.length: raw Buffer mode - * hands back exactly ONE queued chunk, so the boundaries created by - * push() and unshift() survive the read. Only a stream with a - * decoder attached collapses the queue into a single string. */ - if (this._decoder) return this._takeAll(); - const c = this._takeChunk(); - this._maybeEmitEnd(); - return c; + /* howMuchToRead(NaN). THE semantics of read() follow + * NODE_COMPAT_MATRIX.primary: a compiled binary reproduces ONE + * Node, and the two majors disagree here. + * + * Node 24 (primary today): flowing && length ? head : length + * Node 26 (nodejs#60441): !decoder ? head : length + * + * read() is only ever the PAUSED path here — flowing delivers + * through _drainData()/_takeChunk() — so on 24 it always collapses + * the queue. Promoting the primary to 26 means returning the head + * entry (`this._takeChunk()`) whenever no decoder is attached; + * this branch is the single point of change. See nodejs#60441. */ + return this._takeAll(); } if (this._objectMode) return this._takeChunk(); if (n <= 0) return null; diff --git a/packages/runtime/src/scr_island_js.h b/packages/runtime/src/scr_island_js.h index 57c139619..262c4a40d 100644 --- a/packages/runtime/src/scr_island_js.h +++ b/packages/runtime/src/scr_island_js.h @@ -2569,15 +2569,19 @@ static const char isl_modules_bootstrap[] = " }\n" " if (n === undefined || n === null || (typeof n === \"number\" && Number.isNaN(n))) {\n" " if (this._objectMode) return this._takeChunk();\n" - /* Node's howMuchToRead() answers a bare read() with - * state.buffer.first().length, not state.length: raw Buffer mode - * hands back exactly ONE queued chunk, so the boundaries created by - * push() and unshift() survive the read. Only a stream with a - * decoder attached collapses the queue into a single string. */ - " if (this._decoder) return this._takeAll();\n" - " const c = this._takeChunk();\n" - " this._maybeEmitEnd();\n" - " return c;\n" + /* howMuchToRead(NaN). THE semantics of read() follow + * NODE_COMPAT_MATRIX.primary: a compiled binary reproduces ONE + * Node, and the two majors disagree here. + * + * Node 24 (primary today): flowing && length ? head : length + * Node 26 (nodejs#60441): !decoder ? head : length + * + * read() is only ever the PAUSED path here — flowing delivers + * through _drainData()/_takeChunk() — so on 24 it always collapses + * the queue. Promoting the primary to 26 means returning the head + * entry (`this._takeChunk()`) whenever no decoder is attached; + * this branch is the single point of change. See nodejs#60441. */ + " return this._takeAll();\n" " }\n" " if (this._objectMode) return this._takeChunk();\n" " if (n <= 0) return null;\n" diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 32c92cc13..a27d7e12e 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -713,16 +713,20 @@ static void *scr_stream_read_n(ScrStream *s, double size) { /* objectMode-style: one whole entry per read, whatever n says */ want = st->r.length > 0 ? 1 : 0; } else if (absent) { - /* Node's howMuchToRead() answers a bare read() with the HEAD entry's - * remaining length rather than state.length, so exactly ONE queued - * chunk comes back and the boundaries push() and unshift() created - * survive the read. That holds in paused mode too, not just while - * flowing — the `flowing` gate this used to carry made a paused - * read() concatenate the whole queue. The ONE case that still - * collapses it is a PAUSED read() on a decoder-backed stream, which - * joins the queue into a single string; while flowing, an encoded - * stream keeps emitting its chunks one data event at a time. */ - want = (st->r.flowing == 1 || !st->r.encoded) && st->r.n > 0 + /* howMuchToRead(NaN). THE semantics of read() follow + * NODE_COMPAT_MATRIX.primary — a compiled binary reproduces ONE Node, + * and the two majors answer a bare read() differently: + * + * Node 24 (primary today): flowing && length ? head : state.length + * Node 26 (nodejs#60441): !decoder ? head : state.length + * + * So on 24 a PAUSED bare read() collapses the whole queue into one + * value and only a FLOWING one walks it chunk by chunk, while on 26 a + * raw Buffer stream hands back the head entry either way. Promoting + * the primary to 26 means widening this gate (and the matching one + * after the refill below) to `flowing == 1 || !st->r.encoded` — this + * expression is the single point of change; see nodejs#60441. */ + want = st->r.flowing == 1 && st->r.n > 0 ? scr_stream_entry_len(st, st->r.buf[0]) - st->r.head_off : st->r.length; } else { @@ -744,8 +748,8 @@ static void *scr_stream_read_n(ScrStream *s, double size) { if (st->r.object_entries) { want = st->r.length > 0 ? 1 : 0; } else if (absent) { - /* same head-entry rule as above, re-derived after the refill */ - want = (st->r.flowing == 1 || !st->r.encoded) && st->r.n > 0 + /* the same primary-pinned rule as above, re-derived after the refill */ + want = st->r.flowing == 1 && st->r.n > 0 ? scr_stream_entry_len(st, st->r.buf[0]) - st->r.head_off : st->r.length; } else { diff --git a/tests/corpus/2845-readable-paused-read-boundaries.ts b/tests/corpus/2845-readable-paused-read-boundaries.ts index 8e4872796..4b270e94b 100644 --- a/tests/corpus/2845-readable-paused-read-boundaries.ts +++ b/tests/corpus/2845-readable-paused-read-boundaries.ts @@ -1,24 +1,30 @@ -// Node's howMuchToRead(): a bare read() on a raw Buffer stream hands back -// exactly ONE queued chunk (state.buffer.first().length), never the whole -// queue, so the boundaries push() created survive the read. read(n) still -// slices across those boundaries, and a read(n) larger than what is -// buffered stays null until EOF releases the remainder. +// read() boundaries, pinned to NODE_COMPAT_MATRIX.primary (Node 24). +// +// Node's howMuchToRead(NaN) is `state.flowing && state.length ? +// state.buffer.first().length : state.length`, so a PAUSED bare read() +// collapses everything buffered into one value and only a FLOWING stream +// walks the queue one chunk at a time. read(n) slices across the pushed +// boundaries either way, and a read(n) larger than what is buffered stays +// null until EOF releases the remainder. +// +// Node 26 (nodejs#60441) makes the bare form return the head entry for any +// stream without a decoder. That is a primary promotion, not a bug fix: +// see the read() comments in scr_stream.c / 13-stream.js / readable.rs. import { Readable } from "node:stream"; const show = (c: Buffer | null): string => (c === null ? "null" : c.toString()); -// read(3) splits the head chunk; the bare reads that follow walk the queue -// one chunk at a time: "lo " (the head's remainder), then "world". +// read(3) splits the head chunk; the bare read that follows takes ALL of +// what is left, across the boundary push() created. const sliced = new Readable({ read() {} }); sliced.push("hello "); sliced.push("world"); sliced.push(null); console.log("slice:", show(sliced.read(3))); -console.log("head-rest:", show(sliced.read())); -console.log("next-chunk:", show(sliced.read())); +console.log("rest:", show(sliced.read())); console.log("drained:", show(sliced.read())); -// No prior read(n): every bare read still yields a single pushed chunk. +// No prior read(n): one paused bare read drains the whole queue. const walk = new Readable({ read() {} }); walk.push("aa"); walk.push("bb"); @@ -26,10 +32,9 @@ walk.push("cc"); walk.push(null); console.log("walk1:", show(walk.read())); console.log("walk2:", show(walk.read())); -console.log("walk3:", show(walk.read())); -console.log("walk4:", show(walk.read())); -// read(n) spanning a boundary leaves the remainder as the new head. +// read(n) spanning a boundary leaves the remainder as the new head, and +// the bare read after it still collapses what remains. const span = new Readable({ read() {} }); span.push("aa"); span.push("bb"); @@ -55,3 +60,41 @@ exact.push("bb"); exact.push(null); console.log("exact:", show(exact.read(4))); console.log("exact-drained:", show(exact.read())); + +// read(0) reads nothing and leaves the queue untouched. +const zero = new Readable({ read() {} }); +zero.push("aa"); +zero.push("bb"); +zero.push(null); +console.log("zero:", show(zero.read(0))); +console.log("zero-after:", show(zero.read())); + +// (A decoder-backed stream collapses the queue too — the one case Node 26 +// keeps agreeing with Node 24 on. 1744-stream-set-encoding covers it; the +// Rust runtime does not implement read() on an encoded stream yet.) + +// Draining inside a 'readable' handler is paused, so it takes one value. +const pull = new Readable({ read() {} }); +pull.push("aa"); +pull.push("bb"); +pull.push("cc"); +pull.push(null); +const pulled: string[] = []; +pull.on("readable", () => { + let c: Buffer | null; + while ((c = pull.read()) !== null) pulled.push(c.toString()); +}); +pull.on("end", () => { + console.log("readable-pull:", pulled.join("|")); + + // FLOWING is the half of the rule that DOES walk the queue: every + // pushed chunk arrives as its own 'data' event. + const flow = new Readable({ read() {} }); + flow.push("aa"); + flow.push("bb"); + flow.push("cc"); + flow.push(null); + const flowed: string[] = []; + flow.on("data", (c: Buffer) => flowed.push(c.toString())); + flow.on("end", () => console.log("flowing-data:", flowed.join("|"))); +}); diff --git a/tests/corpus/2846-readable-unshift-order.ts b/tests/corpus/2846-readable-unshift-order.ts index bb6f229fc..e37c8b1ad 100644 --- a/tests/corpus/2846-readable-unshift-order.ts +++ b/tests/corpus/2846-readable-unshift-order.ts @@ -1,31 +1,36 @@ -// unshift() puts a chunk at the FRONT of the queue as its own entry: it -// never merges with what is already buffered, repeated unshifts stack -// LIFO, and the chunk pushed back after a partial read comes out ahead of -// that read's remainder. +// unshift() puts a chunk at the FRONT of the queue as its own entry, and +// repeated unshifts stack LIFO. What a reader then SEES of those +// boundaries depends on read(), which is pinned to +// NODE_COMPAT_MATRIX.primary (Node 24): a paused bare read() collapses the +// whole queue, so the ordering shows up in the concatenation order rather +// than as separate reads. read(n) still slices the unshifted head like any +// other, and 'data' events in flowing mode keep the entries apart. +// +// Under Node 26 (nodejs#60441) the bare reads below would come back one +// entry at a time instead. See the read() comments in the three runtimes. import { Readable } from "node:stream"; const show = (c: Buffer | null): string => (c === null ? "null" : c.toString()); -// The unshifted chunk stays separate from the push()ed one behind it. +// The unshifted chunk lands ahead of the push()ed one behind it. const front = new Readable({ read() {} }); front.push("bc"); front.unshift(Buffer.from("a")); front.push(null); console.log("front:", show(front.read())); -console.log("behind:", show(front.read())); console.log("drained:", show(front.read())); -// Two unshifts come back out last-in-first-out. +// Two unshifts come back out last-in-first-out: "a" then "b" then "c". const lifo = new Readable({ read() {} }); lifo.push("c"); lifo.unshift(Buffer.from("b")); lifo.unshift(Buffer.from("a")); lifo.push(null); -console.log("lifo1:", show(lifo.read())); -console.log("lifo2:", show(lifo.read())); -console.log("lifo3:", show(lifo.read())); +console.log("lifo:", show(lifo.read())); +console.log("lifo-drained:", show(lifo.read())); -// Pushing bytes back after a partial read jumps the head's remainder. +// Bytes pushed back after a partial read jump ahead of the head's +// remainder: "XY" precedes the "lo " that read(3) left behind. const partial = new Readable({ read() {} }); partial.push("hello "); partial.push("world"); @@ -33,8 +38,6 @@ console.log("partial:", show(partial.read(3))); partial.unshift(Buffer.from("XY")); partial.push(null); console.log("pushed-back:", show(partial.read())); -console.log("head-rest:", show(partial.read())); -console.log("tail:", show(partial.read())); console.log("drained:", show(partial.read())); // unshift onto an empty queue is readable immediately. @@ -44,7 +47,8 @@ empty.push(null); console.log("empty:", show(empty.read())); console.log("empty-drained:", show(empty.read())); -// read(n) still slices an unshifted chunk like any other head. +// read(n) slices an unshifted chunk like any other head, and the bare read +// after it takes the rest. const sliced = new Readable({ read() {} }); sliced.push("de"); sliced.unshift(Buffer.from("abc")); @@ -52,3 +56,14 @@ sliced.push(null); console.log("sliced:", show(sliced.read(2))); console.log("sliced-rest:", show(sliced.read())); console.log("sliced-tail:", show(sliced.read())); + +// Flowing mode keeps the entries apart, so the LIFO order is visible as +// three separate 'data' events. +const flow = new Readable({ read() {} }); +flow.push("c"); +flow.unshift(Buffer.from("b")); +flow.unshift(Buffer.from("a")); +flow.push(null); +const seen: string[] = []; +flow.on("data", (c: Buffer) => seen.push(c.toString())); +flow.on("end", () => console.log("flowing:", seen.join("|")));