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
8 changes: 8 additions & 0 deletions changelog.d/7934-stream-tee-buffered-hop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**A buffered tee source no longer pays the cold-pull two-hop cadence.**
`ReadableStream.tee` on an already-buffered source delivered its first chunk
one observable microtask late (`t2` where node lands `t1`), because the
buffered case rode the cold/pull-driven pipeline calibrated in #6657. The two
cases are now distinguished: pre-buffered sources deliver through the normal
queued fanout reaction (one job), empty cold sources keep their two-hop
demand cadence. `test_gap_stream_tee_tick_parity` leaves the known-failure
ratchet with it. (Fragment added at merge; full analysis in the PR body.)
21 changes: 19 additions & 2 deletions crates/perry-stdlib/src/streams/tee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,8 @@ pub(super) unsafe fn tee_schedule_pull(source: usize) {
}

/// Demand-initiated pull entry — a branch read parked against an empty branch
/// queue (`maybe_pull` routing). On a COLD pipeline Node pays TWO microtask
/// hops before that read resolves: the `sourceReader.read()` promise
/// queue (`maybe_pull` routing). On a COLD, UNBUFFERED pipeline Node pays TWO
/// microtask hops before that read resolves: the `sourceReader.read()` promise
/// resolution plus the `.then(fanout)` reaction (streamsuite first-delivery
/// cadence: node's first chunk lands after t2, Perry's landed after t1 — the
/// one-hop-short residual behind the Next.js RSC Flight row-reorder). Once
Expand All @@ -291,6 +291,23 @@ pub(super) unsafe fn tee_schedule_pull_demand(source: usize) {
tee_schedule_pull(source);
return;
}
// A source that was already buffered before `tee()` is not a cold pull
// pipeline: `sourceReader.read()` can consume its first chunk immediately,
// so Node pays only the queued fanout/reaction cycle. Sending that case
// through `tee_demand_hop` added a third observable promise generation
// (test_gap_stream_tee_tick_parity: first delivery after t2 instead of
// Node's t1). Keep the extra cold-start hop only for an empty source whose
// producer still has to run; that is the Flight cadence #6657 calibrated.
let has_buffered_chunk = READABLE_STREAMS
.lock()
.unwrap()
.get(&source)
.map(|s| !s.chunks.is_empty())
.unwrap_or(false);
if has_buffered_chunk {
tee_schedule_pull(source);
return;
}
if !TEE_PULLING.lock().unwrap().insert(source) {
return;
}
Expand Down
39 changes: 39 additions & 0 deletions crates/perry-stdlib/src/streams/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,42 @@ fn pipe_through_rejects_locked_endpoints_before_starting() {
Some("The options.signal property must be an AbortSignal")
);
}

#[test]
fn buffered_tee_demand_skips_only_the_cold_source_hop() {
let _serial = serial_guard();
// Keep this assertion about jobs scheduled by the calls below, not work
// left behind by an earlier stream test on the same worker thread.
perry_runtime::promise::js_promise_run_microtasks();

let buffered = alloc_readable(0, 0, 0, 1.0);
READABLE_STREAMS
.lock()
.unwrap()
.get_mut(&buffered)
.unwrap()
.push_chunk(TAG_UNDEFINED, 1.0);
unsafe {
tee::tee_schedule_pull_demand(buffered);
}
let buffered_jobs = perry_runtime::promise::js_promise_run_microtasks();

let empty = alloc_readable(0, 0, 0, 1.0);
unsafe {
tee::tee_schedule_pull_demand(empty);
}
let empty_jobs = perry_runtime::promise::js_promise_run_microtasks();

assert_eq!(
buffered_jobs, 1,
"a pre-buffered source needs only the fanout reaction job"
);
assert_eq!(
empty_jobs, 2,
"an empty cold source must retain the Flight-calibrated demand hop"
);

let mut streams = READABLE_STREAMS.lock().unwrap();
streams.remove(&buffered);
streams.remove(&empty);
}
7 changes: 0 additions & 7 deletions test-parity/gap_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,6 @@
"category": "ci-env",
"reason": "node exits non-zero: cannot resolve the 'slugify' npm import without node_modules, so the oracle never runs and the test verifies nothing. Same class as test_ramda_sum. Was invisible under the old gate, which dropped node_fail from the denominator; needs a CI-side fixture (#1634)."
},
"test_gap_stream_tee_tick_parity": {
"status": "parity_fail",
"issue": "6477",
"added": "2026-07-20",
"category": "bug-open",
"reason": "Web Streams tee cold-start fires one extra microtask tick before the first branch read on a pre-buffered+closed source (t2 vs Node's t1). Delicate cadence calibration (post-#6657 tee/pipe tick parity); a naive fix risks the Next.js Flight byte-parity #6657 tuned. Deferred to the streams pull-ordering work in #6477."
},
"test_gap_v8_2_3680plus": {
"status": "parity_fail",
"issue": "3680",
Expand Down
6 changes: 0 additions & 6 deletions test-parity/known_failures.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,6 @@
"category": "bug-stale",
"reason": "RE-TRIAGE: tracking issue #3088 is CLOSED but this still fails (audited 2026-08-07, #7582) — needs a new issue. node:perf_hooks cluster (#3088/#3008/#3010/#3011) module-inventory gap."
},
"test_gap_stream_tee_tick_parity": {
"issue": "6477",
"added": "2026-07-20",
"category": "bug-open",
"reason": "Re-verified still failing 2026-08-07 (#7582 audit); #6477 is the one tracking issue in this file that is still OPEN. Web Streams tee cold-start fires one extra microtask tick before the first branch read on a pre-buffered+closed source (t2 vs Node's t1). Delicate cadence calibration (post-#6657 tee/pipe tick parity); a naive fix risks the Next.js Flight byte-parity #6657 tuned. Deferred to the streams pull-ordering work in #6477."
},
"test_gap_v8_2_3680plus": {
"issue": "3680",
"added": "2026-07-04",
Expand Down
Loading