diff --git a/changelog.d/7934-stream-tee-buffered-hop.md b/changelog.d/7934-stream-tee-buffered-hop.md new file mode 100644 index 0000000000..3f8d3beb78 --- /dev/null +++ b/changelog.d/7934-stream-tee-buffered-hop.md @@ -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.) diff --git a/crates/perry-stdlib/src/streams/tee.rs b/crates/perry-stdlib/src/streams/tee.rs index 69f090d507..654d1e7d5c 100644 --- a/crates/perry-stdlib/src/streams/tee.rs +++ b/crates/perry-stdlib/src/streams/tee.rs @@ -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 @@ -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; } diff --git a/crates/perry-stdlib/src/streams/tests.rs b/crates/perry-stdlib/src/streams/tests.rs index b25936081b..0c56b119f2 100644 --- a/crates/perry-stdlib/src/streams/tests.rs +++ b/crates/perry-stdlib/src/streams/tests.rs @@ -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); +} diff --git a/test-parity/gap_snapshot.json b/test-parity/gap_snapshot.json index 75298ca52d..4ddc53e9b8 100644 --- a/test-parity/gap_snapshot.json +++ b/test-parity/gap_snapshot.json @@ -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", diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 65f574f6a3..5c2c22ee67 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -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",