fix(streams): defer TransformStream sink close behind queued write jobs - #6664
Conversation
Unawaited writer.write() chunks were silently dropped when writer.close() ran in the same synchronous run: transform_write defers the transformer invocation through a two-hop microtask job, but transform_close closed the readable side immediately, so the jobs delivered their chunks onto a Closed controller and controller_enqueue discarded them (the write promises still resolved, hiding the loss). Node delivers the chunks. Per WHATWG, TransformStreamDefaultSinkCloseAlgorithm only runs after queued writes complete. Count queued write jobs per transform writable (async transformers count until their returned promise settles); when close arrives while jobs are pending, mark the writable Closing (so late writes reject) and park the close request, and the last job's completion resumes the full close (flush included). If the stream errors while the close is parked, the close request rejects with that error instead of running flush. Also visit the parked-promise maps (TRANSFORM_WRITE_RELEASES and the new TRANSFORM_PENDING_CLOSE) in the streams GC root scanner - an unawaited write/close promise has no other root while parked, so a sweep could otherwise free or fail to rewrite it. Fixes #6607
|
Warning Review limit reached
Next review available in: 17 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fixes #6607.
Problem
Unawaited
writer.write()chunks on aTransformStreamwere silently dropped whenwriter.close()ran in the same synchronous run:transform_writedefers the transformer invocation through a two-hop microtask job (tick-parity work from the cold-start head-reorder campaign), buttransform_closeclosed the readable side immediately. The queued jobs then delivered their chunks onto aClosedcontroller andcontroller_enqueuediscarded them — while the write promises still resolved, so the loss was silent.Fix
Per WHATWG,
TransformStreamDefaultSinkCloseAlgorithmruns only after queued writes complete. The close now chains behind the pending write jobs:transform_writecounts each queued job per transform writable (TRANSFORM_PENDING_WRITES); async transformers count until their returned promise settles.transform_closewith jobs pending parks the close request (TRANSFORM_PENDING_CLOSE), marks the writableClosingso late writes reject with "Stream is closed or closing" (same as the plain-writable path), and returns the parked promise on repeat close calls.controller.error), the close request rejects with that error instead of running flush.Also: the streams GC root scanner now visits the parked-promise maps (the pre-existing
TRANSFORM_WRITE_RELEASESand the newTRANSFORM_PENDING_CLOSE). Promises parked there are held only as raw addresses; an unawaited write/close has no other root, so a sweep could otherwise free the promise (or evacuation could fail to rewrite the address) before it settles.Not affected: pipeTo-driven paths await each write, so the deferral never triggers there — no tick-cadence change to the Next.js promise-hop parity behavior. The
perry-ext-streamsport runs transformers synchronously and never had this bug.Validation
Built with the perry-dev profile; all outputs compared against
node --experimental-strip-types:transform: hello world— matches Node (was empty).AB!), async transformer (x.y.), write-after-close rejects while the parked chunk is still delivered (rejected kept), close/closed promise settlement, awaited-write regression guard.test_gap_readable_stream_tee_pull,test_gap_stream_async_transform_tick_parity,test_gap_stream_livetee_tick_parity,test_gap_transform_stream_deferred_flush,test_issue_237_streams_pipe,test_issue_320_readable_stream,test_parity_stream_consumers,test_parity_stream_promises,test_data_pipelineall match Node.test_gap_stream_tee_tick_parityshows a one-tick drift that reproduces identically on a pristineorigin/mainbuild of the same profile — pre-existing, unrelated to this change.Per contributor guidelines, no version bump or CHANGELOG entry — maintainer folds metadata at merge.