Fix streaming SSR hangs and silent error absorption in RSC payload injection#2407
Fix streaming SSR hangs and silent error absorption in RSC payload injection#2407
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdd explicit end-on-close/error handling across Node renderer and RSC streams: composite error listeners now forward errors and explicitly end downstream PassThrough/result streams when appropriate; tests, fixtures, demo routes/components, and a Ruby helper were added to exercise and prevent hung HTTP responses on upstream stream errors. (37 words) Changes
Sequence Diagram(s)sequenceDiagram
participant Source as Readable (source)
participant Handler as handleStreamError
participant Dest as PassThrough (destination)
participant Server as Fastify / HTTP Response
participant Reporter as ErrorReporter
Note over Source,Dest: Normal flow: source -> pipe -> PassThrough -> HTTP response
Source->>Handler: provide stream & attach listeners
Handler->>Dest: create PassThrough and pipe source -> PassThrough
Source->>Dest: data chunks
Dest->>Server: stream chunks to client
Note over Source,Reporter: On source error
Source-->>Handler: 'error' event (err)
Handler->>Reporter: call onError(err)
alt source.destroyed && !dest.readableEnded
Handler->>Dest: end() destination (rgba(255,0,0,0.5))
Dest->>Server: end response
else
Handler->>Dest: keep destination open (rgba(255,165,0,0.5))
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ 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 |
size-limit report 📦
|
Greptile SummaryThis PR addresses two real and well-diagnosed production issues in the streaming SSR pipeline: (1) responses hanging forever when a source stream is destroyed because The core fix is a new Key changes and observations:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant React as React renderToPipeableStream
participant PHS as pipeableHtmlStream
participant HS as htmlStream (PassThrough)
participant RSC as RSCRequestTracker
participant SC as startRSC()
participant RS as resultStream (PassThrough)
participant FW as Fastify / handleStreamError
Note over PHS,HS: safePipe(pipeableHtmlStream, htmlStream, onError)
PHS->>HS: pipe() + 'close' handler
React->>PHS: renders HTML chunks
PHS->>HS: data chunks
HS->>SC: first 'data' triggers startRSC()
SC->>RSC: onRSCPayloadGenerated callback
RSC-->>SC: stream2 (RSC payload chunks)
SC->>RS: push RSC init scripts + HTML + RSC payload chunks
alt Normal end
PHS-->>HS: pipe forwards 'end'
HS->>SC: 'close' resolves promise
SC->>SC: await Promise.allSettled(rscPromises)
SC->>RS: endResultStream()
else Source destroyed (error)
PHS->>HS: safePipe 'close' → destination.end()
PHS--xRS: onError callback → resultStream.emit('error')
HS->>SC: 'close' resolves promise
SC->>RS: endResultStream()
end
RS->>FW: PassThrough firewall (errors never reach Fastify)
Note over RS,FW: handleStreamError wraps RS in PassThrough
Last reviewed commit: 7e28e58 |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/react-on-rails-pro/src/injectRSCPayload.ts (1)
327-348:⚠️ Potential issue | 🟠 Major
cleanup()can double-endresultStreamwhen an RSC promise rejects after HTML finishes.Scenario:
htmlStreamends normally →finished()resolves → one of therscPromisesrejects →startRSC's catch (line 270) callsendResultStream()(endingresultStream) →startRSCresolves → the'end'handler'srscPromise.then(cleanup)fires →cleanup()callsflush()(which pushes to an ended stream) andresultStream.end()without awritableEndedguard, causing anERR_STREAM_WRITE_AFTER_ENDerror.The simplest fix is to reuse
endResultStream()insidecleanup(), since it already does flush + guard + end:Proposed fix
htmlStream.on('end', () => { - const cleanup = () => { - if (flushTimeout) { - clearTimeout(flushTimeout); - } - - flush(); - resultStream.end(); - }; - if (!rscPromise) { - cleanup(); + endResultStream(); return; } rscPromise - .then(cleanup) + .then(() => endResultStream()) .finally(() => { rscRequestTracker.clear(); }) .catch(() => endResultStream()); });
fa7ebb9 to
451ccf5
Compare
Code Review – PR #2407 (Fix streaming renders hanging forever when errors occur during SSR)The approach is sound and well-explained. The use of 1. Ruby:
|
There was a problem hiding this comment.
Using raise e re-raises the exception with a new backtrace originating from this line, discarding the original call stack. This makes it significantly harder to debug where the error actually occurred.
Use bare raise instead, which re-raises the current exception with its original backtrace preserved:
| raise |
There was a problem hiding this comment.
Intentional — raise e is needed here (not bare raise) because the inner rescue StandardError at line 452 catches the Async::Variable error (variable already resolved). A bare raise would re-raise that Async::Variable error, not the original rendering error e. The backtrace reset is an acceptable trade-off since the error message and class are preserved, and the re-raise location itself provides useful context (shows this was a post-first-chunk error propagated through the recovery path).
| (stream as Readable).on('close', () => { | ||
| if (!stream1.writableEnded) stream1.end(); | ||
| if (!stream2.writableEnded) stream2.end(); | ||
| }); |
There was a problem hiding this comment.
The comment says "On normal end, pipe() already forwards 'end' — this is a no-op", but this code does not use pipe() — it uses manual on('end') forwarding via stream1.push(null).
push(null) closes the readable side of stream1 but does not set writableEnded = true (that flag is only set by calling end()). So when the source emits 'close' after a normal end, stream1.writableEnded is false and stream1.end() will be called in addition to the earlier push(null).
Similarly, when the source is destroyed with error: stream1.destroy(err) does not set writableEnded, so stream1.end() is also called on an already-destroyed stream.
Adding a destroyed check makes the guard more robust:
| }); | |
| (stream as Readable).on('close', () => { | |
| if (!stream1.destroyed && !stream1.writableEnded) stream1.end(); | |
| if (!stream2.destroyed && !stream2.writableEnded) stream2.end(); | |
| }); |
There was a problem hiding this comment.
Good catch — fixed in 5f05c06. The comment now correctly references the manual on('end') handler instead of pipe().
| */ | ||
| htmlStream.on('error', (err) => { | ||
| resultStream.emit('error', err); | ||
| htmlStream.on('error', () => {}); |
There was a problem hiding this comment.
This completely silences all errors on htmlStream with no logging whatsoever. The comment correctly explains that 'error' alone shouldn't terminate the stream — but the error should at least be logged so operators can diagnose issues. A silent no-op makes production debugging very difficult.
| htmlStream.on('error', () => {}); | |
| htmlStream.on('error', (err) => { | |
| // Prevent unhandled-error crash. Termination is handled by the 'close' event below. | |
| // Non-fatal errors emit 'error' without destroying, so we must not end the stream here. | |
| console.error('[react-on-rails] htmlStream error (stream termination deferred to close event):', err); | |
| }); |
(Replace console.error with whatever logger this package uses, if one exists.)
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| await finished(htmlStream).then(() => Promise.all(rscPromises)); | ||
| } catch (err) { | ||
| resultStream.emit('error', err); | ||
| } catch { |
There was a problem hiding this comment.
startRSC catches all errors and calls endResultStream() without re-throwing, so rscPromise always resolves — it never rejects. This means:
- The error that caused the catch is silently discarded with no logging.
- The
.catch(() => endResultStream())in the'end'handler (line ~347) is unreachable dead code.
At minimum, log the error here. If you want the .catch in the 'end' handler to be meaningful, re-throw after calling endResultStream():
| } catch { | |
| } catch (err) { | |
| // Log the error for observability; end the stream cleanly without propagating | |
| // (propagating would destroy the socket mid-response in Fastify). | |
| console.error('[react-on-rails] startRSC error:', err); | |
| endResultStream(); | |
| } |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
There was a problem hiding this comment.
cleanup() calls resultStream.end() unconditionally, but endResultStream() may have already ended the stream (e.g., if startRSC's catch block fired). While Node.js handles a second end() gracefully, it's inconsistent that endResultStream has a writableEnded guard but cleanup does not. Add the same guard:
| const cleanup = () => { | |
| if (flushTimeout) { | |
| clearTimeout(flushTimeout); | |
| } | |
| flush(); | |
| if (!resultStream.writableEnded) { | |
| resultStream.end(); | |
| } | |
| }; |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
PR Review: Fix Streaming SSR Hangs on ErrorOverall Assessment: The root cause diagnosis is accurate and well-documented. Using A few issues worth addressing before merge: 1. Silent Error Swallowing (Most Significant)
The original code emitted errors to Recommendation: Pass an error callback or integrate with the existing 2. Unnecessary Arrow Function Wrapper (
|
| */ | ||
| htmlStream.on('error', (err) => { | ||
| resultStream.emit('error', err); | ||
| htmlStream.on('error', () => {}); |
There was a problem hiding this comment.
This no-op handler prevents the unhandled-error crash, but discards the error completely — nothing is logged and no caller is notified of what went wrong. The original behavior (emitting to resultStream) was too aggressive (Fastify destroys the socket), but complete silence is the opposite extreme.
Consider logging the error here, e.g. via the pattern used in handleStreamError where an onError callback is invoked:
htmlStream.on('error', (err) => {
// Don't propagate to resultStream (Fastify would destroy the socket),
// but at least log it so errors are visible during debugging.
console.error('[injectRSCPayload] htmlStream error:', err);
});Without this, RSC/HTML stream errors during production SSR are completely invisible.
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| } catch (err) { | ||
| resultStream.emit('error', err); | ||
| } catch { | ||
| endResultStream(); |
There was a problem hiding this comment.
The error from finished(htmlStream) or Promise.all(rscPromises) is silently discarded here. If RSC streaming fails (e.g., network timeout fetching RSC payload, serialization error), there is no log or trace.
| endResultStream(); | |
| } catch (err) { | |
| console.error('[injectRSCPayload] startRSC error:', err); | |
| endResultStream(); | |
| } |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| rscRequestTracker.clear(); | ||
| }) | ||
| .catch((err: unknown) => resultStream.emit('error', err)); | ||
| .catch(() => endResultStream()); |
There was a problem hiding this comment.
Same silent discard issue — the RSC request tracker error is lost.
| .catch(() => endResultStream()); | |
| .catch((err: unknown) => { | |
| console.error('[injectRSCPayload] RSC processing error:', err); | |
| endResultStream(); | |
| }); |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| stream.on('error', (error) => { | ||
| onError(error); | ||
| }); | ||
| // 'close' fires after both normal 'end' and destroy(). |
There was a problem hiding this comment.
The arrow wrapper is unnecessary — onError already matches the event handler signature.
| // 'close' fires after both normal 'end' and destroy(). | |
| stream.on('error', onError); |
There was a problem hiding this comment.
The current code already passes onError directly: source.on('error', onError) at line 152. No wrapper lambda.
PR Review: Fix streaming renders hanging forever when errors occur during SSRThis is a well-researched fix for a genuine Node.js stream semantics issue. The root cause analysis is accurate, and the approach of using A few issues worth addressing: 1. Silent error swallowing in
|
| */ | ||
| htmlStream.on('error', (err) => { | ||
| resultStream.emit('error', err); | ||
| htmlStream.on('error', () => {}); |
There was a problem hiding this comment.
This silently swallows all errors on htmlStream. The previous code at least propagated errors to consumers via resultStream.emit('error', err). While the comment explains why termination should not happen here, the error itself should still be reported — otherwise RSC injection failures become completely invisible to operators.
Consider passing the error to an error reporter or logging it:
| htmlStream.on('error', () => {}); | |
| htmlStream.on('error', (err) => { | |
| // Error alone is not the end of the stream — termination is handled by the 'close' event. | |
| // Log for observability but don't end the stream. | |
| console.error('[injectRSCPayload] htmlStream error:', err); | |
| }); |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| await finished(htmlStream).then(() => Promise.all(rscPromises)); | ||
| } catch (err) { | ||
| resultStream.emit('error', err); | ||
| } catch { |
There was a problem hiding this comment.
The error from startRSC (e.g., a failed RSC payload stream or finished() rejection) is silently discarded. The consumer gets a gracefully-ended stream but no indication that something went wrong.
| } catch { | |
| } catch (err) { | |
| // Log the error — the stream will be ended gracefully, but callers need visibility. | |
| console.error('[injectRSCPayload] startRSC error:', err); | |
| endResultStream(); | |
| } |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
There was a problem hiding this comment.
cleanup calls resultStream.end() without a writableEnded guard. There is a scenario where this double-ends the stream:
htmlStreamemits'end';rscPromise.then(cleanup)is scheduled.finished(htmlStream)resolves insidestartRSC;Promise.all(rscPromises)rejects.- The
catchblock callsendResultStream(), settingwritableEnded = true.startRSCthen resolves. cleanupruns via.then(cleanup)and callsresultStream.end()on an already-ended stream.
In recent Node.js, this is benign, but it's semantically wrong and fragile. The simplest fix is to have cleanup delegate to endResultStream(), which already has the guard:
| if (!resultStream.writableEnded) { | |
| resultStream.end(); | |
| } |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| if (typeof window !== 'undefined') { | ||
| return <div>Client-side fallback</div>; | ||
| } | ||
| return new Promise((_resolve, reject) => { |
There was a problem hiding this comment.
AlwaysFailsAsync returns a Promise rather than throwing one. In React's streaming SSR, a component suspends by throwing a Promise (legacy Suspense) or using use() (React 19). Returning a Promise produces an invalid React element — React will render it as a shell error (during renderToPipeableStream's synchronous phase) rather than as a Suspense boundary failure that completes the shell and fails mid-stream.
This means the demo exercises a different error path (shell failure) from the one described in the comment. If the intent is to demonstrate a mid-stream Suspense boundary error (the core bug scenario), the component needs to throw:
| return new Promise((_resolve, reject) => { | |
| const AlwaysFailsAsync = () => { | |
| if (typeof window !== 'undefined') { | |
| return <div>Client-side fallback</div>; | |
| } | |
| // Throw a thenable to trigger Suspense and then reject mid-stream. | |
| // This is the pattern that triggers a post-shell stream error. | |
| throw Object.assign( | |
| new Promise((_resolve, reject) => | |
| setTimeout(() => reject(new Error('Async component crashed during server rendering!')), 2000) | |
| ), | |
| { _isSuspensePromise: true }, | |
| ); | |
| }; |
There was a problem hiding this comment.
Dummy app test fixture — moved to PR #2497.
PR Review: Fix streaming renders hanging forever when errors occur during SSRThe root-cause analysis and overall approach are correct and well-documented. Using A few issues worth addressing before merge: 1. Silent error swallowing in
|
| await finished(htmlStream).then(() => Promise.all(rscPromises)); | ||
| } catch (err) { | ||
| resultStream.emit('error', err); | ||
| } catch { |
There was a problem hiding this comment.
The caught error is completely discarded here. The original code did resultStream.emit('error', err) which at least surfaced the failure. Even if we don't want to propagate it to Fastify (to avoid socket destruction), the error should be reported/logged so operators can observe failures in RSC processing.
| } catch { | |
| } catch (err) { | |
| // Don't propagate to resultStream — that would cause Fastify to destroy the socket. | |
| // But do report so the error is observable in logs/monitoring. | |
| console.error('[react-on-rails] RSC processing error:', err); | |
| endResultStream(); | |
| } |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
| * Prevent unhandled error crash. Error alone is not the end of the stream — | ||
| * termination is handled by the 'close' event below. | ||
| */ | ||
| htmlStream.on('error', () => {}); |
There was a problem hiding this comment.
This silently discards the error. The intent (prevent unhandled-error crash) is correct, but losing the error entirely makes production debugging much harder. At minimum, log it.
| htmlStream.on('error', () => {}); | |
| htmlStream.on('error', (err) => { | |
| // Prevent unhandled-error crash. Termination is handled by the 'close' event. | |
| // Log the error so it's observable — do not propagate to resultStream (would destroy socket). | |
| console.error('[react-on-rails] htmlStream error:', err); | |
| }); |
There was a problem hiding this comment.
Fixed — the error handling in injectRSCPayload.ts has been completely reworked. Errors are now forwarded to resultStream via safePipe's onError callback, the htmlStream.on('error') handler emits to resultStream, and startRSC's catch block also emits to resultStream. No errors are silently swallowed anymore. See commits e4638a1..2185635.
…jection (#2407) ## Summary Fix streaming SSR renders hanging forever when errors occur during rendering (Issue #2402), and fix silent error absorption preventing errors from reaching error reporters like Sentry (Issue #2450). ### Problem 1: Streaming hangs (Issue #2402) **Root cause**: Node.js `stream.pipe()` does not propagate errors or closure from source to destination. When a source stream errors, `pipe()` silently unpipes but leaves the destination PassThrough open, causing any consumer awaiting `stream.end` (like Fastify's `res.send()`) to hang forever. **Approach**: Use the `'close'` event as the reliable termination signal — it fires after both normal `end()` and `destroy()`. Error events alone are NOT the end of a stream: non-fatal errors (e.g., `emitError` for `throwJsErrors`) emit `'error'` without destroying, and React may continue rendering other Suspense boundaries. ### Problem 2: Silent error absorption (Issue #2450) **Root cause**: Three error handlers in `injectRSCPayload.ts` swallowed errors silently — `htmlStream.on('error', () => {})`, `catch {}` in `startRSC`, and `.catch(() => endResultStream())` — preventing errors from reaching `errorReporter`/Sentry. Additionally, the `safePipe` call from `pipeableHtmlStream` to `htmlStream` had no `onError` callback, so source stream errors were silently dropped. **Approach**: Introduce a shared `safePipe` utility that replaces scattered pipe+close boilerplate. Forward errors through `safePipe`'s `onError` callback so they propagate to `handleStreamError` → `errorReporter` in the node renderer. The pipe stays intact, data continues flowing, and Fastify never sees the errors. ### Fixes | # | File | Layer | Fix | |---|------|-------|-----| | 1 | `node-renderer/src/shared/utils.ts` | Last defense before Fastify | `handleStreamError`: error handler only reports; `'close'` handler ends the PassThrough. The PassThrough acts as a firewall — non-fatal errors never reach Fastify (which would destroy the socket on any stream error). Uses `.once('close')` for the one-shot event. | | 2 | `react-on-rails-pro/src/RSCRequestTracker.ts` | RSC payload tee'ing | End both tee'd PassThrough streams on `'close'` to prevent `for-await` hangs in `injectRSCPayload`. | | 3 | `react-on-rails-pro/src/injectRSCPayload.ts` | RSC payload injection | Add `safePipe` with `onError` callback to forward errors from `pipeableHtmlStream` to `resultStream`. Consolidate separate `'end'`+`'close'` handlers into a single `'close'` handler. Use `htmlStream.on('close')` instead of `finished()` inside `startRSC` (the close-based promise never rejects, preventing dangling RSC promises). Chain `.catch()` before `.finally()` to prevent error masking. | | 4 | `react-on-rails-pro/src/safePipe.ts` | **New shared utility** | Replaces scattered pipe+close boilerplate with a single function handling the Node.js `pipe()` gap (destination not ended on source destroy) and optional `onError` callback for non-fatal error reporting. Supports both Node.js `Readable` and React's `PipeableStream` (which lacks `.on()`). | | 5 | `react-on-rails-pro/src/streamingUtils.ts` | Transform pipeline | `pipeToTransform`: use `safePipe` with `onError` callback to forward source errors to `readableStream` via `emitError`. | | 6 | `react-on-rails-pro/src/transformRSCNodeStream.ts` | RSC stream transform | Add `safePipe` with `'close'` handling for the missed pipe site that the initial streaming hang fix did not cover. | | 7 | `react_on_rails_pro_helper.rb` | Ruby safety net | `consumer_stream_async`: resolve `first_chunk_var` in rescue block with the error object to prevent Rails request hang if async task raises before the first chunk. Use `is_a?(StandardError)` to match the rescue clause. | | 8 | `react_on_rails_pro/concerns/stream.rb` | Ruby streaming | `render_to_string` error path: call `@async_barrier.stop` to cancel SSR fibers, resolve `first_chunk_var` with the error, and re-raise for proper propagation. | ### Key Node.js stream behaviors this PR addresses | Scenario | Events fired | `destroyed` | `writableEnded` | `pipe()` behavior | |---|---|---|---|---| | `stream.end()` | `'finish'` → `'end'` → `'close'` | false | true | Forwards `'end'` to destination | | `stream.destroy(err)` | `'error'` → `'close'` (NO `'end'`!) | true | **false** | Unpipes, does NOT end destination | | `stream.emit('error')` | `'error'` only | **false** | false | Stream stays alive, continues piping | ## Test plan - [x] Unit tests (`handleStreamError.test.ts`): 6 tests — PassThrough ends on source destruction, stays open for non-fatal errors, data integrity, pipe error behavior - [x] E2E tests (`streamErrorHang.test.ts`): 3 tests — HTTP responses complete instead of hanging (mid-stream error, pre-data error, normal completion) - [x] `injectRSCPayload.test.ts` — 7 tests pass - [x] `streamServerRenderedReactComponent.test.jsx` — 16 tests pass - [x] `RSCRequestTracker.test.ts` — 8 tests pass - [x] `streamBackpressure.e2e.test.tsx` — 4 tests pass - [x] `react-on-rails` open-source tests: 112/112 pass - [x] ESLint + RuboCop + Prettier: pass - [x] Demo pages added: `/stream_error_demo`, `/stream_shell_error_demo` Closes #2402 Closes #2450 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Justin Gordon <[email protected]>
…upport * origin/master: (38 commits) fix: use env-var-driven ports in Procfile templates to support multiple worktrees (#2539) Fix prettier formatting in auto-bundling doc Docs: Clarify .client/.server suffixes vs use client RSC directive (#2406) Warn against using .server/.client variants with RSC features Docs: move internal-only docs out of published docs trees (#2414) Fix crash when HTTPX::ErrorResponse is returned in get_form_body_for_file (#2532) Skip flaky external URLs in lychee checks (#2547) Update mise docs: prefer shell-level shims over conductor-exec (#2537) Document compression middleware compatibility with streaming SSR (#2544) Fix duplicate node-renderer message reporting in render failures (#2531) Fix private_output_path not configured on fresh Shakapacker installs (#2289) Bump the npm-security group across 1 directory with 3 updates (#2387) docs: use https links (#2518) Consolidate changelog to keep only rc6 prerelease (#2533) Fix CSS module class name divergence with rspack SSR (#2489) Bump version to 16.4.0.rc.6 Add BugBot license scanning config (#2515) Fix buildVM promise cleanup ordering (#2483) (#2484) Fix streaming SSR hangs and silent error absorption in RSC payload injection (#2407) Ensure lefthook uses mise tools in non-interactive shells (#2512) ... # Conflicts: # CHANGELOG.md
Summary
Fix streaming SSR renders hanging forever when errors occur during rendering (Issue #2402), and fix silent error absorption preventing errors from reaching error reporters like Sentry (Issue #2450).
Problem 1: Streaming hangs (Issue #2402)
Root cause: Node.js
stream.pipe()does not propagate errors or closure from source to destination. When a source stream errors,pipe()silently unpipes but leaves the destination PassThrough open, causing any consumer awaitingstream.end(like Fastify'sres.send()) to hang forever.Approach: Use the
'close'event as the reliable termination signal — it fires after both normalend()anddestroy(). Error events alone are NOT the end of a stream: non-fatal errors (e.g.,emitErrorforthrowJsErrors) emit'error'without destroying, and React may continue rendering other Suspense boundaries.Problem 2: Silent error absorption (Issue #2450)
Root cause: Three error handlers in
injectRSCPayload.tsswallowed errors silently —htmlStream.on('error', () => {}),catch {}instartRSC, and.catch(() => endResultStream())— preventing errors from reachingerrorReporter/Sentry. Additionally, thesafePipecall frompipeableHtmlStreamtohtmlStreamhad noonErrorcallback, so source stream errors were silently dropped.Approach: Introduce a shared
safePipeutility that replaces scattered pipe+close boilerplate. Forward errors throughsafePipe'sonErrorcallback so they propagate tohandleStreamError→errorReporterin the node renderer. The pipe stays intact, data continues flowing, and Fastify never sees the errors.Fixes
node-renderer/src/shared/utils.tshandleStreamError: error handler only reports;'close'handler ends the PassThrough. The PassThrough acts as a firewall — non-fatal errors never reach Fastify (which would destroy the socket on any stream error). Uses.once('close')for the one-shot event.react-on-rails-pro/src/RSCRequestTracker.ts'close'to preventfor-awaithangs ininjectRSCPayload.react-on-rails-pro/src/injectRSCPayload.tssafePipewithonErrorcallback to forward errors frompipeableHtmlStreamtoresultStream. Consolidate separate'end'+'close'handlers into a single'close'handler. UsehtmlStream.on('close')instead offinished()insidestartRSC(the close-based promise never rejects, preventing dangling RSC promises). Chain.catch()before.finally()to prevent error masking.react-on-rails-pro/src/safePipe.tspipe()gap (destination not ended on source destroy) and optionalonErrorcallback for non-fatal error reporting. Supports both Node.jsReadableand React'sPipeableStream(which lacks.on()).react-on-rails-pro/src/streamingUtils.tspipeToTransform: usesafePipewithonErrorcallback to forward source errors toreadableStreamviaemitError.react-on-rails-pro/src/transformRSCNodeStream.tssafePipewith'close'handling for the missed pipe site that the initial streaming hang fix did not cover.react_on_rails_pro_helper.rbconsumer_stream_async: resolvefirst_chunk_varin rescue block with the error object to prevent Rails request hang if async task raises before the first chunk. Useis_a?(StandardError)to match the rescue clause.react_on_rails_pro/concerns/stream.rbrender_to_stringerror path: call@async_barrier.stopto cancel SSR fibers, resolvefirst_chunk_varwith the error, and re-raise for proper propagation.Key Node.js stream behaviors this PR addresses
destroyedwritableEndedpipe()behaviorstream.end()'finish'→'end'→'close''end'to destinationstream.destroy(err)'error'→'close'(NO'end'!)stream.emit('error')'error'onlyTest plan
handleStreamError.test.ts): 6 tests — PassThrough ends on source destruction, stays open for non-fatal errors, data integrity, pipe error behaviorstreamErrorHang.test.ts): 3 tests — HTTP responses complete instead of hanging (mid-stream error, pre-data error, normal completion)injectRSCPayload.test.ts— 7 tests passstreamServerRenderedReactComponent.test.jsx— 16 tests passRSCRequestTracker.test.ts— 8 tests passstreamBackpressure.e2e.test.tsx— 4 tests passreact-on-railsopen-source tests: 112/112 pass/stream_error_demo,/stream_shell_error_demoCloses #2402
Closes #2450
🤖 Generated with Claude Code