Skip to content

Fix streaming SSR hangs and silent error absorption in RSC payload injection#2407

Merged
justin808 merged 23 commits intomasterfrom
2402-streaming-hang-on-error
Mar 4, 2026
Merged

Fix streaming SSR hangs and silent error absorption in RSC payload injection#2407
justin808 merged 23 commits intomasterfrom
2402-streaming-hang-on-error

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

@AbanoubGhadban AbanoubGhadban commented Feb 13, 2026

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 handleStreamErrorerrorReporter 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

  • Unit tests (handleStreamError.test.ts): 6 tests — PassThrough ends on source destruction, stays open for non-fatal errors, data integrity, pipe error behavior
  • E2E tests (streamErrorHang.test.ts): 3 tests — HTTP responses complete instead of hanging (mid-stream error, pre-data error, normal completion)
  • injectRSCPayload.test.ts — 7 tests pass
  • streamServerRenderedReactComponent.test.jsx — 16 tests pass
  • RSCRequestTracker.test.ts — 8 tests pass
  • streamBackpressure.e2e.test.tsx — 4 tests pass
  • react-on-rails open-source tests: 112/112 pass
  • ESLint + RuboCop + Prettier: pass
  • Demo pages added: /stream_error_demo, /stream_shell_error_demo

Closes #2402
Closes #2450

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 13, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Add 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

Cohort / File(s) Summary
Node renderer stream util
packages/react-on-rails-pro-node-renderer/src/shared/utils.ts
Replace simple stream.on('error', onError) with a composite error handler that forwards errors to onError and conditionally ends the downstream PassThrough on source destroy/close if the destination hasn't ended, preventing hung response streams.
Node renderer tests & fixture
packages/react-on-rails-pro-node-renderer/tests/handleStreamError.test.ts, packages/react-on-rails-pro-node-renderer/tests/streamErrorHang.test.ts, packages/react-on-rails-pro-node-renderer/tests/fixtures/stream-test-bundle.js
Add unit and E2E tests plus a VM fixture exposing Node Readable to reproduce mid-stream and pre-data errors; assert handleStreamError behavior, HTTP response completion, error reporting, and correct data propagation prior to errors.
RSC streaming pipeline
packages/react-on-rails-pro/src/injectRSCPayload.ts, packages/react-on-rails-pro/src/streamingUtils.ts, packages/react-on-rails-pro/src/RSCRequestTracker.ts
Register guarded close listeners and introduce endResultStream helper to ensure transform/result/tee'd streams are ended when the source closes or is destroyed, aligning end-on-destroy behavior across the RSC pipeline.
Ruby streaming helper
react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
Wrap async streaming block with rescue to nil out first_chunk_var.value on exceptions (swallowing cleanup errors) then re-raise, avoiding caller-side hangs when streaming fails.
Dummy app demos, routes & components
react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb, react_on_rails_pro/spec/dummy/app/views/pages/stream_error_demo.html.erb, react_on_rails_pro/spec/dummy/app/views/pages/stream_shell_error_demo.html.erb, react_on_rails_pro/spec/dummy/config/routes.rb, react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/StreamErrorDemo.jsx, react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/StreamShellErrorDemo.jsx
Add demo controller actions, views, routes, and two demo components (one that fails asynchronously during server render, one that throws during shell render) to exercise streaming error scenarios end-to-end.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nibbled at a leaking stream,

Found the gaps where spinners dream.
I listen, call the watcher true,
Close the gate when streams undo.
🥕 Now responses finish — hop anew.

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed All code changes directly address the core requirements from #2402: preventing HTTP response streams from hanging by ensuring destination PassThrough streams are closed/destroyed when source streams error, with comprehensive fixes applied at all five affected locations and thorough test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the streaming error hang issue: stream error handlers, test fixtures, unit and E2E tests, demo components for validation, and controller routes for testing purposes. No extraneous changes detected.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into master
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: fixing streaming SSR hangs and preventing silent error absorption in RSC payload injection, which directly aligns with the PR's core objectives and changes across multiple files.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 2402-streaming-hang-on-error

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 13, 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.16 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.16 KB (0%)
registerServerComponent/client bundled (brotli) 61.54 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.54 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 121.69 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 121.69 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.63 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.63 KB (0%)

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Feb 13, 2026

Greptile Summary

This 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 pipe() silently unpipes without ending the destination, and (2) errors in the RSC payload injection pipeline being silently absorbed before reaching error reporters like Sentry.

The core fix is a new safePipe utility (duplicated in both react-on-rails-pro and node-renderer packages) that closes the Node.js pipe() gap by listening to the 'close' event — which fires after both normal end() and destroy() — and ensuring the destination is properly ended in all cases. This is then applied consistently across the streaming pipeline: injectRSCPayload, streamingUtils, transformRSCNodeStream, and RSCRequestTracker. On the Ruby side, a safety net in consumer_stream_async resolves first_chunk_var with the raised error to prevent Rails request hangs for pre-first-chunk failures.

Key changes and observations:

  • safePipe utility: Correctly attaches listeners before pipe() to avoid theoretical races; handles both Node.js Readable and React PipeableStream (which lacks .on()).
  • injectRSCPayload: Switches from finished() (which rejects on stream destruction) to a 'close'-based promise that always resolves — preventing dangling RSC promises. However, Promise.allSettled(rscPromises) at line 277 discards rejected results, meaning errors from for await RSC stream processing can still be silently dropped.
  • RSCRequestTracker: The new 'close' handler on the source RSC stream correctly unblocks for await consumers in startRSC when the source is destroyed without an error event.
  • Ruby helper: The pattern of resolving Async::Variable with an exception object and raising it at the call site is idiomatic for the Async gem.
  • The test suite is thorough, with the handleStreamError.test.ts suite including a documentation test that proves Node.js pipe() does not propagate source errors.

Confidence Score: 4/5

  • This PR is safe to merge with one logic issue to address: RSC stream errors from for await processing remain silently dropped via uninspected Promise.allSettled results in injectRSCPayload.ts.
  • The core fixes are well-reasoned and correctly address the two root causes (pipe() not ending destinations on destroy, and three silent catch blocks). The safePipe utility, RSCRequestTracker tee fix, and Ruby safety net are all correct. The Promise.allSettled without result inspection at line 277 partially contradicts the PR's own goal of fixing silent error absorption for RSC streams — errors from stream2 for await loops would still be dropped if they occur after htmlStream has closed. All other logic is sound and tests are comprehensive.
  • packages/react-on-rails-pro/src/injectRSCPayload.ts — the Promise.allSettled call does not inspect rejected results, leaving RSC for-await errors silently dropped.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/safePipe.ts New shared utility that fills Node.js pipe()'s gap — properly ends the destination on source destroy via the 'close' event, and optionally forwards source errors via onError without breaking the pipe. Handles both Node.js Readable and React PipeableStream. Logic is correct and ordering (listeners before pipe) is intentional.
packages/react-on-rails-pro-node-renderer/src/shared/utils.ts Adds a safePipe utility and refactors handleStreamError to use it. The PassThrough returned by handleStreamError acts as a Fastify firewall — errors are reported via onError but never forwarded to the HTTP layer, while the 'close' event ensures the PassThrough is properly ended on source destruction. Correct and well-structured.
packages/react-on-rails-pro/src/injectRSCPayload.ts Key fix file for both streaming hangs and silent error absorption. Uses safePipe with onError to forward pipeableHtmlStream errors. Switches from finished() to a close-based promise to prevent dangling RSC promises. Two issues: Promise.allSettled results are never inspected (RSC for-await errors silently dropped), and rscRequestTracker.clear() is skipped on the no-RSC early-return path.
packages/react-on-rails-pro/src/RSCRequestTracker.ts Adds a 'close' handler on the source RSC stream to end both tee'd PassThrough streams (stream1, stream2) when the source is destroyed without error. Correctly guards against double-end with writableEnded/destroyed checks. The manual tee via on('data') + push() to avoid backpressure coupling between stream1 and stream2 is well-justified.
packages/react-on-rails-pro/src/streamingUtils.ts Switches pipeToTransform to use safePipe with emitError as the onError callback. Source stream errors are forwarded to readableStream (the bufferStream wrapper) rather than being silently dropped. The emitError type (unknown) is safely compatible with safePipe's onError type (Error) via TypeScript contravariance.
packages/react-on-rails-pro/src/transformRSCNodeStream.ts Replaces raw pipe() + return with safePipe, ensuring the Transform (htmlExtractor) is properly ended when the RSC source stream is destroyed. Clean, minimal change that closes the previously missed pipe site.
react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb Adds rescue StandardError in consumer_stream_async to resolve first_chunk_var with the error object for pre-first-chunk failures. Uses an inner rescue to detect whether the variable was already resolved, and re-raises if so. The pattern is idiomatic for Async::Variable and the is_a?(StandardError) check in the caller is correctly matched to the rescue clause.
react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb Adds @async_barrier&.stop in the rescue block of stream_view_containing_react_components to cancel pending SSR fibers when render_to_string raises before the response is committed. Prevents leaked async work for pre-commit errors. Correct and complete.
packages/react-on-rails-pro-node-renderer/tests/handleStreamError.test.ts Comprehensive unit tests covering all termination scenarios: mid-stream error, pre-data error, non-fatal error without destruction, normal completion, and data preservation. Includes a documentation test proving Node.js pipe() does not propagate source errors to destination.

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: 7e28e58

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.

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-end resultStream when an RSC promise rejects after HTML finishes.

Scenario: htmlStream ends normally → finished() resolves → one of the rscPromises rejects → startRSC's catch (line 270) calls endResultStream() (ending resultStream) → startRSC resolves → the 'end' handler's rscPromise.then(cleanup) fires → cleanup() calls flush() (which pushes to an ended stream) and resultStream.end() without a writableEnded guard, causing an ERR_STREAM_WRITE_AFTER_END error.

The simplest fix is to reuse endResultStream() inside cleanup(), 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());
   });

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 19, 2026

Code Review – PR #2407 (Fix streaming renders hanging forever when errors occur during SSR)

The approach is sound and well-explained. The use of 'close' as the reliable termination signal is correct, and the test suite is comprehensive. A few issues to address before merging:


1. Ruby: raise e drops the original backtrace (bug)

In react_on_rails_pro_helper.rb, the rescue block uses raise e, which re-raises as a new exception with a new backtrace pointing to the raise line, discarding the original call stack. The correct Ruby idiom to re-raise and preserve backtrace is bare raise (no argument).

rescue StandardError => e
  begin
    first_chunk_var.value = nil
  rescue StandardError
    # Already resolved, safe to ignore
  end
  raise   # not: raise e
end

2. RSCRequestTracker.ts: misleading guard — writableEnded is not set by push(null)

The new 'close' handler comments say "On normal end, pipe() already forwards 'end' — this is a no-op." But this code does not use pipe() — it uses manual on('data')/on('end') forwarding. Calling stream1.push(null) sets the readable-side EOF flag, but does not set writableEnded = true. That flag is only set when end() is called.

Consequence: on a normal source end, both stream1.push(null) (from the 'end' handler) and stream1.end() (from the 'close' handler) are called. In Node.js 14+ this is effectively a no-op, but it's semantically incorrect and the comment is wrong. Consider tracking whether the end was already delivered via a boolean flag, or checking stream1.readableEnded (which is set synchronously when 'end' emits, before 'close' fires).

Also applies when the source is destroyed with an error: stream1.destroy(err) does not set writableEnded, so stream1.end() is also called afterward. Adding a !stream1.destroyed check would make the guard more robust:

(stream as Readable).on('close', () => {
  if (!stream1.destroyed && !stream1.writableEnded) stream1.end();
  if (!stream2.destroyed && !stream2.writableEnded) stream2.end();
});

3. injectRSCPayload.ts: errors are silently swallowed with no logging

Two places now silently discard errors:

a) The htmlStream.on('error', () => {}) handler has no logging at all. If htmlStream emits an error, the caller has no visibility. At minimum, log the error even if not propagating it.

b) The startRSC catch block:

} catch {
  endResultStream();
}

The bound variable (err) was removed, so there is no way to log the actual error. This replaces the previous resultStream.emit('error', err) which, while causing the hanging issue, at least surfaced the error. The new code should log the error to stderr or to whatever logger is used in this package.


4. injectRSCPayload.ts: rscPromise.catch in the 'end' handler is unreachable dead code

startRSC catches all of its own errors internally:

const startRSC = async () => {
  try { ... } catch { endResultStream(); }  // never re-throws
};

Since startRSC never rejects, rscPromise always resolves. The catch(() => endResultStream()) on the chain in the 'end' handler is therefore unreachable. This is misleading — it looks like a safety net but provides none. Either remove it or make startRSC re-throw so the catch is actually reachable.


5. injectRSCPayload.ts: cleanup() calls resultStream.end() without checking writableEnded

If startRSC catches an error and calls endResultStream() (which ends the result stream), rscPromise still resolves (per point 4), and the 'end' handler's rscPromise.then(cleanup) will execute cleanup(), which calls resultStream.end() unconditionally. Node.js handles a second end() call gracefully, but cleanup() should guard the same way endResultStream() does:

const cleanup = () => {
  if (flushTimeout) clearTimeout(flushTimeout);
  flush();
  if (!resultStream.writableEnded) resultStream.end(); // add guard
};

Minor observations

  • The fixtures/stream-test-bundle.js sets global.Readable = require('stream').Readable. This is intentional for the VM test harness, but a comment explaining it's a test-only fixture would help future readers.
  • Several tests use setTimeout(resolve, 50) for synchronization. These are potentially flaky on heavily loaded CI systems. Event-driven synchronization (waiting on stream events directly) would be more reliable, though the 5s test timeout provides enough headroom in practice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
raise

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.

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();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
});
(stream as Readable).on('close', () => {
if (!stream1.destroyed && !stream1.writableEnded) stream1.end();
if (!stream2.destroyed && !stream2.writableEnded) stream2.end();
});

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.

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', () => {});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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.)

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.

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

startRSC catches all errors and calls endResultStream() without re-throwing, so rscPromise always resolves — it never rejects. This means:

  1. The error that caused the catch is silently discarded with no logging.
  2. 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():

Suggested change
} 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();
}

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.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
const cleanup = () => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
flush();
if (!resultStream.writableEnded) {
resultStream.end();
}
};

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.

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.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 19, 2026

PR Review: Fix Streaming SSR Hangs on Error

Overall Assessment: The root cause diagnosis is accurate and well-documented. Using 'close' as the reliable termination signal (rather than 'error') is the correct Node.js stream approach, and the fix is applied consistently across all affected layers. The PR includes good test coverage including unit tests that document the pipe() non-propagation behavior.

A few issues worth addressing before merge:


1. Silent Error Swallowing (Most Significant)

injectRSCPayload.ts has replaced error propagation with complete silence:

  • htmlStream.on('error', () => {}) — no-op handler, errors are completely discarded
  • } catch { endResultStream(); } — error is discarded without logging
  • .catch(() => endResultStream()) — same pattern

The original code emitted errors to resultStream. The PR description correctly notes that emitting on resultStream causes Fastify to destroy the socket mid-response. However, the fix goes too far in the other direction: errors are now invisible to any consumer or debugging system. At minimum, errors should be logged (e.g., via an onError callback or a logger) even if they're not propagated downstream.

Recommendation: Pass an error callback or integrate with the existing errorReporter pattern used elsewhere in the codebase, so errors are visible without causing socket destruction.


2. Unnecessary Arrow Function Wrapper (utils.ts)

stream.on('error', (error) => {
  onError(error);
});

This is functionally identical to stream.on('error', onError) — the wrapper adds no value.


3. Dead Code in StreamShellErrorDemo.jsx

const StreamShellErrorDemo = () => {
  throw new Error('Component crashed immediately during shell render!');
  return <div>This will never render</div>; // unreachable
};

The return statement after throw is unreachable dead code. The component works fine without it (TypeScript doesn't require a return after an unconditional throw).


4. Undocumented Addition: server_side_hello_world_hooks Controller Action

The PR adds a controller action for server_side_hello_world_hooks which is not mentioned in the PR description or summary table. The route and view already existed, but the controller action was apparently missing. If this is a pre-existing bug fix, it should be noted in the description to make the change auditable.


Positive Observations

  • The table in the PR description explaining stream.pipe() behavior for normal end, destroy(err), and emit('error') is an excellent reference and should be preserved in a code comment or architecture doc.
  • The 'close'-over-'error' approach is correctly applied at every layer of the pipeline.
  • The writableEnded guards before calling .end() prevent double-end errors.
  • The Ruby rescue block correctly re-raises after resolving first_chunk_var so callers aren't left hanging.
  • Test coverage is thorough, including both unit tests for stream semantics and E2E tests verifying actual HTTP response completion.

*/
htmlStream.on('error', (err) => {
resultStream.emit('error', err);
htmlStream.on('error', () => {});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
endResultStream();
} catch (err) {
console.error('[injectRSCPayload] startRSC error:', err);
endResultStream();
}

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.

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());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same silent discard issue — the RSC request tracker error is lost.

Suggested change
.catch(() => endResultStream());
.catch((err: unknown) => {
console.error('[injectRSCPayload] RSC processing error:', err);
endResultStream();
});

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.

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().
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The arrow wrapper is unnecessary — onError already matches the event handler signature.

Suggested change
// 'close' fires after both normal 'end' and destroy().
stream.on('error', onError);

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.

The current code already passes onError directly: source.on('error', onError) at line 152. No wrapper lambda.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 19, 2026

PR Review: Fix streaming renders hanging forever when errors occur during SSR

This is a well-researched fix for a genuine Node.js stream semantics issue. The root cause analysis is accurate, and the approach of using 'close' as the termination signal is the correct solution. The comments and documentation throughout are exemplary.

A few issues worth addressing:

1. Silent error swallowing in injectRSCPayload.ts (three places)

The most significant concern is that errors are now silently swallowed in three locations, replacing the old behavior that at least propagated errors to consumers via resultStream.emit('error', err):

  • Line 270–272: The startRSC catch block calls endResultStream() but discards the error entirely. Failures during RSC processing (e.g., a component stream erroring) become invisible.
  • Line 303: htmlStream.on('error', () => {}) is a no-op. While preventing an uncaught exception crash is correct, the error should at minimum be logged.
  • Line 347: .catch(() => endResultStream()) in the 'end' handler discards any error thrown from rscPromise.

Downstream consumers (and developers debugging production issues) lose all visibility into why a response body ended early. At minimum, these should log the error. If there's a logger available in this context, passing the error to it would be appropriate.

2. cleanup function calls resultStream.end() without a writableEnded guard (line 334)

endResultStream() (line 199–205) guards with if (!resultStream.writableEnded), but the cleanup function called from the 'end' handler (line 334) calls resultStream.end() directly without that guard.

Failure scenario: startRSC awaits finished(htmlStream), then Promise.all(rscPromises) — if that rejects, the catch block calls endResultStream() (setting writableEnded = true). startRSC then resolves (it swallows the error), so rscPromise.then(cleanup) fires and cleanup calls resultStream.end() on an already-ended stream. In recent Node.js versions this is a no-op, but it is semantically incorrect and may behave differently across Node versions.

Fix: add if (!resultStream.writableEnded) guard in cleanup, or better — have cleanup call endResultStream() directly.

3. AlwaysFailsAsync demo component uses the wrong Suspense pattern

In StreamErrorDemo.jsx, AlwaysFailsAsync returns a Promise instead of throwing one (or using use()). In React's streaming SSR, a component suspends by throwing a Promise (legacy) or using the use() hook (React 19). Returning a Promise from a function component produces an invalid React element, which React will fail to render — but not via the Suspense boundary mechanism. The error will likely be a shell error rather than a Suspense boundary error, so the demo may not accurately demonstrate issue #2402's specific scenario (mid-stream Suspense boundary failure).

Minor: server_side_hello_world_hooks action added without apparent template

The controller adds a server_side_hello_world_hooks action (no template visible in the diff). The route already exists in routes.rb, so a 404 isn't a concern, but if this action was added intentionally here, a corresponding template should be included.

*/
htmlStream.on('error', (err) => {
resultStream.emit('error', err);
htmlStream.on('error', () => {});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
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);
});

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.

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
} catch {
} catch (err) {
// Log the error — the stream will be ended gracefully, but callers need visibility.
console.error('[injectRSCPayload] startRSC error:', err);
endResultStream();
}

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.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cleanup calls resultStream.end() without a writableEnded guard. There is a scenario where this double-ends the stream:

  1. htmlStream emits 'end'; rscPromise.then(cleanup) is scheduled.
  2. finished(htmlStream) resolves inside startRSC; Promise.all(rscPromises) rejects.
  3. The catch block calls endResultStream(), setting writableEnded = true. startRSC then resolves.
  4. cleanup runs via .then(cleanup) and calls resultStream.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:

Suggested change
if (!resultStream.writableEnded) {
resultStream.end();
}

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.

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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
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 },
);
};

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.

Dummy app test fixture — moved to PR #2497.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 19, 2026

PR Review: Fix streaming renders hanging forever when errors occur during SSR

The root-cause analysis and overall approach are correct and well-documented. Using 'close' as the authoritative termination signal (rather than 'error' or 'end') is the right design for Node.js stream error handling. The test coverage — both unit and E2E — is solid and directly validates the bug being fixed.

A few issues worth addressing before merge:


1. Silent error swallowing in startRSC catch block (injectRSCPayload.ts:270)

} catch {
  endResultStream();
}

The original emitted resultStream.emit('error', err), passing the error along. The new code drops err entirely — no logging, no reporting. If finished(htmlStream) or RSC promise processing fails, that failure is now invisible. Even if we don't want to propagate the error to Fastify (to avoid socket destruction), the error should at minimum be logged or passed to a reporter so operators can observe it.


2. Silent htmlStream.on('error', () => {}) handler (injectRSCPayload.ts:303)

htmlStream.on('error', () => {});

Again, the error is completely discarded. The intent (prevent unhandled-error crash) is correct, but the implementation loses diagnostic information. Even a console.error or calling the existing error reporter here would help when debugging production issues.


3. Misleading comment in RSCRequestTracker.ts (line ~163)

// On normal end, pipe() already forwards 'end' — this is a no-op.

This comment was copy-pasted from handleStreamError but doesn't apply here — this code doesn't use pipe(). It manually forwards data with stream.on('data'). On a normal end, stream1.push(null) is called, but writableEnded stays false (only stream.end() sets it). So the close handler will call stream1.end() / stream2.end() on normal termination — it is not a no-op. The behavior is still correct, but the comment is actively misleading.


4. close handler doesn't guard against already-destroyed streams in RSCRequestTracker.ts

(stream as Readable).on('close', () => {
  if (!stream1.writableEnded) stream1.end();
  if (!stream2.writableEnded) stream2.end();
});

When the source errors, stream.on('error') calls stream1.destroy(err). After that, close fires and calls stream1.end() on an already-destroyed stream. Calling end() on a destroyed PassThrough is a no-op in practice, but the intent (and guard condition) would be clearer with !stream1.writableEnded && !stream1.destroyed.


5. writableEnded check after push(null) in RSCRequestTracker.ts

Related to #3: push(null) signals EOF on the readable side but does not set writableEnded. This means for normal stream ends, the close handler always executes stream1.end() and stream2.end(). This is safe — calling end() after push(null) is harmless — but it contradicts the comment claiming it's a no-op.


Minor: Stray controller action in the diff

def server_side_hello_world_hooks
  stream_view_containing_react_components(template: "/pages/server_side_hello_world_hooks")
end

This action is added in pages_controller.rb and the route already existed. Was this intentionally included? If so, a brief note in the PR description or commit message would clarify why it's part of this fix.


Summary: The fix is mechanically sound. The main concern is that two error paths (startRSC catch, htmlStream.on('error')) now silently swallow errors that previously surfaced. In production, this trades a potential socket-destruction problem for invisible failures. At minimum, add error reporting/logging in both paths.

await finished(htmlStream).then(() => Promise.all(rscPromises));
} catch (err) {
resultStream.emit('error', err);
} catch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
} 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();
}

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.

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', () => {});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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);
});

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.

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.

@justin808 justin808 merged commit 5653021 into master Mar 4, 2026
49 checks passed
@justin808 justin808 deleted the 2402-streaming-hang-on-error branch March 4, 2026 04:15
AbanoubGhadban added a commit that referenced this pull request Mar 7, 2026
…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]>
justin808 added a commit that referenced this pull request Mar 8, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:16.4.0-must-have Must-have for 16.4.0: critical bug/perf/usability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

injectRSCPayload silently absorbs stream errors with no logging Streaming renders hang forever when errors occur during SSR

3 participants