#17292

fix(sse): mid-stream upstream read failure closes the client stream cleanly — a truncated 200 looks like success

Openmanjughosaopened 2d ago

Version: 3.8.52 (desktop portable, Windows)

File: open-sse/utils/earlyStreamKeepalive.ts → withEarlyStreamKeepalive()

What the shipped bundle does

(module 422520)

const reader = response.body.getReader();
let forwarded = 0;
try {
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    value && (controller.enqueue(value), forwarded += value.byteLength);
  }
} catch (r) {
  forwarded === 0 && (controller.enqueue(errorFrame), record(errorFrame));
} finally {
  ...;
  try { controller.close(); } catch {}
}

Problem

When the upstream body fails after at least one chunk was forwarded (forwarded > 0), the catch block does nothing and finally calls controller.close(). The client receives a clean end of stream — byte-identical to a normal completion. No event: error frame, no controller.error(), no non-200 status. The truncation is invisible to the caller.

Expected

A mid-stream upstream failure should be signalled to the client: controller.error(error), or an event: error frame appended after the already-forwarded bytes (legal in SSE, and what both native protocols do).

Repro

Any SSE target whose upstream socket dies mid-response — proxy reset, provider timeout, gateway-side deadline. Client result: HTTP 200 + a partial assistant message + a normal stop, so the caller treats a truncated turn as complete and never retries.

Suggested fix

catch (error) {
  if (forwarded === 0) controller.enqueue(errorFrame);
  else controller.error(error);        // or append an error frame, then close
}

Keep the pre-first-byte behaviour unchanged — that branch is correct.

Also note that controller.close() in finally masks a prior controller.error(); it should be guarded.

Activity

1
manjughosa1d ago

Adding measured evidence for this one — I hit the same bug independently and ran a differential test on 3.8.52 before finding this issue (I had filed it as #17464, now closing that as a duplicate).

Differential test

Method: load the chunk under a minimal webpack runtime shim, build an upstream response that only starts returning after 60 ms (so the slow path installs the keepalive wrapper), then read the output stream in two scenarios.

=== before (guard still `forwarded === 0`) ===
[case 1: upstream dies mid-stream]  content forwarded: true | error frame: false   <- bug reproduced
[case 2: normal completion]         content forwarded: true | error frame: false | [DONE] passthrough: true

=== after (error frame on any read failure) ===
[case 1: upstream dies mid-stream]  content forwarded: true | error frame: true    <- fixed
[case 2: normal completion]         content forwarded: true | error frame: false | [DONE] passthrough: true   <- no regression

The fix I run locally is an equal-length replacement, so the chunk byte count is unchanged.

Coverage

3.8.52 contains 8 files referencing withEarlyStreamKeepalive, but only 2 carry this catch branch. Both are affected — worth checking both when patching, otherwise the second copy keeps the old behaviour.

Downstream symptom (adds context to "looks like success")

Chain in my case: cc-switch → Claude Code. A clean EOF mid-turn makes CC conclude the assistant finished, so a tool call that was still streaming arrives as truncated JSON rendered as plain text, and the task stops halfway instead of retrying. From the client side it looks like a model quality problem, not a transport problem — which is why it took a while to trace back here.