Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,19 +584,18 @@ function wrapRscResponseForDevErrorReporting(
}
};

const cleanup = new TransformStream<Uint8Array, Uint8Array>({
flush() {
onConsumed();
},
});

const piped = originalBody.pipeThrough(cleanup);
const reader = piped.getReader();
// A manual passthrough instead of pipeThrough(new TransformStream(...)):
// the transform's only job was firing `onConsumed` on clean drain, which
// the `done` branch below covers. It also avoids the internal pipeTo
// promise, which rejects unhandled when the consumer cancels the stream
// with a reason.
const reader = originalBody.getReader();
const wrappedStream = new ReadableStream<Uint8Array>({
pull(controller) {
return reader.read().then(
({ done, value }) => {
if (done) {
onConsumed();
controller.close();
} else {
controller.enqueue(value);
Expand Down
3 changes: 3 additions & 0 deletions packages/vinext/src/server/app-page-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -284,6 +285,7 @@ export function buildAppPageRscResponse(
body: ReadableStream,
options: BuildAppPageRscResponseOptions,
): Response {
body = tagConsumerCancellation(body);
Comment thread
jlucaso1 marked this conversation as resolved.
Comment thread
jlucaso1 marked this conversation as resolved.
const headers = new Headers({
"Content-Type": VINEXT_RSC_CONTENT_TYPE,
Vary: VINEXT_RSC_VARY_HEADER,
Expand Down Expand Up @@ -329,6 +331,7 @@ export function buildAppPageHtmlResponse(
body: ReadableStream,
options: BuildAppPageHtmlResponseOptions,
): Response {
body = tagConsumerCancellation(body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Tag cancellation on boundary-render responses

Apply the cancellation wrapper to the HTTP-access/error-boundary response paths as well. This only tags bodies constructed through buildAppPageRscResponse and buildAppPageHtmlResponse, while renderAppPageBoundaryResponse still returns its RSC stream directly and its HTML branch uses renderAppPageHtmlResponse, which also constructs an untagged Response. If a client disconnects while a suspended custom not-found, forbidden, unauthorized, or error boundary is streaming, the cancellation therefore reaches the same RSC error handler without a ResponseAbortedError and is still reported through onRequestError; the new integration coverage exercises only a normal page.

Useful? React with 👍 / 👎.

const headers = new Headers({
"Content-Type": "text/html; charset=utf-8",
Vary: VINEXT_RSC_VARY_HEADER,
Expand Down
10 changes: 10 additions & 0 deletions packages/vinext/src/server/app-rsc-errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolveAppPageSpecialError } from "./app-page-execution.js";
import { isNavigationSignalError } from "../utils/navigation-signal.js";
import { isResponseAbortedError } from "./response-aborted.js";

type DigestError = Error & { digest?: string };
const ORIGINAL_SERVER_ERROR = Symbol.for("vinext.originalServerError");
Expand Down Expand Up @@ -121,6 +122,15 @@ export function createRscOnErrorHandler(
return wellKnownDigest;
}

// Renders aborted because the response consumer went away (client
// disconnect / aborted navigation) are expected control flow, not request
// errors. The response boundary tags that cancellation (see
// response-aborted.ts); skip reporting, matching Next.js's isAbortError
// handling. The digest is moot since no client is listening anymore.
if (isResponseAbortedError(error)) {
return errorDigest(getThrownValueMessage(error));
}

if (
nodeEnv !== "production" &&
error instanceof Error &&
Expand Down
6 changes: 5 additions & 1 deletion packages/vinext/src/server/app-server-action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { applyEdgeRuntimeHeader } from "./app-page-response.js";
import { resolveAppPageActionRerenderTarget } from "./app-page-request.js";
import { resolveAppPageNavigationParams } from "./app-page-element-builder.js";
import { deferUntilStreamConsumed } from "./app-page-stream.js";
import { tagConsumerCancellation } from "./response-aborted.js";
import { buildAppPageTags } from "./implicit-tags.js";
import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js";
import { getSetCookieName } from "./cookie-utils.js";
Expand Down Expand Up @@ -489,7 +490,10 @@ function createServerActionRscResponse(
return new Response(body, init);
}

return new Response(deferUntilStreamConsumed(body, clearRequestContext), init);
return new Response(
tagConsumerCancellation(deferUntilStreamConsumed(body, clearRequestContext)),
init,
);
}

function isRequestBodyTooLarge(error: unknown): boolean {
Expand Down
17 changes: 15 additions & 2 deletions packages/vinext/src/server/app-ssr-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -717,7 +726,8 @@ export async function handleSsr(
}

const finalStream = deferUntilStreamConsumed(
htmlStream.pipeThrough(
pumpThrough(
htmlStream,
createTickBufferedTransform(
rscEmbed,
getInsertedHTML,
Expand All @@ -728,7 +738,10 @@ export async function handleSsr(
options?.scriptNonce,
),
),
cleanup,
() => {
rscEmbed.abort?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cancel the SSR Flight branch when abandoning HTML

When a document consumer disconnects while a Server Component is suspended, this only cancels rscEmbed's auxiliary branch. The other branch created for createFromReadableStream remains actively reading, and aborting the Fizz HTML render does not cancel that independent Flight reader, so the underlying RSC render can continue indefinitely and a later failure can still reach onRequestError. Fresh evidence in the current tree is the internal rscStream.tee() whose ssrStream branch is never retained for cleanup; cancel both Flight branches with the branded reason and cover a never-settling Server Component rather than one that resolves after 1.5 seconds.

Useful? React with 👍 / 👎.

cleanup();
},
);

return {
Expand Down
30 changes: 23 additions & 7 deletions packages/vinext/src/server/app-ssr-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { NAVIGATION_RUNTIME_SYMBOL_DESCRIPTION } from "../client/navigation-runt
type RscEmbedTransform = {
flush(): string;
finalize(): Promise<string>;
/**
* Stop reading the embed stream so pending finalize()/getRawBuffer()
* callers settle. Used when the response consumer goes away while the
* auxiliary Flight stream is still suspended; a no-op after natural end.
*/
abort?(reason?: unknown): void;
/** Resolves when all raw bytes from the embed stream have been read. */
getRawBuffer(): Promise<ArrayBuffer>;
};
Expand Down Expand Up @@ -131,6 +137,10 @@ export function createRscEmbedTransform(
const pumpPromise = pumpReader();

return {
abort(reason?: unknown): void {
void reader.cancel(reason).catch(() => {});
},

flush(): string {
if (pendingChunks.length === 0) return "";

Expand Down Expand Up @@ -621,14 +631,20 @@ export function createTickBufferedTransform(
}

const finalScripts = await rscEmbed.finalize();
if (finalScripts) {
controller.enqueue(encoder.encode(finalScripts));
}
try {
if (finalScripts) {
controller.enqueue(encoder.encode(finalScripts));
}

// Emit `</body></html>` last so the document always terminates with a
// well-formed close, after any trailing flight chunks / preinit scripts.
// Mirrors Next.js's `createMoveSuffixStream` behaviour (#1532).
controller.enqueue(encoder.encode(DOCUMENT_CLOSE_SUFFIX));
// Emit `</body></html>` last so the document always terminates with
// a well-formed close, after any trailing flight chunks / preinit
// scripts. Mirrors Next.js's `createMoveSuffixStream` behaviour
// (#1532).
controller.enqueue(encoder.encode(DOCUMENT_CLOSE_SUFFIX));
} catch {
// The readable side was cancelled while finalize() was pending
// (consumer went away mid-flush); nobody is reading anymore.
}
},
});
}
14 changes: 7 additions & 7 deletions packages/vinext/src/server/defer-until-stream-consumed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ export function deferUntilStreamConsumed(
}
};

const cleanup = new TransformStream<Uint8Array, Uint8Array>({
flush() {
once();
},
});

const reader = stream.pipeThrough(cleanup).getReader();
// A manual passthrough instead of pipeThrough(new TransformStream(...)):
// the transform's only job was firing `onFlush` on clean drain, which the
// `done` branch below covers. It also avoids the internal pipeTo promise,
// which rejects unhandled when the consumer cancels the stream with a
// reason.
const reader = stream.getReader();
return new ReadableStream<Uint8Array>({
pull(controller) {
return reader.read().then(
({ done, value }) => {
if (done) {
once();
controller.close();
} else {
controller.enqueue(value);
Expand Down
103 changes: 103 additions & 0 deletions packages/vinext/src/server/response-aborted.ts
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);
Comment thread
jlucaso1 marked this conversation as resolved.
},
},
{ highWaterMark: 0 },
);
}
4 changes: 2 additions & 2 deletions packages/vinext/src/server/rsc-stream-hints.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { pumpThrough } from "./stream-pump.js";
const REACT_FLIGHT_STYLESHEET_PRELOAD_HINT = /^([0-9a-f]*:HL\[.*?),"stylesheet"(\]|,)/;
const STYLESHEET_TO_STYLE_JSON_PADDING = " ".repeat("stylesheet".length - "style".length);

Expand Down Expand Up @@ -112,8 +113,7 @@ export function normalizeReactFlightPreloadHints(
let rawBytesRemaining = 0;
let passThrough = false;

return stream.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
return pumpThrough(stream, new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
if (passThrough) {
controller.enqueue(chunk);
Expand Down
41 changes: 41 additions & 0 deletions packages/vinext/src/server/stream-pump.ts
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 (;;) {
Comment thread
jlucaso1 marked this conversation as resolved.
const { done, value } = await reader.read();
Comment thread
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;
}
Loading