Skip to content
Merged
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
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
- m-kawafuji
- m-shojaei
- machour
- MahinAnowar
- majamarijan
- Malien
- Manc
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix server crash (`TypeError: Invalid state: Unable to enqueue`) when a request is aborted while the RSC HTML stream has a pending flush — `injectRSCPayload` now handles cancellation of its readable side, clears the pending flush, and cancels the underlying RSC payload stream
222 changes: 222 additions & 0 deletions packages/react-router/__tests__/rsc/html-stream-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { injectRSCPayload } from "../../lib/rsc/html-stream/server";
import { routeRSCServerRequest } from "../../lib/rsc/server.ssr";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

function createDeferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
let promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

function createRSCStream({
keepOpen = false,
chunks = ['S1:"hello"'],
}: {
keepOpen?: boolean;
chunks?: string[];
} = {}) {
let cancelled = false;
let cancelReason: unknown;
let stream = new ReadableStream<Uint8Array>({
start(controller) {
chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)));
if (!keepOpen) {
controller.close();
}
},
cancel(reason) {
cancelled = true;
cancelReason = reason;
},
});
return {
stream,
isCancelled: () => cancelled,
cancelReason: () => cancelReason,
};
}

async function withUnhandledRejections(run: () => Promise<void>) {
let unhandledRejections: unknown[] = [];
let onUnhandledRejection = (reason: unknown) =>
unhandledRejections.push(reason);
process.on("unhandledRejection", onUnhandledRejection);
try {
await run();
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
return unhandledRejections;
}

function tick() {
return new Promise((resolve) => setTimeout(resolve, 20));
}

async function withTimeout<T>(promise: Promise<T>, message: string) {
let timeout: ReturnType<typeof setTimeout>;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), 1000);
}),
]);
} finally {
clearTimeout(timeout!);
}
}

async function readStream(stream: ReadableStream<Uint8Array>) {
let reader = stream.getReader();
let chunks: Uint8Array[] = [];

while (true) {
let { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
}

let length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
let merged = new Uint8Array(length);
let offset = 0;
for (let chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}

return decoder.decode(merged);
}

describe("injectRSCPayload", () => {
it("streams buffered HTML, RSC payload chunks, and the HTML trailer", async () => {
let html = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("<html><body>"));
controller.enqueue(encoder.encode("hi</body></html>"));
controller.close();
},
});

let result = await readStream(
html.pipeThrough(injectRSCPayload(createRSCStream().stream)),
);

expect(result).toBe(
'<html><body>hi<script>(self.__FLIGHT_DATA||=[]).push("S1:\\"hello\\"")</script></body></html>',
);
});

it("does not crash when the readable side is cancelled while a flush is pending", async () => {
let rsc = createRSCStream({ keepOpen: true });
let transform = injectRSCPayload(rsc.stream);
let writer = transform.writable.getWriter();
let reader = transform.readable.getReader();
let reason = new Error("client aborted");

let unhandledRejections = await withUnhandledRejections(async () => {
// Schedule the buffered flush (`setTimeout(..., 0)`) by writing a chunk,
// then cancel the readable side (client aborted the request) before the
// timer fires.
writer
.write(encoder.encode("<html><body>hi</body></html>"))
.catch(() => {});
await reader.cancel(reason);

// Let the pending flush timer fire.
await tick();
});

// Without a cancel handler the pending flush enqueues into a cancelled
// stream, and the rejection from the timer callback kills the process.
expect(unhandledRejections).toEqual([]);
expect(rsc.isCancelled()).toBe(true);
expect(rsc.cancelReason()).toBe(reason);
});

it("does not crash when the readable side is cancelled while the RSC payload is streaming", async () => {
let rsc = createRSCStream({ keepOpen: true });
let transform = injectRSCPayload(rsc.stream);
let writer = transform.writable.getWriter();
let reader = transform.readable.getReader();
let reason = new Error("client aborted");

let unhandledRejections = await withUnhandledRejections(async () => {
writer
.write(encoder.encode("<html><body>hi</body></html>"))
.catch(() => {});
// Read the flushed HTML and the first RSC script chunk so the RSC
// payload stream is being consumed, then abort.
await reader.read();
await reader.read();
await reader.cancel(reason);

await tick();
});

expect(unhandledRejections).toEqual([]);
expect(rsc.isCancelled()).toBe(true);
expect(rsc.cancelReason()).toBe(reason);
});
});

describe("routeRSCServerRequest", () => {
it("does not crash when an RSC Framework document response is cancelled while payload injection has a pending flush", async () => {
let htmlPulled = createDeferred();
let htmlCancelled = createDeferred<unknown>();
let response = await routeRSCServerRequest({
request: new Request("https://remix.run/"),
serverResponse: new Response(createRSCStream().stream),
createFromReadableStream: async (body) => {
await readStream(body);
return { type: "render" } as never;
},
async renderHTML(getPayload) {
await getPayload();
let sent = false;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (sent) {
return;
}
sent = true;
controller.enqueue(encoder.encode("<html><body>hi</body></html>"));
htmlPulled.resolve();
},
cancel(reason) {
htmlCancelled.resolve(reason);
},
});
},
});
let reader = response.body!.getReader();
let reason = new Error("client aborted");

let unhandledRejections = await withUnhandledRejections(async () => {
let read = reader.read().catch(() => {});

await htmlPulled.promise;
await Promise.resolve();
await withTimeout(
reader.cancel(reason),
"Timed out cancelling document response body",
);
await withTimeout(read, "Timed out settling pending document body read");

await tick();
});

expect(unhandledRejections).toEqual([]);
await expect(
withTimeout(htmlCancelled.promise, "Timed out cancelling HTML stream"),
).resolves.toBe(reason);
});
});
40 changes: 34 additions & 6 deletions packages/react-router/lib/rsc/html-stream/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export function injectRSCPayload(rscStream: ReadableStream<Uint8Array>) {
(resolve) => (resolveFlightDataPromise = resolve),
);
let startedRSC = false;
let cancelled = false;
let rscReader: ReadableStreamDefaultReader<Uint8Array> | null = null;

// Buffer all HTML chunks enqueued during the current tick of the event loop (roughly)
// and write them to the output stream all at once. This ensures that we don't generate
Expand All @@ -31,18 +33,26 @@ export function injectRSCPayload(rscStream: ReadableStream<Uint8Array>) {
timeout = null;
}

return new TransformStream({
let transformer: Transformer<Uint8Array, Uint8Array> & {
cancel?: (reason: unknown) => void | Promise<void>;
} = {
transform(chunk, controller) {
buffered.push(chunk);
if (timeout) {
return;
}

timeout = setTimeout(async () => {
// The readable side may have been cancelled (e.g., the client aborted
// the request) while this flush was pending — enqueueing would throw.
if (cancelled) {
return;
}
flushBufferedChunks(controller);
if (!startedRSC) {
startedRSC = true;
writeRSCStream(rscStream, controller)
rscReader = rscStream.getReader();
writeRSCStream(rscReader, controller, () => cancelled)
.catch((err) => controller.error(err))
.then(resolveFlightDataPromise);
}
Expand All @@ -56,18 +66,36 @@ export function injectRSCPayload(rscStream: ReadableStream<Uint8Array>) {
}
controller.enqueue(encoder.encode("</body></html>"));
},
});
async cancel(reason) {
cancelled = true;
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
buffered.length = 0;
if (rscReader) {
await rscReader.cancel(reason).catch(() => {});
} else {
await rscStream.cancel(reason).catch(() => {});
}
resolveFlightDataPromise();
},
};
return new TransformStream(transformer);
}

async function writeRSCStream(
rscStream: ReadableStream<Uint8Array>,
reader: ReadableStreamDefaultReader<Uint8Array>,
controller: TransformStreamDefaultController<Uint8Array>,
isCancelled: () => boolean,
) {
let decoder = new TextDecoder("utf-8", { fatal: true });
const reader = rscStream.getReader();
try {
let read: ReadableStreamReadResult<Uint8Array>;
while ((read = await reader.read()) && !read.done) {
if (isCancelled()) {
return;
}
const chunk = read.value;
// Try decoding the chunk to send as a string.
// If that fails (e.g. binary data that is invalid unicode), write as base64.
Expand All @@ -92,7 +120,7 @@ async function writeRSCStream(
}

let remaining = decoder.decode();
if (remaining.length) {
if (remaining.length && !isCancelled()) {
writeChunk(JSON.stringify(remaining), controller);
}
}
Expand Down