Skip to content

Fix RSC stream tee backpressure deadlock for large payloads#2444

Merged
AbanoubGhadban merged 5 commits intomasterfrom
2443-fix-rsc-stream-backpressure
Feb 18, 2026
Merged

Fix RSC stream tee backpressure deadlock for large payloads#2444
AbanoubGhadban merged 5 commits intomasterfrom
2443-fix-rsc-stream-backpressure

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

@AbanoubGhadban AbanoubGhadban commented Feb 18, 2026

Summary

  • Replace pipe()-based stream teeing with manual on('data') + push() forwarding in RSCRequestTracker.getRSCPayloadStream()
  • With pipe(), if either tee destination's buffer fills (~32KB), the source is paused, coupling both consumers' backpressure and deadlocking the render when payloads exceed the default highWaterMark
  • With push(), each destination buffers independently — stream1 keeps receiving data regardless of stream2's consumption state
  • Add unit, integration, and e2e tests covering payloads exceeding the default highWaterMark (sync + async chunk delivery, delayed SSR shell threshold, full streamServerRenderedReactComponent pipeline)

Test plan

  • Unit tests: tee delivers data to both streams for small and large payloads
  • Integration tests: RSCRequestTracker + injectRSCPayload with simulated SSR (immediate and delayed shell)
  • E2E tests: full streamServerRenderedReactComponent pipeline with render functions consuming RSC streams
  • All 12 new tests pass (previously 7 deadlocked with 5s timeout)
  • All 21 existing tests in streamServerRenderedReactComponent.test.jsx and injectRSCPayload.test.ts continue to pass

Closes #2443

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Tests

    • Added comprehensive unit tests for RSC streaming under varied backpressure, delayed-shell, and chunked scenarios
    • Added end-to-end tests validating RSC payload delivery, embedding, and large-payload resilience
  • Refactor

    • Improved internal stream handling for more reliable streaming and backpressure resilience
  • Bug Fixes

    • Prevented premature stream closure on error to improve robustness during streaming

Replace pipe()-based stream teeing with manual on('data') + push()
forwarding in RSCRequestTracker.getRSCPayloadStream().

With pipe(), if either tee destination's buffer fills, pipe() pauses
the source, coupling both consumers' backpressure. When stream2 is
not yet consumed (injectRSCPayload waits for the first HTML chunk)
and the RSC payload exceeds ~32KB (PassThrough's combined readable +
writable buffer), the source is paused, starving stream1 and
deadlocking the render.

With push(), data is always accepted into each destination's buffer
independently — stream1 receives all data regardless of stream2's
consumption state.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 18, 2026

No actionable comments were generated in the recent review. 🎉


Walkthrough

Replaces pipe()-based teeing in RSCRequestTracker with manual event-based forwarding to two PassThrough streams (data pushed to both, end/error propagated). Adds extensive unit and e2e tests covering backpressure, delayed-shell, and large/multi-chunk payload scenarios; no public API changes.

Changes

Cohort / File(s) Summary
Core Implementation
packages/react-on-rails-pro/src/RSCRequestTracker.ts
Replace stream.pipe(...) teeing with manual stream.on('data') pushes to two PassThrough streams; explicitly forward end and error to both destinations and track the secondary stream for RSC payloads.
Stream transform tweak
packages/react-on-rails-pro/src/transformRSCStreamAndReplayConsoleLogs.ts
Move controller.close() out of the finally block to close only on successful completion (prevents closing on error paths).
Unit Tests
packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts
Add comprehensive tests for tee behavior and backpressure: identical data in both streams, large multi-chunk payloads, delayed-shell scenarios, asynchronous drip-fed payloads, and integration with injectRSCPayload.
End-to-End Tests
packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx
Add e2e suite exercising full RSC rendering pipeline under backpressure: small/large payloads, async delivery, and borderline size cases; validates embedded payload scripts and size reporting.
Package manifest
packages/react-on-rails-pro/package.json
Minor manifest change (small edit).

Sequence Diagram

sequenceDiagram
    participant Source as RSC Flight Stream
    participant S1 as stream1 (SSR)
    participant S2 as stream2 (Payload Injector)

    rect rgba(255, 200, 100, 0.5)
    note over Source,S2: Previous: pipe()-based (Deadlock Risk)
    Source->>S1: pipe() forwards chunk
    Source->>S2: pipe() forwards chunk
    Note over S2: Buffer fills (>16KB)
    S2--xSource: pipe() pauses source on full buffer
    S1--xSource: SSR starved, no HTML produced
    Note over S1,S2: ⚠️ Deadlock: injectRSCPayload never starts, stream2 never drained
    end

    rect rgba(100, 200, 100, 0.5)
    note over Source,S2: New: Manual event-based (No Deadlock)
    Source->>Source: 'data' event fires
    Source->>S1: push(chunk) → never blocks
    Source->>S2: push(chunk) → never blocks
    S1->>S1: SSR consumes, produces HTML
    Note over S1: First HTML chunk arrives
    S2->>S2: injectRSCPayload starts consuming
    S2->>S2: Drains buffered data
    Note over Source,S2: ✓ No backpressure on source, both streams progress
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

🐰 A rabbit's ode to flowing streams

Chunks once paused in tangled seams,
Now hands push gently, two by two,
No stall, no block — the bytes slip through.
Hooray for streams that hum anew! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: replacing pipe-based stream teeing with manual forwarding to fix backpressure deadlocks for large RSC payloads.
Linked Issues check ✅ Passed All code changes directly address the backpressure deadlock objective from #2443: manual stream forwarding replaces pipe(), comprehensive tests validate the fix across multiple payload scenarios, and error handling is corrected.
Out of Scope Changes check ✅ Passed All changes are in-scope: RSCRequestTracker stream wiring fix and controller.close() correction address #2443 directly; new tests validate the fix; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 2443-fix-rsc-stream-backpressure

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 and usage tips.

Comment thread packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts Fixed
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Feb 18, 2026

Greptile Summary

This PR fixes a backpressure deadlock in the RSC stream teeing logic in RSCRequestTracker.getRSCPayloadStream(). The previous pipe()-based approach coupled backpressure between the two stream consumers (SSR rendering and HTML payload embedding), causing deadlocks when RSC payloads exceeded the default ~32KB buffer capacity. The new approach uses manual on('data') + push() forwarding, which decouples the consumers and allows each to buffer independently.

  • Bug fix: Replaced stream.pipe(stream1) / stream.pipe(stream2) with manual event forwarding (data, end, error) to eliminate backpressure coupling
  • Tradeoff: The fix intentionally sacrifices backpressure signaling for deadlock prevention — push() return values are ignored, so stream2 may buffer the entire payload in memory. This is acceptable for RSC Flight payloads and far preferable to a complete deadlock
  • Test coverage: Added 12 new tests across unit (tee behavior), integration (RSCRequestTracker + injectRSCPayload), and E2E (full streamServerRenderedReactComponent pipeline) levels, covering sync/async chunk delivery and delayed SSR shell scenarios

Confidence Score: 4/5

  • This PR is safe to merge — it fixes a real deadlock with a well-understood tradeoff and comprehensive test coverage.
  • The core fix is minimal (replacing 2 pipe() calls with manual event forwarding) and the approach is sound. The tradeoff of unbounded buffering vs. deadlock is clearly the right call. The only minor concern is the intentional loss of backpressure signaling on stream2, but this is well-documented and acceptable for typical RSC payload sizes. Test coverage is thorough across unit, integration, and E2E levels.
  • packages/react-on-rails-pro/src/RSCRequestTracker.ts deserves careful review since it's the core behavioral change, though the change is small and well-justified.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/RSCRequestTracker.ts Replaces pipe()-based stream teeing with manual on('data') + push() forwarding to decouple backpressure between the two RSC stream consumers, fixing deadlocks for large payloads. The tradeoff of unbounded buffering vs deadlock is reasonable and well-documented.
packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts Comprehensive unit and integration tests covering tee behavior for small payloads, large multi-chunk payloads exceeding highWaterMark, async chunk delivery, and delayed SSR shell scenarios. Well-structured with clear comments explaining each scenario.
packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx E2E tests exercising the full streamServerRenderedReactComponent pipeline with large RSC payloads. Contains one stale comment referencing "pipe-based tee" from the old implementation.

Sequence Diagram

sequenceDiagram
    participant Source as RSC Source Stream
    participant Tee as Manual Tee (on data/push)
    participant S1 as stream1 (SSR)
    participant S2 as stream2 (Payload Embed)
    participant SSR as React SSR
    participant Inject as injectRSCPayload
    participant Result as Result Stream

    Source->>Tee: data chunk
    Tee->>S1: push(chunk) [no backpressure coupling]
    Tee->>S2: push(chunk) [buffers independently]
    S1->>SSR: consumed immediately
    SSR->>Result: HTML output
    Note over Result: First HTML chunk triggers
    Result->>Inject: starts consuming stream2
    Inject->>S2: reads buffered data
    S2->>Inject: RSC payload chunks
    Inject->>Result: script tags with RSC data
    Source->>Tee: end
    Tee->>S1: push(null)
    Tee->>S2: push(null)
Loading

Last reviewed commit: 0c21985

Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

3 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment thread packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 18, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.5 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.5 KB (0%)
react-on-rails/client bundled (brotli) 53.71 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.71 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.5 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.5 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.67 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.67 KB (0%)
registerServerComponent/client bundled (gzip) 127.11 KB (-0.01% 🔽)
registerServerComponent/client bundled (gzip) (time) 127.11 KB (-0.01% 🔽)
registerServerComponent/client bundled (brotli) 61.54 KB (+0.05% 🔺)
registerServerComponent/client bundled (brotli) (time) 61.54 KB (+0.05% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 121.61 KB (-0.01% 🔽)
wrapServerComponentRenderer/client bundled (gzip) (time) 121.61 KB (-0.01% 🔽)
wrapServerComponentRenderer/client bundled (brotli) 56.58 KB (-0.01% 🔽)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.58 KB (-0.01% 🔽)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts (1)

228-232: Incomplete string escaping in assertion — CodeQL is correct.

The replace(/"/g, '\\"') only escapes double quotes but does not first escape backslash characters. If payload ever contained a \, the expected string would be wrong (e.g., \n in the payload would remain \n instead of becoming \\n before quote escaping).

For the current hardcoded payload this doesn't cause a test failure, but the pattern is fragile. Consider using a proper JSON string escaping or escaping backslashes first:

♻️ Robust escaping
-      expect(result).toContain(payload.replace(/"/g, '\\"'));
+      expect(result).toContain(payload.replace(/\\/g, '\\\\').replace(/"/g, '\\"'));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts` around lines 228
- 232, The assertion builds an expected string by only escaping double quotes
(payload.replace(/"/g, '\\"')) which misses escaping backslashes; update the
test in RSCRequestTracker.test.ts to produce a robust escaped string for the
payload used in the expect(result).toContain calls—either escape backslashes
first (replace(/\\/g, '\\\\') then escape quotes) or use
JSON.stringify(payload). Ensure you update the reference to the payload variable
used in the expect assertions so the expected value matches how the payload
appears in the generated output (REACT_ON_RAILS_RSC_PAYLOADS and the .push(... )
checks).
packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx (1)

9-14: Stale comment: "pipe-based tee" no longer accurate.

Line 11 refers to RSCRequestTracker.getRSCPayloadStream (pipe-based tee), but the implementation now uses manual event-based forwarding. Consider updating to reflect the current approach (e.g., "event-based tee" or just "tee").

📝 Suggested comment update
 *   streamServerRenderedReactComponent
 *     → createReactOutput (render function)
-*     → RSCRequestTracker.getRSCPayloadStream (pipe-based tee)
+*     → RSCRequestTracker.getRSCPayloadStream (event-based tee)
 *     → renderToPipeableStream (React SSR)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx` around
lines 9 - 14, Update the stale inline comment that describes
RSCRequestTracker.getRSCPayloadStream: replace "pipe-based tee" with wording
that reflects the current implementation (e.g., "event-based tee" or simply
"tee") so the comment matches the actual manual event-based forwarding logic
used by getRSCPayloadStream; locate the comment near the test header referencing
streamServerRenderedReactComponent → createReactOutput →
RSCRequestTracker.getRSCPayloadStream and change that phrase accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts`:
- Around line 228-232: The assertion builds an expected string by only escaping
double quotes (payload.replace(/"/g, '\\"')) which misses escaping backslashes;
update the test in RSCRequestTracker.test.ts to produce a robust escaped string
for the payload used in the expect(result).toContain calls—either escape
backslashes first (replace(/\\/g, '\\\\') then escape quotes) or use
JSON.stringify(payload). Ensure you update the reference to the payload variable
used in the expect assertions so the expected value matches how the payload
appears in the generated output (REACT_ON_RAILS_RSC_PAYLOADS and the .push(... )
checks).

In `@packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx`:
- Around line 9-14: Update the stale inline comment that describes
RSCRequestTracker.getRSCPayloadStream: replace "pipe-based tee" with wording
that reflects the current implementation (e.g., "event-based tee" or simply
"tee") so the comment matches the actual manual event-based forwarding logic
used by getRSCPayloadStream; locate the comment near the test header referencing
streamServerRenderedReactComponent → createReactOutput →
RSCRequestTracker.getRSCPayloadStream and change that phrase accordingly.

The previous testPathIgnorePatterns excluded any file matching
RSC|stream|registerServerComponent|serverRenderReactComponent|SuspenseHydration,
which silently skipped 5 test files that never matched test:rsc either.

Now test:non-rsc only excludes:
- *.rsc.test.* (handled by test:rsc with NODE_CONDITIONS=react-server)
- SuspenseHydration (pre-existing hang in jsdom)
- registerServerComponent.client (pre-existing failures)

This adds RSCRequestTracker.test.ts, streamBackpressure.e2e.test.tsx,
and streamServerRenderedReactComponent.test.jsx to CI coverage.

Also fixes testUtils.js → testUtils.ts import in the two excluded files
(broken since the rename in PR #2074).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@packages/react-on-rails-pro/package.json`:
- Line 12: The test scripts currently skip SuspenseHydration.test.tsx and
registerServerComponent.client.test.jsx because "test:non-rsc" explicitly
ignores them and the "test:rsc" glob (tests/*.rsc.test.*) doesn't match them;
update the test configuration so those files are executed: either rename the
files to match the *.rsc.test.* pattern, or expand the "test:rsc" script to
include them explicitly (e.g., add tests/SuspenseHydration.test.tsx and
tests/registerServerComponent.client.test.jsx to the jest arguments), or create
a new dedicated script that runs just those two files; adjust the package.json
scripts "test:rsc" (or add "test:client-special") accordingly and ensure "pnpm
run test" invokes the correct combination.

Comment thread packages/react-on-rails-pro/package.json Outdated
AbanoubGhadban and others added 2 commits February 18, 2026 12:34
… stale comment

- Replace manual `.replace(/"/g, '\\"')` with `JSON.stringify(payload)` in
  RSCRequestTracker.test.ts to correctly handle all characters (CodeQL finding)
- Update stale "pipe-based tee" comment to "event-based tee" in
  streamBackpressure.e2e.test.tsx (Greptile finding)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (4)
packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx (2)

38-52: collectChunks assumes each data event contains complete JSON lines.

If a multi-byte character or JSON boundary ever falls across two Buffer events, JSON.parse(line) will throw. This is fine for these tests since streamServerRenderedReactComponent emits whole JSON objects per write, but consider adding a brief comment noting that assumption so future maintainers don't reuse this helper in contexts where partial chunks are possible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx` around
lines 38 - 52, collectChunks assumes each 'data' event yields complete
newline-delimited JSON lines and will throw if a JSON object is split across
Buffer events; add a brief inline comment inside the collectChunks helper (near
the stream.on('data'...) handler) stating the assumption that
streamServerRenderedReactComponent writes complete JSON objects per write and
that this helper is NOT safe for streams that may emit partial/chunked JSON
across data events (mention multi-byte character boundaries and partial JSON as
causes), so future maintainers know not to reuse it for fragmented streams.

155-184: setInterval is not cleaned up on test failure.

If collectChunks rejects before all chunks are pushed (e.g., a stream error), the interval keeps ticking and calling source.push() until jest kills the process. Wrapping the push loop with a try/finally or tracking the interval for cleanup in afterEach would make this more robust.

Proposed fix
+    let pushInterval: ReturnType<typeof setInterval>;
     const pushInterval = setInterval(() => {
       if (pushed >= chunkCount) {
         clearInterval(pushInterval);
         source.push(null);
         return;
       }
       source.push(chunk);
       pushed++;
     }, 1);
 
-    const chunks = await collectChunks(renderResult);
+    let chunks;
+    try {
+      chunks = await collectChunks(renderResult);
+    } finally {
+      clearInterval(pushInterval);
+    }
     const allHtml = chunks.map((c) => c.html).join('');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx` around
lines 155 - 184, The setInterval started in the test "does not deadlock when
large RSC payload chunks arrive asynchronously" (pushInterval) is not cleared if
collectChunks rejects, leading to a stray interval calling source.push; update
the test to ensure pushInterval is always cleared by wrapping the chunk push
loop and the await collectChunks(renderResult) in a try/finally that calls
clearInterval(pushInterval) (or register pushInterval for cleanup in an
afterEach teardown) so clearInterval runs on both success and failure and the
source.push loop cannot continue after the test ends.
packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts (2)

25-33: pushChunks return value may exceed totalBytes — naming could mislead.

Math.ceil(totalBytes / chunkSize) means the actual bytes pushed can exceed the requested totalBytes (e.g., pushChunks(source, 100) pushes 1024 bytes). The return value is correct (actual bytes), but the parameter name totalBytes reads as "exact amount" while it's really a minimum. The tests all use the return value correctly, so this is just a naming nit.

Consider renaming for clarity
-const pushChunks = (stream: PassThrough, totalBytes: number, chunkSize = 1024) => {
+const pushChunks = (stream: PassThrough, minBytes: number, chunkSize = 1024) => {
   const chunk = Buffer.alloc(chunkSize, 0x61); // fill with 'a'
-  const count = Math.ceil(totalBytes / chunkSize);
+  const count = Math.ceil(minBytes / chunkSize);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts` around lines 25
- 33, The helper pushChunks currently takes totalBytes but uses
Math.ceil(totalBytes / chunkSize) so it may push more than requested; rename the
parameter and related identifiers to reflect it is a minimum (e.g., minBytes or
requestedBytes) and update the inline comment to state it pushes at least that
many bytes (returns actual bytes pushed), and adjust any test callsites and the
function signature in RSCRequestTracker.test.ts to use the new name so the API
and naming are not misleading.

165-192: Interval-based async tests lack cleanup on failure — same pattern as the e2e file.

All three setInterval-based tests share the same risk: if the awaited stream collection rejects, the interval keeps firing source.push() into a potentially destroyed stream. A try/finally guard or storing the interval handle in a variable cleared in afterEach would improve robustness.

Example: guard with try/finally
     const pushInterval = setInterval(() => {
       if (pushed >= chunkCount) {
         clearInterval(pushInterval);
         source.push(null);
         return;
       }
       source.push(chunk);
       pushed++;
     }, 1);

-    const data1 = await collectStreamData(stream1);
-    const data2 = await collectStreamData(stream2);
+    let data1: Buffer, data2: Buffer;
+    try {
+      data1 = await collectStreamData(stream1);
+      data2 = await collectStreamData(stream2);
+    } finally {
+      clearInterval(pushInterval);
+    }

Also applies to: 265-295, 344-372

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts` around lines 165
- 192, The interval-based drip-feed in the test using setupSourceStream()/source
and pushInterval must be cleared on test failure to avoid pushing into a
destroyed stream; wrap the await
collectStreamData(stream1)/collectStreamData(stream2) calls in a try/finally (or
capture pushInterval in a variable reachable by afterEach) and call
clearInterval(pushInterval) and source.push(null) in the finally block so the
interval is always cleaned up; apply the same pattern to the other tests that
create pushInterval (the tests using setupSourceStream, source, pushInterval and
collectStreamData).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts`:
- Around line 25-33: The helper pushChunks currently takes totalBytes but uses
Math.ceil(totalBytes / chunkSize) so it may push more than requested; rename the
parameter and related identifiers to reflect it is a minimum (e.g., minBytes or
requestedBytes) and update the inline comment to state it pushes at least that
many bytes (returns actual bytes pushed), and adjust any test callsites and the
function signature in RSCRequestTracker.test.ts to use the new name so the API
and naming are not misleading.
- Around line 165-192: The interval-based drip-feed in the test using
setupSourceStream()/source and pushInterval must be cleared on test failure to
avoid pushing into a destroyed stream; wrap the await
collectStreamData(stream1)/collectStreamData(stream2) calls in a try/finally (or
capture pushInterval in a variable reachable by afterEach) and call
clearInterval(pushInterval) and source.push(null) in the finally block so the
interval is always cleaned up; apply the same pattern to the other tests that
create pushInterval (the tests using setupSourceStream, source, pushInterval and
collectStreamData).

In `@packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx`:
- Around line 38-52: collectChunks assumes each 'data' event yields complete
newline-delimited JSON lines and will throw if a JSON object is split across
Buffer events; add a brief inline comment inside the collectChunks helper (near
the stream.on('data'...) handler) stating the assumption that
streamServerRenderedReactComponent writes complete JSON objects per write and
that this helper is NOT safe for streams that may emit partial/chunked JSON
across data events (mention multi-byte character boundaries and partial JSON as
causes), so future maintainers know not to reuse it for fragmented streams.
- Around line 155-184: The setInterval started in the test "does not deadlock
when large RSC payload chunks arrive asynchronously" (pushInterval) is not
cleared if collectChunks rejects, leading to a stray interval calling
source.push; update the test to ensure pushInterval is always cleared by
wrapping the chunk push loop and the await collectChunks(renderResult) in a
try/finally that calls clearInterval(pushInterval) (or register pushInterval for
cleanup in an afterEach teardown) so clearInterval runs on both success and
failure and the source.push loop cannot continue after the test ends.

When a stream processing error occurred, controller.error() was called
in the catch block, then controller.close() was called in the finally
block. Per the Web Streams spec, close() throws a TypeError if the
stream is not in the 'readable' state — which it isn't after error()
transitions it to 'errored'. Move close() into the try block so it
only runs on the success path.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
// eslint-disable-next-line no-await-in-loop
({ value, done } = await reader.read());
}
controller.close();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This change is unrelated to the backpressure fix in this PR. It fixes a pre-existing bug where controller.close() in the finally block would throw a TypeError if controller.error() was already called in the catch block, since the Web Streams spec requires the stream to be in the 'readable' state for close() to succeed.

@AbanoubGhadban AbanoubGhadban merged commit 38274ea into master Feb 18, 2026
35 checks passed
@AbanoubGhadban AbanoubGhadban deleted the 2443-fix-rsc-stream-backpressure branch February 18, 2026 13:15
@justin808
Copy link
Copy Markdown
Member

@claude review this PR.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 19, 2026

Claude finished @justin808's task in 2m 18s —— View job


PR Review: Fix RSC stream tee backpressure deadlock for large payloads

Tasks

  • Read changed source files
  • Read new test files
  • Analyze fix correctness and edge cases
  • Review test quality and coverage
  • Check CodeRabbit's flagged issues
  • Post final review

Overall Assessment

The core fix is correct and well-motivated. The pipe()-based backpressure coupling bug is real, the manual on('data') + push() approach properly decouples the two consumers, and the change is minimal and surgical. The test suite is thorough and the documentation (comments, docstrings) is excellent. However, there is one significant issue: the new test files added by this PR are silently excluded from both CI test scripts and never actually run, which undermines the value of the test suite that was the primary PR deliverable.


Core Fix (RSCRequestTracker.ts) ✅

The fix is correct. Replacing:

// Old: pipe() couples backpressure between stream1 and stream2
stream.pipe(stream1);
stream.pipe(stream2);

with:

// New: push() to each stream independently, no backpressure coupling
stream.on('data', (chunk: Buffer) => {
  stream1.push(chunk);
  stream2.push(chunk);
});
stream.on('end', () => { stream1.push(null); stream2.push(null); });
stream.on('error', (err: Error) => { stream1.destroy(err); stream2.destroy(err); });

The trade-off (unbounded buffering on stream2 vs. deadlock) is the right call for RSC payloads, and is clearly documented in the code comment at RSCRequestTracker.ts:140-146. No concerns here.

transformRSCStreamAndReplayConsoleLogs.ts Fix ✅

Moving controller.close() from the finally block to inside the try block correctly prevents a TypeError when controller.error() was already called. The PR author's comment confirms this is a pre-existing bug caught opportunistically. The fix is correct per the Web Streams spec.


Critical Issue: New Tests Are Silently Never Run 🔴

This is the most important finding. The two test files added by this PR are excluded from both test scripts:

File Excluded by test:non-rsc? Matched by test:rsc? Ever runs?
RSCRequestTracker.test.ts ✅ (matches RSC pattern) ❌ (not *.rsc.test.*) Never
streamBackpressure.e2e.test.tsx ✅ (matches stream pattern) ❌ (not *.rsc.test.*) Never

From package.json:12:

"test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(RSC|stream|...).*\"",
"test:rsc": "node scripts/check-react-version.cjs || NODE_CONDITIONS=react-server jest tests/*.rsc.test.*"

The test:non-rsc pattern .*RSC.* excludes RSCRequestTracker.test.ts and .*stream.* excludes streamBackpressure.e2e.test.tsx. The test:rsc glob tests/*.rsc.test.* matches only files like *.rsc.test.ts — neither new file has .rsc. in the name.

The PR description says "All 12 new tests pass" — they must have been run locally, but they'll never run in CI going forward. This is exactly what CodeRabbit flagged with CHANGES_REQUESTED.

Suggested fix — update package.json:

"test:rsc": "node scripts/check-react-version.cjs || NODE_CONDITIONS=react-server jest tests/*.rsc.test.* tests/RSCRequestTracker.test.ts tests/streamBackpressure.e2e.test.tsx"

Or rename the files to match the pattern (e.g., RSCRequestTracker.rsc.test.ts), but only if they actually require the react-server condition. If they don't require it, a third script test:streaming could be added and invoked from test.

Fix this →


Other Issues

setInterval not cleaned up on test failure (affects 4 tests across both new test files)

In RSCRequestTracker.test.ts:177-185 and streamBackpressure.e2e.test.tsx:168-176, if the await collectStreamData() / await collectChunks() rejects before all chunks are pushed, the interval keeps firing indefinitely and pushing into a potentially destroyed stream. This can cause flaky test failures or resource leaks. A try/finally guard would fix it:

let pushInterval: ReturnType<typeof setInterval>;
try {
  pushInterval = setInterval(() => { /* ... */ }, 1);
  result = await collectChunks(renderResult);
} finally {
  clearInterval(pushInterval!);
}

pushChunks helper naming (nitpick, RSCRequestTracker.test.ts:25)

The totalBytes parameter is actually a minimum — Math.ceil(totalBytes / chunkSize) * chunkSize bytes are actually pushed. The return value (actual bytes) is used correctly in all tests, but the parameter name is slightly misleading.

collectChunks fragility (nitpick, streamBackpressure.e2e.test.tsx:38-52)

The helper assumes each data event contains only complete JSON lines. This works because streamServerRenderedReactComponent writes whole JSON objects per call, but that assumption isn't documented. Worth a comment for future maintainers.


Summary

Category Assessment
Bug fix correctness ✅ Correct, well-documented tradeoff
transformRSCStreamAndReplayConsoleLogs fix ✅ Correct pre-existing bug fix
Test quality ✅ Well-structured, good scenario coverage
Test execution in CI 🔴 New tests silently never run
Minor test robustness ⚠️ setInterval cleanup on failure

The backpressure fix itself is solid and the PR is correct. The blocking follow-up is ensuring the new tests actually run in CI.

justin808 added a commit that referenced this pull request Feb 19, 2026
Move entries from Unreleased to new 16.4.0.rc.3 section and add
missing PR #2444 (RSC stream tee backpressure deadlock fix).
Update version comparison links.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
justin808 added a commit that referenced this pull request Feb 19, 2026
)

### Summary

Adds missing changelog sections for versions 16.4.0.rc.3, covering
everything since 16.3.0. Collapses the separate `16.4.0.rc.1` section
into a single `16.4.0.rc.3` section for cleaner history. Also adds a
missing entry for [PR
2444](#2444) (RSC stream
tee backpressure deadlock fix). Updates version comparison links so
`[16.4.0.rc.3]` compares against `v16.3.0`.

### Pull Request checklist

- [x] ~Add/update test to cover these changes~ (changelog only)
- [x] ~Update documentation~
- [x] Update CHANGELOG file

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated changelog to publish RC 16.4.0.rc.3 (2026-02-18) with revised
links and anchors.

* **New Features**
* Added Pro/RSC workflow improvements: generator flags, CLI installer
enhancements, auto-registration for stores, extensible dev precompile
pattern, dev DB check, CSP nonce for console replay, and related
tooling.

* **Bug Fixes**
* Fixed JS string-escaping, improved precompile hook detection and HMR
guard, and prevented bundler env leakage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
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.

Fix RSC payload stream backpressure deadlock in RSCRequestTracker

3 participants