Skip to content

Commit 685b7c4

Browse files
lidge-junclaude
andcommitted
feat(passthrough): clean response.failed terminal on mid-stream SSE reset
An upstream reset after headers used to tear the client connection with a raw socket error and no terminal SSE event. relaySseWithFailedTail closes any partial block, emits a synthetic response.failed (code upstream_reset) plus data: [DONE], and closes the stream. win32 keeps the pure native tee relay (Bun#32111 JS-sink segfault) — the guard test now pins that platform-gated invariant. Deliberately not a resend: the upstream already committed the request (cursor committed=non-replayable policy). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1524e96 commit 685b7c4

3 files changed

Lines changed: 141 additions & 3 deletions

File tree

src/server.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,13 @@ async function handleResponses(
457457
consumeForResponseLogMetadata(inspectBody, logCtx, turnAc.signal, () => unregisterTurn(turnAc));
458458
}
459459
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
460-
return markNativePassthroughSseResponse(new Response(nativeBody, {
460+
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
461+
// relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
462+
// mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
463+
const clientBody = process.platform === "win32"
464+
? nativeBody
465+
: relaySseWithFailedTail(nativeBody, upstream);
466+
return markNativePassthroughSseResponse(new Response(clientBody, {
461467
status: upstreamResponse.status,
462468
headers,
463469
}));
@@ -982,6 +988,54 @@ export function relayWithAbort(
982988
});
983989
}
984990

991+
/**
992+
* Relay a passthrough SSE body like relayWithAbort, but convert a MID-STREAM failure (upstream
993+
* reset after headers) into a clean terminal: any partial block is closed off, then a synthetic
994+
* `response.failed` event and `data: [DONE]` are emitted and the stream closes. Without this the
995+
* client sees a raw socket teardown with no terminal SSE event. Deliberately NOT a resend: the
996+
* upstream already committed the request (duplicate-completion risk — same policy as cursor's
997+
* committed=non-replayable transport retry).
998+
*/
999+
export function relaySseWithFailedTail(
1000+
body: ReadableStream<Uint8Array>,
1001+
upstream: AbortController,
1002+
): ReadableStream<Uint8Array> {
1003+
const reader = body.getReader();
1004+
const encoder = new TextEncoder();
1005+
return new ReadableStream<Uint8Array>({
1006+
async pull(controller) {
1007+
try {
1008+
const { done, value } = await reader.read();
1009+
if (done) {
1010+
controller.close();
1011+
return;
1012+
}
1013+
controller.enqueue(value);
1014+
} catch (err) {
1015+
const failure = {
1016+
type: "upstream_error",
1017+
code: "upstream_reset",
1018+
message: `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
1019+
};
1020+
const payload = JSON.stringify({
1021+
type: "response.failed",
1022+
response: { status: "failed", error: failure, last_error: failure },
1023+
});
1024+
try {
1025+
// Leading blank line terminates a partial SSE block so the failed frame parses cleanly.
1026+
controller.enqueue(encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\ndata: [DONE]\n\n`));
1027+
controller.close();
1028+
} catch { /* client already torn down */ }
1029+
upstream.abort();
1030+
}
1031+
},
1032+
cancel(reason) {
1033+
upstream.abort(reason);
1034+
reader.cancel(reason).catch(() => {});
1035+
},
1036+
});
1037+
}
1038+
9851039
function nextSseBlock(buffer: string): { block: string; rest: string } | null {
9861040
const match = buffer.match(/\r?\n\r?\n/);
9871041
if (!match || match.index === undefined) return null;

tests/passthrough-abort.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
3030
}
3131

3232
describe("passthrough relayWithAbort (RC2, passthrough path)", () => {
33-
test("native passthrough SSE response body avoids async-pull client wrappers", async () => {
33+
test("native passthrough SSE keeps win32 on the pure native relay (Bun#32111)", async () => {
3434
const source = await readSource("src/server.ts");
3535
const sseBranch = source.slice(
3636
source.indexOf("if (isEventStream && upstreamResponse.body)"),
@@ -42,7 +42,12 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => {
4242
);
4343

4444
expect(sseBranch).toContain("upstreamResponse.body.tee()");
45-
expect(sseBranch).toContain("new Response(nativeBody");
45+
// win32 must receive the tee'd body untouched — no JS pull wrapper (Bun#32111 segfault).
46+
expect(sseBranch).toContain('process.platform === "win32"');
47+
expect(sseBranch).toContain("? nativeBody");
48+
// Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed.
49+
expect(sseBranch).toContain("relaySseWithFailedTail(nativeBody, upstream)");
50+
expect(sseBranch).toContain("new Response(clientBody");
4651
expect(sseBranch).toContain("markNativePassthroughSseResponse");
4752
expect(sseBranch).not.toContain("relaySseWithHeartbeat(");
4853
expect(sseBranch).not.toContain("trackStreamLifetime(");

tests/sse-failed-tail.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { relaySseWithFailedTail } from "../src/server";
3+
4+
const encoder = new TextEncoder();
5+
const decoder = new TextDecoder();
6+
7+
function sourceStream(chunks: string[], opts: { failAfter?: boolean; error?: Error } = {}): ReadableStream<Uint8Array> {
8+
let i = 0;
9+
return new ReadableStream<Uint8Array>({
10+
pull(controller) {
11+
if (i < chunks.length) {
12+
controller.enqueue(encoder.encode(chunks[i++]));
13+
return;
14+
}
15+
if (opts.failAfter) {
16+
controller.error(opts.error ?? Object.assign(new Error("The socket connection was closed unexpectedly."), { code: "ECONNRESET" }));
17+
return;
18+
}
19+
controller.close();
20+
},
21+
});
22+
}
23+
24+
async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
25+
const reader = stream.getReader();
26+
let out = "";
27+
for (;;) {
28+
const { done, value } = await reader.read();
29+
if (done) return out;
30+
out += decoder.decode(value, { stream: true });
31+
}
32+
}
33+
34+
describe("relaySseWithFailedTail", () => {
35+
test("relays a healthy stream verbatim with no injected frame", async () => {
36+
const upstream = new AbortController();
37+
const src = sourceStream(["event: response.completed\n", 'data: {"type":"response.completed"}\n\n', "data: [DONE]\n\n"]);
38+
const out = await drain(relaySseWithFailedTail(src, upstream));
39+
expect(out).toBe('event: response.completed\ndata: {"type":"response.completed"}\n\ndata: [DONE]\n\n');
40+
expect(out).not.toContain("response.failed");
41+
expect(upstream.signal.aborted).toBe(false);
42+
});
43+
44+
test("mid-stream error keeps prior bytes and appends a clean failed terminal", async () => {
45+
const upstream = new AbortController();
46+
const src = sourceStream(['data: {"type":"response.output_text.delta","delta":"hel', ""], { failAfter: true });
47+
const out = await drain(relaySseWithFailedTail(src, upstream));
48+
// Prior (partial) bytes preserved, then blank-line boundary, then the failed frame.
49+
expect(out.startsWith('data: {"type":"response.output_text.delta","delta":"hel')).toBe(true);
50+
expect(out).toContain("\n\nevent: response.failed\ndata: ");
51+
expect(out.endsWith("data: [DONE]\n\n")).toBe(true);
52+
const dataLine = out.split("event: response.failed\ndata: ")[1]!.split("\n")[0]!;
53+
const parsed = JSON.parse(dataLine) as { type: string; response: { status: string; error: { code: string; message: string } } };
54+
expect(parsed.type).toBe("response.failed");
55+
expect(parsed.response.status).toBe("failed");
56+
expect(parsed.response.error.code).toBe("upstream_reset");
57+
expect(parsed.response.error.message).toContain("socket connection was closed unexpectedly");
58+
// Stream CLOSED (drain returned) rather than erroring, and the upstream fetch was aborted.
59+
expect(upstream.signal.aborted).toBe(true);
60+
});
61+
62+
test("error before any bytes yields only the failed terminal", async () => {
63+
const upstream = new AbortController();
64+
const src = sourceStream([], { failAfter: true });
65+
const out = await drain(relaySseWithFailedTail(src, upstream));
66+
expect(out).toContain("event: response.failed\ndata: ");
67+
expect(out.endsWith("data: [DONE]\n\n")).toBe(true);
68+
});
69+
70+
test("client cancel aborts the upstream controller", async () => {
71+
const upstream = new AbortController();
72+
// A source that never ends on its own.
73+
const src = new ReadableStream<Uint8Array>({ pull() { /* stay pending */ } });
74+
const relayed = relaySseWithFailedTail(src, upstream);
75+
const reader = relayed.getReader();
76+
await reader.cancel(new DOMException("client closed", "AbortError"));
77+
expect(upstream.signal.aborted).toBe(true);
78+
});
79+
});

0 commit comments

Comments
 (0)