Skip to content

fix(streams): defer TransformStream sink close behind queued write jobs - #6664

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6607-transform-close-pending-writes
Jul 19, 2026
Merged

fix(streams): defer TransformStream sink close behind queued write jobs#6664
proggeramlug merged 1 commit into
mainfrom
fix/6607-transform-close-pending-writes

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Fixes #6607.

Problem

Unawaited writer.write() chunks on a TransformStream were silently dropped when writer.close() ran in the same synchronous run:

const ts = new TransformStream();
const writer = ts.writable.getWriter();
writer.write("hello");
writer.write(" world");
writer.close();
let out = "";
for await (const chunk of ts.readable) out += chunk;
// Node: "hello world" — Perry: "" (empty)

transform_write defers the transformer invocation through a two-hop microtask job (tick-parity work from the cold-start head-reorder campaign), but transform_close closed the readable side immediately. The queued jobs then delivered their chunks onto a Closed controller and controller_enqueue discarded them — while the write promises still resolved, so the loss was silent.

Fix

Per WHATWG, TransformStreamDefaultSinkCloseAlgorithm runs only after queued writes complete. The close now chains behind the pending write jobs:

  • transform_write counts each queued job per transform writable (TRANSFORM_PENDING_WRITES); async transformers count until their returned promise settles.
  • transform_close with jobs pending parks the close request (TRANSFORM_PENDING_CLOSE), marks the writable Closing so late writes reject with "Stream is closed or closing" (same as the plain-writable path), and returns the parked promise on repeat close calls.
  • The last job's completion resumes the full close — flush included, so the existing nextjs: dynamic RSC render evaporates silently with a clean async graph after expected manifest ENOENTs (post-#5988 wall for 8/8) #5989 deferred-flush chaining composes on top. If the stream errored while the close was parked (abort / 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_RELEASES and the new TRANSFORM_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-streams port runs transformers synchronously and never had this bug.

Validation

Built with the perry-dev profile; all outputs compared against node --experimental-strip-types:

  • Issue repro: transform: hello world — matches Node (was empty).
  • Variants, all byte-identical to Node: sync transformer + flush trailer (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.
  • Stream gap/parity sweep (10 tests): 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_pipeline all match Node. test_gap_stream_tee_tick_parity shows a one-tick drift that reproduces identically on a pristine origin/main build of the same profile — pre-existing, unrelated to this change.

Per contributor guidelines, no version bump or CHANGELOG entry — maintainer folds metadata at merge.

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
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c113dcea-4fe9-421a-81bb-5337bbf1977f

📥 Commits

Reviewing files that changed from the base of the PR and between fac2bbe and 1440869.

📒 Files selected for processing (2)
  • crates/perry-stdlib/src/streams.rs
  • crates/perry-stdlib/src/streams/transform.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6607-transform-close-pending-writes

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug merged commit 5eb2bc4 into main Jul 19, 2026
23 of 26 checks passed
@proggeramlug
proggeramlug deleted the fix/6607-transform-close-pending-writes branch July 19, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TransformStream: unawaited writer.write() chunks dropped when writer.close() runs before the queued transform jobs

1 participant