-
Notifications
You must be signed in to change notification settings - Fork 292
fix: clean up request context on stream error in deferUntilStreamConsumed #760
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
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 |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; | |
| import { | ||
| createAppPageFontData, | ||
| createAppPageRscErrorTracker, | ||
| deferUntilStreamConsumed, | ||
| renderAppPageHtmlResponse, | ||
| renderAppPageHtmlStream, | ||
| renderAppPageHtmlStreamWithRecovery, | ||
|
|
@@ -100,6 +101,60 @@ describe("app page stream helpers", () => { | |
| expect(contextCleared).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("calls onFlush when the upstream stream errors mid-consumption", async () => { | ||
| const onFlush = vi.fn(); | ||
| const streamError = new Error("component threw during streaming"); | ||
|
|
||
| // Emit one chunk, then error on the next pull — simulates a component | ||
| // throwing partway through RSC/SSR streaming. | ||
| let pullCount = 0; | ||
| const source = new ReadableStream<Uint8Array>({ | ||
| pull(controller) { | ||
| pullCount++; | ||
| if (pullCount === 1) { | ||
| controller.enqueue(new TextEncoder().encode("partial")); | ||
| } else { | ||
| controller.error(streamError); | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| const wrapped = deferUntilStreamConsumed(source, onFlush); | ||
| const reader = wrapped.getReader(); | ||
|
|
||
| // First read succeeds with the enqueued chunk. | ||
| const { value } = await reader.read(); | ||
| expect(new TextDecoder().decode(value)).toBe("partial"); | ||
|
|
||
| // Second read should surface the upstream error. | ||
| await expect(reader.read()).rejects.toThrow("component threw during streaming"); | ||
|
|
||
| // onFlush must have been called despite the error — this is the bug fix. | ||
| expect(onFlush).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("calls onFlush only once when the stream errors then is cancelled", async () => { | ||
| const onFlush = vi.fn(); | ||
| const streamError = new Error("stream error"); | ||
|
|
||
| // Error on the very first pull — simulates immediate failure. | ||
| const source = new ReadableStream<Uint8Array>({ | ||
| pull(controller) { | ||
| controller.error(streamError); | ||
| }, | ||
| }); | ||
|
|
||
| const wrapped = deferUntilStreamConsumed(source, onFlush); | ||
| const reader = wrapped.getReader(); | ||
|
|
||
| // Reading the errored stream triggers the error handler. | ||
| await expect(reader.read()).rejects.toThrow("stream error"); | ||
|
|
||
| // The idempotent once() guard prevents double invocation — even if | ||
| // some code path triggered cleanup again, onFlush fires exactly once. | ||
| expect(onFlush).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
Comment on lines
+136
to
+156
Contributor
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 test name says "errors then is cancelled" but the test doesn't actually exercise a cancel-after-error sequence — it only tests an immediate error on first pull and verifies |
||
|
|
||
| it("builds an HTML response, including link headers, and defers clearing request context until after body is consumed", async () => { | ||
| const clearRequestContext = vi.fn(); | ||
|
|
||
|
|
||
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.
Nit: after
controller.error(error), the reader from the pipedTransformStreamis left in a locked state without being explicitly cancelled. In practice this is fine because the outerReadableStreamis now errored and will never callpull()orcancel()again, so thereader(and the underlying pipe) will be GC'd. But if you wanted to be defensive about releasing the lock eagerly, you could addreader.cancel()aftercontroller.error(error). Not blocking — the current behavior is correct.