Skip to content

Commit 645042d

Browse files
committed
fix(request): reject before onTimeout so stream stalls fail instead of ending cleanly
withTimeout invoked onTimeout before rejecting. forwardStreamingResponse's onTimeout cancels the stream reader, and cancelling settles the pending read() with {done: true} ahead of the rejection in the microtask queue, so Promise.race resolved: a stalled upstream stream was forwarded to the client as a clean end-of-stream — truncated body with a normal end(), no status.lastError, and the onStreamError failover hook never fired. The stall branch of the catch block was unreachable. Rejecting first enqueues the race's rejection ahead of any settlement the onTimeout side effects can cause, restoring the intended stall handling: the response is destroyed, lastError records the stall, and onStreamError fires. readErrorBody (the only other caller) uses a no-op onTimeout and is unaffected. The regression test pinning the old behavior is flipped to the intended expectations and documents the mechanism. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
1 parent 4d5dd51 commit 645042d

2 files changed

Lines changed: 17 additions & 14 deletions

File tree

lib/request/stream-failover-runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ export async function withTimeout<T>(
4848
promise,
4949
new Promise<T>((_resolve, reject) => {
5050
timeout = setTimeout(() => {
51-
onTimeout();
51+
// Reject BEFORE onTimeout: onTimeout side effects (e.g. cancelling a
52+
// stream reader) can settle `promise` with a clean value, and a
53+
// settlement enqueued ahead of this rejection would win the race —
54+
// turning a stall into a silent success.
5255
reject(new Error(message));
56+
onTimeout();
5357
}, Math.max(1, timeoutMs));
5458
}),
5559
]);

test/stream-failover-runtime.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,12 @@ describe("forwardStreamingResponse", () => {
232232
expect(res.chunks).toEqual([]);
233233
});
234234

235-
it("currently ends a stalled stream cleanly instead of failing it", async () => {
236-
// Pins a BUG (not the intended design): on a stall, withTimeout's
237-
// onTimeout cancels the reader, which settles the pending read() with
238-
// {done: true} BEFORE the rejection lands, so Promise.race resolves.
239-
// The stall is reported as a clean end: truncated body, res.end(), no
240-
// lastError, and onStreamError (the failover hook) never fires. The
241-
// stall branch of the catch block is unreachable today. Fixed in the
242-
// stacked follow-up PR, which flips these expectations.
235+
it("records the stall error, destroys the response, and reports failure on timeout", async () => {
236+
// Regression: withTimeout used to invoke onTimeout (which cancels the
237+
// reader and thereby settles the pending read() with {done: true})
238+
// before rejecting, so the race resolved and a stalled upstream was
239+
// forwarded as a clean end-of-stream — truncated body, res.end(), no
240+
// lastError, and onStreamError (the failover hook) never fired.
243241
const res = new FakeServerResponse();
244242
const status = createStatus();
245243
const upstream = new Response(streamOf(
@@ -250,13 +248,14 @@ describe("forwardStreamingResponse", () => {
250248
const onStreamError = vi.fn();
251249
await expect(
252250
forwardStreamingResponse(upstream, res.asServerResponse(), status, onStreamError, 25),
253-
).resolves.toBe(true);
251+
).resolves.toBe(false);
254252

255-
expect(onStreamError).not.toHaveBeenCalled();
256-
expect(status.lastError).toBeNull();
257-
expect(res.destroyed).toBe(false);
253+
expect(onStreamError).toHaveBeenCalledTimes(1);
254+
expect(status.lastError).toBe("upstream stream stalled after 25ms");
255+
expect(res.destroyed).toBe(true);
256+
expect(res.destroyError).toBeInstanceOf(Error);
258257
expect(Buffer.concat(res.chunks).toString("utf8")).toBe("data: a\n\n");
259-
expect(res.ended).toBe(true);
258+
expect(res.ended).toBe(false);
260259
});
261260

262261
it("fails the stream when the upstream read rejects mid-stream", async () => {

0 commit comments

Comments
 (0)