Skip to content

fix: clean up request context on stream error in deferUntilStreamConsumed#760

Open
NathanDrake2406 wants to merge 1 commit intocloudflare:mainfrom
NathanDrake2406:fix/stream-error-context-leak
Open

fix: clean up request context on stream error in deferUntilStreamConsumed#760
NathanDrake2406 wants to merge 1 commit intocloudflare:mainfrom
NathanDrake2406:fix/stream-error-context-leak

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • deferUntilStreamConsumed's pull() handler had no error path for upstream stream errors
  • When a component throws during streaming, clearRequestContext() was never called, leaking per-request state (headers, cookies, navigation context)
  • Normal completion (flush) and client disconnect (cancel) were handled correctly — only the error path was missing
  • Adds error handler that calls cleanup before propagating the error to the consumer

Test plan

  • Test: stream that errors mid-consumption → onFlush callback still invoked
  • Test: immediate stream error → onFlush called exactly once (idempotent guard)
  • Existing app-page-stream tests pass (11/11)

…umed

deferUntilStreamConsumed's pull() handler had no error path — when the
upstream RSC/SSR stream errored mid-consumption, the onFlush callback
(clearRequestContext) was never invoked, leaking per-request state.
The cancel hook handled client disconnect and the flush hook handled
normal completion, but stream errors were uncovered. Adds an error
handler that calls once() before propagating the error.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@760

commit: 6c85dd3

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Clean, well-scoped fix. The error path in pull() was the only missing cleanup branch — flush (normal completion) and cancel (client disconnect) were already handled. The once() idempotency guard makes the three paths safely composable.

Tests are clear and cover both the mid-stream error and immediate error cases. All 11 tests pass.

One minor observation below, but not blocking.

},
(error) => {
once();
controller.error(error);
Copy link
Copy Markdown
Contributor

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 piped TransformStream is left in a locked state without being explicitly cancelled. In practice this is fine because the outer ReadableStream is now errored and will never call pull() or cancel() again, so the reader (and the underlying pipe) will be GC'd. But if you wanted to be defensive about releasing the lock eagerly, you could add reader.cancel() after controller.error(error). Not blocking — the current behavior is correct.

Comment on lines +136 to +156
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);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 onFlush is called once. The test is still valuable (it covers the immediate-error path and the once() guard), but the name is slightly misleading. Consider renaming to something like "calls onFlush exactly once on immediate stream error" to match what's actually being asserted.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review submitted. Here's a summary:

Approved the PR. The fix is correct and well-scoped:

  • deferUntilStreamConsumed had three terminal paths: normal completion (flush), client disconnect (cancel), and upstream error. Only the error path was missing the once() cleanup call — this PR adds it.
  • The once() idempotency guard ensures onFlush (i.e., clearRequestContext()) fires exactly once regardless of which path triggers first, making the three paths safely composable.
  • Two new tests cover mid-stream errors and immediate errors, and all 11 tests pass.

I left two non-blocking comments:

  1. A minor note about optionally cancelling the inner reader after controller.error() to eagerly release the lock (not necessary for correctness, just defensive).
  2. A suggestion to rename the second test — it says "errors then is cancelled" but doesn't actually exercise a cancel-after-error sequence; it tests the immediate-error + once() guard path.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants