Skip to content

node:stream: restore the destroyed-stream guard in Readable.prototype.pause - #33467

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/4d8ee6d9/readable-pause-destroyed-guard
Jul 12, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/4d8ee6d9/readable-pause-destroyed-guard

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Repro

import { PassThrough, Writable } from "node:stream";

const sink = () => new Writable({ write(c, e, cb) { cb(); } });
const a = new PassThrough(); a.resume(); a.end("x");      // control: no pipe
const b = new PassThrough(); b.pipe(sink()); b.end("x");  // piped to completion
setTimeout(() => {
  console.log(`resume-only: readableFlowing=${a.readableFlowing} isPaused=${a.isPaused()}`);
  console.log(`piped:       readableFlowing=${b.readableFlowing} isPaused=${b.isPaused()}`);
}, 150);
node v26.3.0: resume-only readableFlowing=true  isPaused=false · piped readableFlowing=true  isPaused=false
bun 1.4.0:    resume-only readableFlowing=true  isPaused=false · piped readableFlowing=false isPaused=true

The post-pipe readable-state flags diverge on every completed pipe. Code that inspects isPaused() / readableFlowing to decide whether to re-resume() a reusable source concludes the wrong thing on Bun.

Cause

A completed pipe unpipes its source, and Readable.prototype.unpipe calls source.pause(). Node's pause() early-returns when the stream is destroyed, so a source that autoDestroy'd itself at 'end' keeps readableFlowing === true. Bun's pause() has no such guard, so it pauses the already-destroyed source.

The guard was removed from both pause() and resume() in 6abf57e, so that legacy fd-slicer-style readables (yauzl → extract-zip → puppeteer) keep working. Those assign this.destroyed = true right before push(null), which on modern streams hits the prototype setter; with the upstream guard, a piped destination's 'drain' can no longer resume the source and the last buffered chunk is dropped.

Only the resume() guard causes that truncation. With pause() guarded, pause() leaves kFlowing set, so resume()'s if (flowing === 0) check is skipped and flow() is never rescheduled. Emulating each variant on Node v26.3.0 with the fd-slicer pattern:

variant result
both guards (stock Node 26) truncated, 131072 / 171072 bytes
pause() guard only truncated, 131072 / 171072 bytes
resume() guard only removed complete, 171072 / 171072 bytes
both guards removed (Bun today) complete, 171072 / 171072 bytes

So pause() was over-reverted: its guard is not load-bearing for fd-slicer.

Fix

Restore if ((state[kState] & kDestroyed) !== 0) return this; in Readable.prototype.pause only. resume() keeps the documented divergence.

Verification

Every flag now matches Node v26.3.0:

case node bun before bun after
pipe to completion (autoDestroy default) flowing=true paused=false flowing=false paused=true flowing=true paused=false
pipe to completion, autoDestroy: false flowing=false paused=true flowing=false paused=true flowing=false paused=true
manual unpipe() of a live source flowing=false paused=true flowing=false paused=true flowing=false paused=true
pause() after destroy() flowing=true paused=false flowing=false paused=true flowing=true paused=false

Both new test bodies in test/js/node/stream/node-stream.test.js were run verbatim against real Node v26.3.0 and pass there unchanged, so the expectations are not hand-derived.

  • test/js/node/stream/node-stream.test.js: two new tests (pause() no-op on destroyed; post-pipe state for autoDestroy on/off). Both fail on main, pass with this change. The existing fd-slicer regression test still passes.
  • test/js/node/test/parallel/test-stream-destroy.js: the vendored upstream pause() assertion no longer needs the Bun gate, so it now runs.
  • 249 vendored test-stream-*.js, 493 vendored test-http-* / test-net-* / test-readline* / test-pipe*: no new failures (the handful that fail do so identically on a baseline debug build of main).

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:14 PM PT - Jul 6th, 2026

❌ @robobun, your commit e25520e has 1 failures in Build #69338 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33467

That installs a local version of the PR into your bun-33467 executable, so you can run:

bun-33467 --bun

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 25 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53c08d7e-bd33-4c6d-a251-bc06be776010

📥 Commits

Reviewing files that changed from the base of the PR and between 0d4c18a and e25520e.

📒 Files selected for processing (2)
  • src/js/internal/streams/readable.ts
  • test/js/node/stream/node-stream.test.js

Walkthrough

Adds an early-return guard to Readable.prototype.pause so calling pause() on an already-destroyed stream is a no-op, updates related inline comments describing divergence from Node 26, and adjusts/extends stream tests to cover the new destroyed-stream pause and pipe-unpipe semantics.

Changes

Pause No-Op on Destroyed Streams

Layer / File(s) Summary
pause() early-return implementation
src/js/internal/streams/readable.ts
Adds an early return in Readable.prototype.pause when the readable state is destroyed, and updates the resume() comment describing the intentional Node 26 divergence.
Stream test coverage for pause/pipe semantics
test/js/node/stream/node-stream.test.js
Adds tests confirming pause() no-ops on destroyed streams and that completed pipe unpiping triggers a no-op pause() on auto-destroyed sources, updating the divergence comment to focus on resume().
test-stream-destroy.js assertion updates
test/js/node/test/parallel/test-stream-destroy.js
Adds a pause() no-op assertion for destroyed streams and narrows the non-Bun conditional block to assert only resume() no-op behavior, removing the prior pause() expectation.

Suggested reviewers: alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: restoring the destroyed-stream guard in Readable.prototype.pause.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses custom section headings instead of the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found — the change is small and well-verified, but since it partially reverts a deliberate Node-26 divergence in core Readable.prototype.pause semantics, it's worth a maintainer confirming the fd-slicer reasoning before merge.

Extended reasoning...

Overview

This PR restores the kDestroyed early-return guard in Readable.prototype.pause() (src/js/internal/streams/readable.ts:1180-1182), which had been removed alongside the same guard in resume() by commit 6abf57e to keep fd-slicer/yauzl/extract-zip working. The resume() divergence stays; only pause() is brought back to upstream Node 26 behavior. Two new tests are added to node-stream.test.js and the vendored test-stream-destroy.js un-gates its pause() assertion.

Security risks

None. This is purely observable stream-state semantics (readableFlowing / isPaused() after a completed pipe). No auth, crypto, filesystem, or network trust boundaries are involved.

Level of scrutiny

Medium-high. The runtime change is a single 3-line early return that exactly matches upstream Node, and the verification is unusually thorough (truth table on Node v26.3.0, both new tests run verbatim on real Node, the existing fd-slicer regression test still passes, 700+ vendored stream/http/net tests show no new failures). However, Readable.prototype.pause() runs on every unpipe() of every completed pipe, and the original guard removal was a deliberate ecosystem-compat divergence. The argument that the pause() half of that removal was not load-bearing is convincing but subtle enough that whoever owns the streams-compat story should sign off.

Other factors

  • Bug hunter found nothing.
  • No CODEOWNERS coverage for this path.
  • Tests follow harness conventions (await observable conditions via 'unpipe'/'close', no sleeps, cover autoDestroy on and off plus the resume-only control).
  • The fd-slicer regression test in the same describe block is the load-bearing safety net for the original divergence and is untouched.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

it partially reverts a deliberate Node-26 divergence in core Readable.prototype.pause semantics, it's worth a maintainer confirming the fd-slicer reasoning before merge

Agreed that's the thing to check, so here is the evidence rather than the argument. The guard combination is not held together by reasoning, it is pinned by tests that fail in opposite directions:

Restoring the resume() guard (the half this PR leaves diverged) is caught by the existing fd-slicer regression test from 6abf57e. I applied it locally to confirm the net is live:

(fail) node v26 stream semantics > drain still resumes a source that flagged itself
       destroyed before EOF (fd-slicer pattern) [5017.63ms]
  ^ this test timed out after 5000ms.

Removing the pause() guard (the half this PR restores) is caught by the two new tests, which fail on main and pass here.

So a future change cannot quietly move either half without a red test. That is what makes the split safe to carry.

Why the pause() half was never load-bearing

pause() leaves kFlowing set when it early-returns. resume() then sees flowing already true, skips resume(this, state), and never reschedules flow(), so the buffer is stranded. The stranding comes from resume(), and pause()'s guard only changes which flags an already-destroyed stream reports. Emulating each variant against the fd-slicer pattern on Node v26.3.0:

variant bytes delivered
both guards (stock Node 26) 131072 / 171072 (truncated)
pause() guard only 131072 / 171072 (truncated)
resume() guard removed 171072 / 171072
both guards removed (Bun before this PR) 171072 / 171072

Rows 1 and 2 are identical, which is the whole point: adding back the pause() guard does not move the fd-slicer outcome.

Additional verification since opening

  • 278 vendored test-whatwg-webstreams* / test-webstream* / test-http2-* tests, run on this branch and on a baseline debug build of main. Failing sets are identical except test-http2-pipe.js, which is flaky on main (5/8 pass without this change) and passed 20/20 with it.
  • Bun.Cookie's Expires serialization is also reachable from pause()-free code paths, so to be explicit: the red test/js/bun/cookie/cookie-map.test.ts lane is unrelated. 028f210723 switched the output to an IMF-fixdate (Thu, 01 Jan 1970 00:00:00 GMT) while the test still expects Fri, 1 Jan 1970 00:00:00 -0000, which is not even the correct weekday for the epoch. It reproduces on a clean checkout and on unrelated branches' builds.
  • The binary-size annotation reports +0.0 KB on every target, as expected for a JS-only change.

@robobun
robobun force-pushed the farm/4d8ee6d9/readable-pause-destroyed-guard branch from 5991fb8 to 7daed39 Compare July 6, 2026 15:49
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Correction to my previous note on the red test/js/bun/cookie/cookie-map.test.ts lane: that was not an open breakage, it was already fixed on main by 48ff9eb (cookie: update remaining cookie-map Expires assertions to IMF-fixdate, #33425). This branch was cut from 9f29e55, one commit earlier, so it was carrying the stale assertions.

Rebased onto current main. The only commit that pulls in is 48ff9eb, which touches test/js/bun/cookie/cookie-map.test.ts and nothing else, so there is no overlap with this diff and it is byte-identical to what was reviewed. Locally that lane is now 33 pass / 0 fail, and the stream tests are unchanged and still green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/test/parallel/test-stream-destroy.js`:
- Around line 122-137: This test file section diverges from the upstream Node.js
version which reverted the destroyed-stream pause()/resume() no-op changes and
removed the associated tests. Remove the first pause() test block entirely (the
one without the Bun condition check). For the resume() test block that is
wrapped in the if (!('Bun' in globalThis)) condition, move this Bun-specific
test coverage to a Bun-owned test or expectations file instead of keeping it in
this Node mirror file, as it represents deliberate Bun divergence from Node
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6abf0401-da35-4153-a4bf-3bd6e3e9fc48

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 0d4c18a.

📒 Files selected for processing (3)
  • src/js/internal/streams/readable.ts
  • test/js/node/stream/node-stream.test.js
  • test/js/node/test/parallel/test-stream-destroy.js

Comment thread test/js/node/test/parallel/test-stream-destroy.js Outdated
….pause

A completed pipe unpipes its source, and Readable.prototype.unpipe calls
source.pause(). Node's pause() early-returns on a destroyed stream, so a
source that autoDestroy'd itself at 'end' keeps readableFlowing === true
and isPaused() === false. Bun paused it instead, so the post-pipe readable
state diverged on every completed pipe:

  const b = new PassThrough();
  b.pipe(new Writable({ write(c, e, cb) { cb(); } }));
  b.end("x");
  setTimeout(() => console.log(b.readableFlowing, b.isPaused()), 150);
  // node: true false
  // bun:  false true

6abf57e dropped the guard from both pause() and resume() so legacy
fd-slicer-style readables (yauzl, extract-zip, puppeteer), which assign
`this.destroyed = true` right before push(null), can still flush their
buffer when a piped destination drains. Only the resume() guard causes
that truncation: with the guard in place, pause() leaves flowing set, so
the later resume() sees flowing already true and never reschedules flow().
Restoring the guard in pause() alone keeps fd-slicer working and brings
the state flags back in line with Node.

Verified against Node v26.3.0: pipe-to-completion, pipe with
autoDestroy: false, manual unpipe of a live source, and pause()/resume()
on a destroyed stream now all report identical flags.
@robobun
robobun force-pushed the farm/4d8ee6d9/readable-pause-destroyed-guard branch from 0d4c18a to e25520e Compare July 6, 2026 20:53
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the diff is green on every lane it touches. Build #69338 finished with 284 passed / 2 failed, both macOS-only and both unrelated to node:stream.

  • darwin-26-aarch64 test-bun exited 1 before running any tests: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. Refusing to continue with a partial download. Pure Buildkite infra.
  • darwin-14-aarch64 test-bun: test/integration/next-pages/test/dev-server.test.ts failed because Puppeteer's browser launch could not connect to the Chrome DevTools WebSocket (Browser launch attempt {1,2,3}/3 failed: WebSocket connection ... Connection ended). Bun's ws module does not use node:stream at all (its pause()/resume() are local stubs on a private field), so Readable.prototype.pause is not on this code path.
  • The only test-level flaky annotation is test/js/bun/spawn/spawn-pipe-leak.test.ts, which passed after retries and is unrelated.
  • binary-size reports +0.0 KB on every target, as expected for a JS-only change.

This PR's own surface is confirmed green: test/js/node/stream/node-stream.test.js (89 pass locally and on CI), the vendored test/js/node/test/parallel/test-stream-destroy.js (untouched by this PR, still passes), the previously-red cookie-map.test.ts lane (resolved by rebasing onto current main), and a local sweep of the 249 vendored test-stream-*, 493 test-http-*/test-net-*/test-readline*/test-pipe*, and 278 webstreams/http2 tests showing no new failures vs a baseline debug build of main.

Leaving the two macOS lanes for a maintainer rather than pushing another retrigger. Happy to rebase again if a fresh main would re-roll them.

@Jarred-Sumner
Jarred-Sumner merged commit 2e2230a into main Jul 12, 2026
72 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/4d8ee6d9/readable-pause-destroyed-guard branch July 12, 2026 10:18
Jarred-Sumner pushed a commit that referenced this pull request Jul 12, 2026
## Repro

```js
import { Readable } from "node:stream";
const src = Readable.from([Buffer.from("a"), Buffer.from("b")], { objectMode: false });
const rd = Readable.toWeb(src).getReader();
for (;;) { const { done } = await rd.read(); if (done) break; }
await new Promise(r => setTimeout(r, 30));
console.log(`flowing=${src.readableFlowing} isPaused=${src.isPaused()}`);
```

```
node v26.3.0: flowing=false isPaused=true
bun 1.4.0:    flowing=true  isPaused=false
```

`readableEnded` / `destroyed` agree; only `readableFlowing` /
`isPaused()` diverge, so code that branches on those after the adapter
finishes (pooling, reuse logic, diagnostics) observes the wrong state.

## Cause

The toWeb adapter's `pull()` calls `source.resume()`. After the source
emits `'end'` and autoDestroys, Node 26's `resume()` is a no-op on
destroyed streams (nodejs/node#62557), so the late `pull()`s leave the
source non-flowing.

Bun dropped that guard in 6abf57e so that fd-slicer-style readables
(yauzl / extract-zip / puppeteer), which assign `this.destroyed = true`
right before `push(null)`, can still be resumed by a piped destination's
`'drain'` to flush their buffered tail. With no guard at all, the
adapter's post-EOF `resume()` flips `readableFlowing` back to `true`.

## Fix

Narrow the `resume()` guard to `kDestroyed && kEndEmitted` instead of
removing it entirely. In the fd-slicer case `'end'` has not yet been
emitted when drain resumes the source, so the narrowed guard does not
fire and the buffered tail still flushes. Once `'end'` has fired there
is nothing left to flush, so `resume()` can become a no-op and keep
state parity with Node.

`pause()` is left unchanged; #33467 addresses the mirrored post-pipe
divergence on the `pause()` side.

## Verification

- New test `Readable.toWeb leaves the source paused / non-flowing after
EOF` fails on main (`readableFlowing: true, isPaused: false`) and passes
with this change; the same test body passes on Node v26.3.0.
- The existing fd-slicer regression test (`drain still resumes a source
that flagged itself destroyed before EOF`) still passes.
- `test/js/node/stream/` and the vendored `test-stream-*.js` parallel
suite: no new failures relative to main.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/stream/node-stream.test.js

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants