Fix RSC stream tee backpressure deadlock for large payloads#2444
Fix RSC stream tee backpressure deadlock for large payloads#2444AbanoubGhadban merged 5 commits intomasterfrom
Conversation
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]>
|
No actionable comments were generated in the recent review. 🎉 WalkthroughReplaces 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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 |
Greptile SummaryThis PR fixes a backpressure deadlock in the RSC stream teeing logic in
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
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)
Last reviewed commit: 0c21985 |
size-limit report 📦
|
There was a problem hiding this comment.
🧹 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. Ifpayloadever contained a\, the expected string would be wrong (e.g.,\nin the payload would remain\ninstead of becoming\\nbefore 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]>
There was a problem hiding this comment.
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.
…ed tests" This reverts commit 1c980b4.
… 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]>
There was a problem hiding this comment.
🧹 Nitpick comments (4)
packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx (2)
38-52:collectChunksassumes eachdataevent contains complete JSON lines.If a multi-byte character or JSON boundary ever falls across two
Bufferevents,JSON.parse(line)will throw. This is fine for these tests sincestreamServerRenderedReactComponentemits 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:setIntervalis not cleaned up on test failure.If
collectChunksrejects before all chunks are pushed (e.g., a stream error), the interval keeps ticking and callingsource.push()until jest kills the process. Wrapping the push loop with a try/finally or tracking the interval for cleanup inafterEachwould 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:pushChunksreturn value may exceedtotalBytes— naming could mislead.
Math.ceil(totalBytes / chunkSize)means the actual bytes pushed can exceed the requestedtotalBytes(e.g.,pushChunks(source, 100)pushes 1024 bytes). The return value is correct (actual bytes), but the parameter nametotalBytesreads 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 firingsource.push()into a potentially destroyed stream. Atry/finallyguard or storing the interval handle in a variable cleared inafterEachwould 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(); |
There was a problem hiding this comment.
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.
|
@claude review this PR. |
|
Claude finished @justin808's task in 2m 18s —— View job PR Review: Fix RSC stream tee backpressure deadlock for large payloadsTasks
Overall AssessmentThe core fix is correct and well-motivated. The Core Fix (
|
| 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.
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.
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]>
) ### 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]>
Summary
pipe()-based stream teeing with manualon('data')+push()forwarding inRSCRequestTracker.getRSCPayloadStream()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 highWaterMarkpush(), each destination buffers independently — stream1 keeps receiving data regardless of stream2's consumption statestreamServerRenderedReactComponentpipeline)Test plan
streamServerRenderedReactComponentpipeline with render functions consuming RSC streamsstreamServerRenderedReactComponent.test.jsxandinjectRSCPayload.test.tscontinue to passCloses #2443
🤖 Generated with Claude Code
Summary by CodeRabbit
Tests
Refactor
Bug Fixes