-
-
Notifications
You must be signed in to change notification settings - Fork 632
Fix silent error absorption in RSC payload injection #2486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 2402-streaming-hang-on-error
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,11 +12,12 @@ | |
| * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md | ||
| */ | ||
|
|
||
| import { PassThrough, Readable } from 'stream'; | ||
| import { PassThrough } from 'stream'; | ||
| import { finished } from 'stream/promises'; | ||
| import { PipeableOrReadableStream } from 'react-on-rails/types'; | ||
| import { createRSCPayloadKey } from './utils.ts'; | ||
| import RSCRequestTracker from './RSCRequestTracker.ts'; | ||
| import safePipe from './safePipe.ts'; | ||
|
|
||
| // In JavaScript, when an escape sequence with a backslash (\) is followed by a character | ||
| // that isn't a recognized escape character, the backslash is ignored, and the character | ||
|
|
@@ -78,16 +79,7 @@ export default function injectRSCPayload( | |
| domNodeId: string | undefined, | ||
| ) { | ||
| const htmlStream = new PassThrough(); | ||
| pipeableHtmlStream.pipe(htmlStream); | ||
| // When the source is destroyed, pipe() unpipes but does NOT end htmlStream. | ||
| // Listen for 'close' to ensure htmlStream ends, which triggers the cleanup chain. | ||
| if (typeof (pipeableHtmlStream as Readable).on === 'function') { | ||
| (pipeableHtmlStream as Readable).on('close', () => { | ||
| if (!htmlStream.writableEnded) { | ||
| htmlStream.end(); | ||
| } | ||
| }); | ||
| } | ||
| safePipe(pipeableHtmlStream, htmlStream); | ||
| const decoder = new TextDecoder(); | ||
| let rscPromise: Promise<void> | null = null; | ||
|
|
||
|
|
@@ -267,7 +259,8 @@ export default function injectRSCPayload( | |
|
|
||
| // Wait for HTML stream to complete, then wait for all RSC promises | ||
| await finished(htmlStream).then(() => Promise.all(rscPromises)); | ||
| } catch { | ||
| } catch (e) { | ||
| resultStream.emit('error', e instanceof Error ? e : new Error(String(e))); | ||
| endResultStream(); | ||
| } | ||
| }; | ||
|
|
@@ -297,10 +290,21 @@ export default function injectRSCPayload( | |
| }); | ||
|
|
||
| /** | ||
| * Prevent unhandled error crash. Error alone is not the end of the stream — | ||
| * termination is handled by the 'close' event below. | ||
| * Report errors on htmlStream by emitting them on resultStream, where they | ||
| * propagate to handleStreamError → errorReporter in the node renderer. | ||
| * Error alone is not the end of the stream — termination is handled by the | ||
| * 'close' event below. | ||
| * | ||
| * We only emit here when startRSC hasn't been called yet. Once startRSC is | ||
|
Comment on lines
+294
to
+298
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The A boolean flag shared between the two code paths would be more robust: let errorForwarded = false;
htmlStream.on('error', (err) => {
if (!rscPromise && !errorForwarded) {
errorForwarded = true;
resultStream.emit('error', err instanceof Error ? err : new Error(String(err)));
}
});And in the } catch (e) {
if (!errorForwarded) {
errorForwarded = true;
resultStream.emit('error', e instanceof Error ? e : new Error(String(e)));
}
endResultStream();
}
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The "subtle window" described here cannot occur due to Node.js's synchronous event handling guarantees:
So the invariant holds: if `rscPromise` is set (our handler skips), `finished()` is guaranteed to be listening and will handle the error in the catch block. If `rscPromise` is not set, `startRSC` hasn't been called, so `finished()` isn't listening, and only our handler reports. There is no ordering reversal possible — both the check (`!rscPromise`) and `finished()`'s listener registration happen synchronously before any event loop tick. |
||
| * running, finished(htmlStream) inside startRSC rejects on error, and the | ||
| * catch block there handles reporting — emitting here too would cause the | ||
| * same error to be reported twice. | ||
| */ | ||
| htmlStream.on('error', () => {}); | ||
| htmlStream.on('error', (err) => { | ||
| if (!rscPromise) { | ||
| resultStream.emit('error', err instanceof Error ? err : new Error(String(err))); | ||
| } | ||
| }); | ||
|
|
||
| /** | ||
| * 'close' fires after both normal 'end' and destroy(). | ||
|
|
@@ -339,7 +343,10 @@ export default function injectRSCPayload( | |
| .finally(() => { | ||
| rscRequestTracker.clear(); | ||
| }) | ||
| .catch(() => endResultStream()); | ||
| .catch((e: unknown) => { | ||
| resultStream.emit('error', e instanceof Error ? e : new Error(String(e))); | ||
| endResultStream(); | ||
| }); | ||
| }); | ||
|
|
||
| return resultStream; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,65 @@ | ||||||||||||||||||||||||||||||||||||||||
| /* | ||||||||||||||||||||||||||||||||||||||||
| * Copyright (c) 2025 Shakacode LLC | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * This file is NOT licensed under the MIT (open source) license. | ||||||||||||||||||||||||||||||||||||||||
| * It is part of the React on Rails Pro offering and is licensed separately. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * Unauthorized copying, modification, distribution, or use of this file, | ||||||||||||||||||||||||||||||||||||||||
| * via any medium, is strictly prohibited without a valid license agreement | ||||||||||||||||||||||||||||||||||||||||
| * from Shakacode LLC. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * For licensing terms, please see: | ||||||||||||||||||||||||||||||||||||||||
| * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import { Readable, Writable } from 'stream'; | ||||||||||||||||||||||||||||||||||||||||
| import { PipeableOrReadableStream } from 'react-on-rails/types'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Pipes source to destination with proper 'close' event handling. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * Node.js `pipe()` does NOT end the destination when the source is destroyed — | ||||||||||||||||||||||||||||||||||||||||
| * it silently unpipes, leaving the destination open forever. This function fills | ||||||||||||||||||||||||||||||||||||||||
| * that gap by listening for the 'close' event (which fires after both normal | ||||||||||||||||||||||||||||||||||||||||
| * 'end' and `destroy()`) and ending the destination if the source didn't end | ||||||||||||||||||||||||||||||||||||||||
| * normally. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * An optional `onError` callback provides observability for source stream errors | ||||||||||||||||||||||||||||||||||||||||
| * without forwarding them to the destination (which would break the pipe). | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * NOTE: `PipeableOrReadableStream` can be either a React `PipeableStream` | ||||||||||||||||||||||||||||||||||||||||
| * (which only has `.pipe()` and `.abort()`, no `.on()`) or a Node.js | ||||||||||||||||||||||||||||||||||||||||
| * `ReadableStream`. The 'close' and 'error' listeners are only attached when | ||||||||||||||||||||||||||||||||||||||||
| * the source supports `.on()`. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * @param source - The source stream to pipe from | ||||||||||||||||||||||||||||||||||||||||
| * @param destination - The destination stream to pipe into | ||||||||||||||||||||||||||||||||||||||||
| * @param onError - Optional callback for source stream errors (for reporting, not forwarding) | ||||||||||||||||||||||||||||||||||||||||
| * @returns The destination stream (for chaining) | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| export default function safePipe<T extends Writable>( | ||||||||||||||||||||||||||||||||||||||||
| source: PipeableOrReadableStream, | ||||||||||||||||||||||||||||||||||||||||
| destination: T, | ||||||||||||||||||||||||||||||||||||||||
| onError?: (err: Error) => void, | ||||||||||||||||||||||||||||||||||||||||
| ): T { | ||||||||||||||||||||||||||||||||||||||||
| source.pipe(destination); | ||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Listeners are attached after
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (typeof (source as Readable).on === 'function') { | ||||||||||||||||||||||||||||||||||||||||
| const readableSource = source as Readable; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (onError) { | ||||||||||||||||||||||||||||||||||||||||
| readableSource.on('error', onError); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // 'close' fires after both normal 'end' and destroy(). | ||||||||||||||||||||||||||||||||||||||||
| // On normal end, pipe() already forwards 'end' to the destination — this is a no-op. | ||||||||||||||||||||||||||||||||||||||||
| // On destroy, pipe() unpipes but does NOT end the destination — we do it here. | ||||||||||||||||||||||||||||||||||||||||
| readableSource.on('close', () => { | ||||||||||||||||||||||||||||||||||||||||
| if (!destination.writableEnded) { | ||||||||||||||||||||||||||||||||||||||||
| destination.end(); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return destination; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,8 @@ | |
| * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md | ||
| */ | ||
|
|
||
| import { Transform } from 'stream'; | ||
| import { Readable, Transform } from 'stream'; | ||
| import safePipe from './safePipe.ts'; | ||
|
|
||
| /** | ||
| * Transforms an RSC Node.js stream for server-side processing. | ||
|
|
@@ -55,5 +56,5 @@ export default function transformRSCStream(stream: NodeJS.ReadableStream): NodeJ | |
| }, | ||
| }); | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This correctly adds the If error visibility is desired here (consistent with the goal of this PR), consider: return safePipe(stream as Readable, htmlExtractor, (err) => {
// forward to caller or log — RSC stream errors should be observable
htmlExtractor.emit('error', err);
});Or at minimum document why silent error suppression is intentional for this specific pipe.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The safePipe change here was specifically to fix the close event gap (the pipe() destination-not-ended-on-destroy issue that PR #2407 missed at this site). Error reporting for the RSC source stream is a pre-existing concern that this PR doesn't make worse. Regarding the suggested fix — emitting on the destination (htmlExtractor) would actually break the pipe. In Node.js v20+, pipe() attaches an error handler on the destination that calls source.unpipe(destination), so destination.emit('error', err) would disconnect the pipe and stop data flow. The proper way to add error reporting here would require changes to the caller (getReactServerComponent.server.ts) to provide an error callback, which is outside this PR's scope. The Transform's own try/catch in its transform callback already handles parse errors (line 53-54). If the source stream is destroyed, safePipe ensures htmlExtractor is properly ended via the close handler — React's createFromNodeStream sees the stream end and handles the incomplete payload via its error boundary. Happy to open a follow-up issue for RSC source stream error reporting if desired. |
||
| return stream.pipe(htmlExtractor); | ||
| return safePipe(stream as Readable, htmlExtractor); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Double error emission risk
When
htmlStreamemits an error,finished(htmlStream)(called insidestartRSC) will also reject with that same error. This meansresultStream.emit('error', ...)gets called twice for a singlehtmlStreamerror — once here and once in thestartRSCcatch block — resulting in duplicate Sentry/errorReporter reports.Consider removing this handler and relying solely on the
startRSCcatchblock, or deduplicate using a seen-error guard:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — this was a real double-emission bug. Fixed in eb254a4.
The htmlStream.on('error') handler now only emits on resultStream when startRSC hasn't been called yet (!rscPromise). Once startRSC is running, finished(htmlStream) in the startRSC catch block handles reporting — the handler still exists to prevent Node.js from throwing on unhandled 'error' event but no longer double-reports.
This correctly covers both edge cases: