-
Notifications
You must be signed in to change notification settings - Fork 365
fix(server): stop reporting client-aborted renders through onRequestError #2665
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: main
Are you sure you want to change the base?
Changes from all commits
5a0c4e4
92de6f4
eded7a1
4bcb9b9
abf4007
ff7605e
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,6 +12,7 @@ import { | |
| VINEXT_TIMING_HEADER, | ||
| } from "./headers.js"; | ||
| import { setCacheStateHeaders } from "./cache-headers.js"; | ||
| import { tagConsumerCancellation } from "./response-aborted.js"; | ||
| import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; | ||
| import { | ||
| VINEXT_RSC_CONTENT_TYPE, | ||
|
|
@@ -284,6 +285,7 @@ export function buildAppPageRscResponse( | |
| body: ReadableStream, | ||
| options: BuildAppPageRscResponseOptions, | ||
| ): Response { | ||
| body = tagConsumerCancellation(body); | ||
|
jlucaso1 marked this conversation as resolved.
|
||
| const headers = new Headers({ | ||
| "Content-Type": VINEXT_RSC_CONTENT_TYPE, | ||
| Vary: VINEXT_RSC_VARY_HEADER, | ||
|
|
@@ -329,6 +331,7 @@ export function buildAppPageHtmlResponse( | |
| body: ReadableStream, | ||
| options: BuildAppPageHtmlResponseOptions, | ||
| ): Response { | ||
| body = tagConsumerCancellation(body); | ||
|
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.
Apply the cancellation wrapper to the HTTP-access/error-boundary response paths as well. This only tags bodies constructed through Useful? React with 👍 / 👎. |
||
| const headers = new Headers({ | ||
| "Content-Type": "text/html; charset=utf-8", | ||
| Vary: VINEXT_RSC_VARY_HEADER, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,8 @@ import { AppRouterContext } from "vinext/shims/internal/app-router-context"; | |
| import { createClientReferencePreloader } from "./app-client-reference-preloader.js"; | ||
| import { RSC_FORM_STATE_GLOBAL } from "./app-browser-hydration.js"; | ||
| import { isPprFallbackShellAbortError } from "vinext/shims/ppr-fallback-shell"; | ||
| import { isResponseAbortedError } from "./response-aborted.js"; | ||
| import { pumpThrough } from "./stream-pump.js"; | ||
| import DefaultGlobalError from "vinext/shims/default-global-error"; | ||
| import { appendAssetDeploymentIdQuery } from "../utils/deployment-id.js"; | ||
| import { ssrAppRouterInstance } from "./app-ssr-router-instance.js"; | ||
|
|
@@ -598,6 +600,13 @@ export async function handleSsr( | |
| return undefined; | ||
| } | ||
|
|
||
| // Consumer cancellation tagged at the response boundary (client | ||
| // disconnect): not a real render failure, keep it out of the | ||
| // error meta stream. | ||
| if (isResponseAbortedError(error)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| errorMetaRenderer.capture(error); | ||
|
|
||
| if (error && typeof error === "object" && "digest" in error) { | ||
|
|
@@ -717,7 +726,8 @@ export async function handleSsr( | |
| } | ||
|
|
||
| const finalStream = deferUntilStreamConsumed( | ||
| htmlStream.pipeThrough( | ||
| pumpThrough( | ||
| htmlStream, | ||
| createTickBufferedTransform( | ||
| rscEmbed, | ||
| getInsertedHTML, | ||
|
|
@@ -728,7 +738,10 @@ export async function handleSsr( | |
| options?.scriptNonce, | ||
| ), | ||
| ), | ||
| cleanup, | ||
| () => { | ||
| rscEmbed.abort?.(); | ||
|
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.
When a document consumer disconnects while a Server Component is suspended, this only cancels Useful? React with 👍 / 👎. |
||
| cleanup(); | ||
| }, | ||
| ); | ||
|
|
||
| return { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| const RESPONSE_ABORTED_NAME = "ResponseAborted"; | ||
| const RESPONSE_ABORTED_BRAND = Symbol.for("vinext.responseAborted"); | ||
|
|
||
| /** | ||
| * Cancellation reason used when the response consumer goes away (client | ||
| * disconnect, aborted navigation). Spec-compliant server runtimes cancel the | ||
| * response body stream in that situation, usually with no reason; React then | ||
| * aborts the in-flight render with "The render was aborted by the server | ||
| * without a reason." and every aborted task reaches the render `onError`, | ||
| * where it would be reported through `onRequestError` as if it were a real | ||
| * failure. | ||
| * | ||
| * Tagging the cancellation at the response boundary lets the error handlers | ||
| * classify these aborts as expected control flow instead. Mirrors Next.js's | ||
| * `ResponseAborted` (`server/web/spec-extension/adapters/next-request.ts`) and | ||
| * its `isAbortError` handling in `server/pipe-readable.ts`, which swallows | ||
| * exactly this class of error. | ||
| */ | ||
| export class ResponseAbortedError extends Error { | ||
| readonly [RESPONSE_ABORTED_BRAND] = true; | ||
|
|
||
| constructor(cause?: unknown) { | ||
| super( | ||
| "The client closed the connection before the render completed.", | ||
| ...(cause !== undefined ? [{ cause }] : []), | ||
| ); | ||
| this.name = RESPONSE_ABORTED_NAME; | ||
| } | ||
| } | ||
|
|
||
| export function isResponseAbortedError(error: unknown): boolean { | ||
| return ( | ||
| error instanceof Error && | ||
| (error as Partial<Record<typeof RESPONSE_ABORTED_BRAND, unknown>>)[RESPONSE_ABORTED_BRAND] === | ||
| true | ||
| ); | ||
| } | ||
|
|
||
| function isDomAbortError(reason: unknown): boolean { | ||
| return ( | ||
| typeof DOMException !== "undefined" && | ||
| reason instanceof DOMException && | ||
| reason.name === "AbortError" | ||
| ); | ||
| } | ||
|
|
||
| function isPrematureCloseError(reason: unknown): boolean { | ||
| return ( | ||
| reason instanceof Error && | ||
| (reason as Partial<Record<"code", unknown>>).code === "ERR_STREAM_PREMATURE_CLOSE" | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Wrap a render stream that is about to become a Response body so that a | ||
| * consumer cancellation with no reason reaches the underlying render as a | ||
| * tagged {@link ResponseAbortedError} instead of an anonymous abort. | ||
| */ | ||
| export function tagConsumerCancellation( | ||
| stream: ReadableStream<Uint8Array>, | ||
| ): ReadableStream<Uint8Array> { | ||
| const reader = stream.getReader(); | ||
| let cancelled = false; | ||
| return new ReadableStream<Uint8Array>( | ||
| { | ||
| pull(controller) { | ||
| return reader.read().then( | ||
| ({ done, value }) => { | ||
| if (cancelled) return; | ||
| if (done) { | ||
| controller.close(); | ||
| } else { | ||
| controller.enqueue(value); | ||
| } | ||
| }, | ||
| (error) => { | ||
| if (cancelled) return; | ||
| controller.error(error); | ||
| }, | ||
| ); | ||
| }, | ||
| cancel(reason) { | ||
| cancelled = true; | ||
| if (reason == null) { | ||
| return reader.cancel(new ResponseAbortedError()); | ||
| } | ||
| // Runtimes that propagate a standard aborted signal cancel with a | ||
| // DOMException named AbortError; that is still a consumer abort. | ||
| if (isDomAbortError(reason)) { | ||
| return reader.cancel(new ResponseAbortedError(reason)); | ||
| } | ||
| // Node's Readable.fromWeb pipeline cancels with an ordinary Error | ||
| // whose code is ERR_STREAM_PREMATURE_CLOSE when the client | ||
| // disconnects from a Node HTTP server. | ||
| if (isPrematureCloseError(reason)) { | ||
| return reader.cancel(new ResponseAbortedError(reason)); | ||
| } | ||
| return reader.cancel(reason); | ||
|
jlucaso1 marked this conversation as resolved.
|
||
| }, | ||
| }, | ||
| { highWaterMark: 0 }, | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| /** | ||
| * `stream.pipeThrough(transform)` equivalent whose pump owns every promise. | ||
| * | ||
| * The internal pipeTo loop of `pipeThrough` can leave an in-flight | ||
| * `writer.write` rejection unhandled when the consumer cancels the readable | ||
| * side between chunks (observable as an unhandled rejection carrying the | ||
| * cancel reason). This pump awaits or settles every read/write explicitly, and | ||
| * on failure propagates the reason both ways (cancel upstream, abort the | ||
| * transform), so cancellation reasons reach the source untouched. | ||
| */ | ||
| export function pumpThrough<I, O>( | ||
| stream: ReadableStream<I>, | ||
| transform: TransformStream<I, O>, | ||
| ): ReadableStream<O> { | ||
| const writer = transform.writable.getWriter(); | ||
| const reader = stream.getReader(); | ||
|
|
||
| // Cancelling the transform's readable errors its writable. Without this | ||
| // watcher the pump would only notice on the next chunk, keeping a stalled | ||
| // render's resources alive after the consumer went away. | ||
| void writer.closed.catch((reason: unknown) => { | ||
| void reader.cancel(reason).catch(() => {}); | ||
| }); | ||
|
|
||
| void (async () => { | ||
| try { | ||
| for (;;) { | ||
|
jlucaso1 marked this conversation as resolved.
|
||
| const { done, value } = await reader.read(); | ||
|
jlucaso1 marked this conversation as resolved.
|
||
| if (done) { | ||
| await writer.close(); | ||
| return; | ||
| } | ||
| await writer.write(value); | ||
| } | ||
| } catch (error) { | ||
| await Promise.allSettled([reader.cancel(error), writer.abort(error)]); | ||
| } | ||
| })(); | ||
|
|
||
| return transform.readable; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.