Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/compiler/src/compat/node-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
5 changes: 1 addition & 4 deletions packages/runtime-rust/src/island_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 15 additions & 16 deletions packages/runtime-rust/src/readable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,37 +524,36 @@ 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());
}
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;
Expand Down
22 changes: 13 additions & 9 deletions packages/runtime/src/island-js/13-stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 13 additions & 9 deletions packages/runtime/src/scr_island_js.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 16 additions & 12 deletions packages/runtime/src/scr_stream.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
69 changes: 56 additions & 13 deletions tests/corpus/2845-readable-paused-read-boundaries.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
// 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");
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");
Expand All @@ -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("|")));
});
43 changes: 29 additions & 14 deletions tests/corpus/2846-readable-unshift-order.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
// 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");
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.
Expand All @@ -44,11 +47,23 @@ 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"));
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("|")));