Stream ticks ride the user nextTick queue: FIFO with process.nextTick, Node's order

- Every deferred stream emission posts a raw marker on the user tick queue (scr_next_tick_raw) and each marker dispatches exactly one stream tick, so resume_/emitReadable_/endReadableNT interleave with user nextTicks in enqueue order — in Node they are the same queue
- Cures the setEncoding-with-buffered-bytes shape: a nextTick registered after on('data') now observes the flowed chunks, and stream emissions run before microtasks at each checkpoint instead of after
- The loop station stays as the drain for anything a marker never reached and as the uncaught-throw cleanup of undelivered ticks
- Corpus 2606-stream-ticks-are-nextticks pins the interleavings (buffered decode, split multi-byte char, tick/data/tick ordering, end-after-tick) differentially on both backends
This commit is contained in:
Chris Tate
2026-07-24 09:47:43 -05:00
parent 6e9467821f
commit 2785bb09f9
4 changed files with 112 additions and 9 deletions
+24 -4
View File
@@ -591,7 +591,8 @@ ScrArr *scr_active_resources(void) {
* left queued on the uncaught paths) never run — the teardown releases
* them, like Node dropping the queue at exit. */
typedef struct ScrNtick {
ScrClosure *cb; /* owned */
ScrClosure *cb; /* owned; NULL for a raw C-hook entry */
void (*raw)(void); /* the hook when cb == NULL (stream tick markers) */
struct ScrNtick *next;
} ScrNtick;
@@ -612,11 +613,25 @@ void scr_next_tick(ScrClosure *cb /*moves*/) {
scr_nt_tail = t;
}
/* A RAW C-hook tick: the stream unit enqueues one marker per deferred
* stream emission so those emissions interleave with user nextTicks in
* true FIFO order — in Node they ARE nextTicks (resume_, emitReadable_,
* endReadableNT, ...). The hook dispatches exactly one stream tick;
* teardown just drops markers (the stream queue owns its entries). */
void scr_next_tick_raw(void (*fn)(void)) {
ScrNtick *t = calloc(1, sizeof *t);
if (!t) scr_oom();
t->raw = fn;
if (scr_nt_tail) scr_nt_tail->next = t;
else scr_nt_head = t;
scr_nt_tail = t;
}
void scr_nticks_teardown(void) {
while (scr_nt_head != NULL) {
ScrNtick *t = scr_nt_head;
scr_nt_head = t->next;
scr_closure_release(t->cb);
if (t->cb) scr_closure_release(t->cb);
free(t);
}
scr_nt_tail = NULL;
@@ -1677,9 +1692,14 @@ void scr_loop_run(void) {
scr_nt_head = t->next;
if (scr_nt_head == NULL) scr_nt_tail = NULL;
ScrClosure *cb = t->cb;
void (*raw)(void) = t->raw;
free(t);
((void (*)(ScrClosure *))cb->fn)(cb);
scr_closure_release(cb);
if (cb) {
((void (*)(ScrClosure *))cb->fn)(cb);
scr_closure_release(cb);
} else {
raw(); /* one stream tick, FIFO with the user ticks around it */
}
if (scr_exc_pending()) return;
}
}
+5
View File
@@ -3491,6 +3491,11 @@ bool scr_immediate_has_ref(double handle);
* teardown (they must never run yet must not leak). cb ownership moves
* in. */
void scr_next_tick(ScrClosure *cb);
/* A raw C-hook entry on the SAME queue: the stream unit enqueues one
* marker per deferred stream emission, so stream ticks and user
* nextTicks run in true FIFO order (in Node they are the same queue).
* Teardown drops markers without running them. */
void scr_next_tick_raw(void (*fn)(void));
void scr_nticks_teardown(void);
/* ── the events unit (scr_events.c — OPTIONAL, link-gated) ────────────
* Process signal/exit events and the piped-stdin surface. The unit links
+34 -5
View File
@@ -13,11 +13,12 @@
* nothing.
*
* EVENT TIMING. Node schedules most stream emissions on process.nextTick;
* here those deferrals ride a FIFO tick queue drained at the top of every
* loop turn (scr_loop_set_stream — the station before events/net/timers,
* the closest to nextTick; ticks scheduled from user code on the main
* stack run when the loop starts, i.e. after all synchronous code — the
* nextTick shape). The implemented orderings follow lib/internal/streams:
* here each deferral enqueues on the stream tick FIFO AND posts a raw
* marker on the USER nextTick queue (scr_next_tick_raw), so stream
* emissions and user nextTicks run in true FIFO enqueue order — Node's,
* where they are the same queue. The scr_loop_set_stream station remains
* as the drain of anything a marker never reached (and the uncaught-throw
* cleanup). The implemented orderings follow lib/internal/streams:
* - on('data') starts flowing on a TICK (resume_): synchronous code
* after the registration runs before the first 'data'.
* - push() while flowing with an empty buffer emits 'data'
@@ -350,6 +351,8 @@ typedef struct ScrStreamTick {
static ScrStreamTick *scr_st_head = NULL;
static ScrStreamTick *scr_st_tail = NULL;
static void scr_stream_dispatch_one(void);
static void scr_st_tick(ScrStream *s, ScrStreamTickOp op, ScrError *err /*moves*/,
ScrClosure *cb /*moves*/) {
ScrStreamTick *t = calloc(1, sizeof *t);
@@ -361,6 +364,13 @@ static void scr_st_tick(ScrStream *s, ScrStreamTickOp op, ScrError *err /*moves*
if (scr_st_tail) scr_st_tail->next = t;
else scr_st_head = t;
scr_st_tail = t;
/* One marker per tick on the USER nextTick queue: stream emissions are
* process.nextTicks in Node (resume_, emitReadable_, endReadableNT,
* afterWrite, ...), so they must interleave with user nextTicks in
* enqueue order — a `push(); on('data'); process.nextTick(assert)`
* sequence sees its data before the assert runs. The station dispatch
* below stays as the drain of anything a marker never reached. */
scr_next_tick_raw(&scr_stream_dispatch_one);
}
static bool scr_stream_ticks_pending(void) { return scr_st_head != NULL; }
@@ -2909,6 +2919,25 @@ static void scr_stream_run_tick(ScrStreamTick *t) {
}
}
static void scr_stream_dispatch(void);
/* One tick-marker's dispatch: the queue's FIFO head (markers and entries
* are enqueued 1:1 in the same order). After an uncaught throw the
* remaining entries drop through the station's exc branch below, keeping
* the RC audit clean. */
static void scr_stream_dispatch_one(void) {
ScrStreamTick *t = scr_st_head;
if (t == NULL || scr_exc_pending()) return;
scr_st_head = t->next;
if (scr_st_head == NULL) scr_st_tail = NULL;
scr_stream_run_tick(t);
scr_stream_release(t->s);
if (t->err) scr_error_release(t->err);
if (t->cb) scr_closure_release(t->cb);
free(t);
if (scr_exc_pending()) scr_stream_dispatch(); /* its exc branch drops the rest */
}
static void scr_stream_dispatch(void) {
while (scr_st_head != NULL && !scr_exc_pending() && !scr_loop_has_ready()) {
ScrStreamTick *t = scr_st_head;
@@ -0,0 +1,49 @@
// Stream emissions are process.nextTicks in Node (resume_,
// emitReadable_, endReadableNT ride the same FIFO as user nextTicks), so
// a user callback queued AFTER on('data') observes the flowed chunks —
// the setEncoding-with-buffered-bytes shape — and 'readable'/'end'
// interleave with nextTicks in enqueue order. Node is the oracle.
import { Readable } from "node:stream";
// setEncoding() with bytes already buffered: the re-decoded content flows
// before a nextTick registered after the 'data' subscription.
const r = new Readable({ read() {} });
r.push(Buffer.from("a"));
r.push(Buffer.from("b"));
r.setEncoding("utf8");
const chunks: string[] = [];
r.on("data", (c: string) => chunks.push(c));
process.nextTick(() => {
console.log("nextTick-sees", JSON.stringify(chunks));
});
// A chunked multi-byte character re-assembles across the decode.
const r2 = new Readable({ read() {} });
r2.push(Buffer.from([0xf0]));
r2.push(Buffer.from([0x9f]));
r2.setEncoding("utf8");
r2.push(Buffer.from([0x8e]));
r2.push(Buffer.from([0x89]));
const chunks2: string[] = [];
r2.on("data", (c: string) => chunks2.push(c));
process.nextTick(() => {
console.log("split-char", JSON.stringify(chunks2));
});
// FIFO interleave: ticks queued before the flow kick run before it; ones
// queued after run after the data delivery.
const r3 = new Readable({ read() {} });
r3.push(Buffer.from("x"));
r3.setEncoding("utf8");
process.nextTick(() => console.log("tick-before-subscribe"));
r3.on("data", (c: string) => console.log("data", JSON.stringify(c)));
process.nextTick(() => console.log("tick-after-subscribe"));
// 'end' rides the tick queue too: push(null) then a nextTick queued after
// the subscription still precedes 'end' (end waits for the drain + tick).
const r4 = new Readable({ read() {} });
r4.push(Buffer.from("z"));
r4.push(null);
r4.on("data", (c: Buffer) => console.log("r4-data", c.toString("utf8")));
r4.on("end", () => console.log("r4-end"));
process.nextTick(() => console.log("r4-tick"));