Skip to content

Commit df8c047

Browse files
committed
fix: abort websocket turns before upstream headers
1 parent 3c0b3de commit df8c047

4 files changed

Lines changed: 75 additions & 6 deletions

File tree

src/server.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async function handleResponses(
100100
req: Request,
101101
config: OcxConfig,
102102
logCtx: { model: string; provider: string },
103-
options: { forceEmptyResponseId?: boolean } = {},
103+
options: { forceEmptyResponseId?: boolean; abortSignal?: AbortSignal } = {},
104104
): Promise<Response> {
105105
let body: unknown;
106106
try {
@@ -161,6 +161,7 @@ async function handleResponses(
161161
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
162162
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
163163
const upstream = new AbortController();
164+
linkAbortSignal(upstream, options.abortSignal);
164165
let upstreamResponse: Response;
165166
try {
166167
upstreamResponse = await fetch(request.url, {
@@ -199,6 +200,7 @@ async function handleResponses(
199200
// Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
200201
// not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
201202
const upstream = new AbortController();
203+
linkAbortSignal(upstream, options.abortSignal);
202204
let upstreamResponse: Response;
203205
try {
204206
upstreamResponse = await fetch(request.url, {
@@ -259,6 +261,15 @@ async function handleResponses(
259261
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
260262
}
261263

264+
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): void {
265+
if (!signal) return;
266+
if (signal.aborted) {
267+
upstream.abort(signal.reason);
268+
return;
269+
}
270+
signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
271+
}
272+
262273
const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
263274
const MAX_LOG_SIZE = 200;
264275

@@ -620,12 +631,18 @@ export function startServer(port?: number) {
620631
const turnId = (ws.data.turnId ?? 0) + 1;
621632
ws.data.turnId = turnId;
622633
const isCurrent = () => ws.data.turnId === turnId;
634+
const turnAbort = new AbortController();
635+
const cancelTurn = () => {
636+
turnAbort.abort("websocket turn superseded or closed");
637+
};
638+
ws.data.cancel = cancelTurn;
623639

624640
if (frame.generate === false) {
625641
for (const payload of buildWarmupCompletionFrames(frame)) {
626642
if (!isCurrent()) return;
627643
sendTextFrame(ws, payload);
628644
}
645+
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
629646
return;
630647
}
631648

@@ -641,7 +658,10 @@ export function startServer(port?: number) {
641658
body: JSON.stringify({ ...payload, stream: true }),
642659
});
643660
try {
644-
const response = await handleResponses(req, config, logCtx, { forceEmptyResponseId: true });
661+
const response = await handleResponses(req, config, logCtx, {
662+
forceEmptyResponseId: true,
663+
abortSignal: turnAbort.signal,
664+
});
645665
await sendResponseToWebSocket(ws, response, isCurrent);
646666
} catch (err) {
647667
if (!isCurrent()) return;
@@ -653,6 +673,8 @@ export function startServer(port?: number) {
653673
} catch {
654674
/* socket already gone or send dropped */
655675
}
676+
} finally {
677+
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
656678
}
657679
})();
658680
},

src/ws-bridge.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ export function safeResponseHeaders(headers: Headers): Record<string, string> {
3838
const out: Record<string, string> = {};
3939
for (const [name, value] of headers) {
4040
const lower = name.toLowerCase();
41-
if (SAFE_RESPONSE_HEADER_EXACT.has(lower) || lower.startsWith("x-ratelimit-")) {
41+
if (
42+
SAFE_RESPONSE_HEADER_EXACT.has(lower) ||
43+
lower.startsWith("x-ratelimit-") ||
44+
/^x-codex(?:-[a-z0-9-]+)?-(primary|secondary)-(used-percent|window-minutes|reset-at)$/.test(lower) ||
45+
/^x-codex(?:-[a-z0-9-]+)?-limit-name$/.test(lower)
46+
) {
4247
out[lower] = value;
4348
}
4449
}
@@ -240,7 +245,10 @@ export async function sendResponseToWebSocket(
240245
response: Response,
241246
isCurrent: () => boolean,
242247
): Promise<void> {
243-
if (!isCurrent()) return;
248+
if (!isCurrent()) {
249+
await response.body?.cancel().catch(() => {});
250+
return;
251+
}
244252

245253
if (!response.ok) {
246254
const text = await response.text().catch(() => "");
@@ -273,7 +281,10 @@ export async function sendResponseToWebSocket(
273281
}
274282

275283
const { prefix, stream } = await readBoundedPrefix(response.body);
276-
if (!isCurrent()) return;
284+
if (!isCurrent()) {
285+
await stream.cancel().catch(() => {});
286+
return;
287+
}
277288
if (looksLikeSse(prefix)) {
278289
await pumpResponsesSseToWebSocket(ws, stream, { isCurrent });
279290
return;

tests/passthrough-abort.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { relayWithAbort } from "../src/server";
2+
import { linkAbortSignal, relayWithAbort } from "../src/server";
33

44
function streamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
55
let i = 0;
@@ -45,4 +45,14 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => {
4545
expect(relayWithAbort(null, ac)).toBeNull();
4646
expect(ac.signal.aborted).toBe(false);
4747
});
48+
49+
test("turn-level abort signal aborts the upstream fetch before headers arrive", () => {
50+
const upstream = new AbortController();
51+
const turn = new AbortController();
52+
linkAbortSignal(upstream, turn.signal);
53+
expect(upstream.signal.aborted).toBe(false);
54+
turn.abort("replacement turn");
55+
expect(upstream.signal.aborted).toBe(true);
56+
expect(upstream.signal.reason).toBe("replacement turn");
57+
});
4858
});

tests/ws-endpoint.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,18 @@ describe("WS endpoint re-framer (120/132)", () => {
193193
"set-cookie": "secret=1",
194194
"x-ratelimit-remaining": "4",
195195
"x-codex-turn-state": "state",
196+
"x-codex-primary-used-percent": "100.0",
197+
"x-codex-primary-window-minutes": "15",
198+
"x-codex-secondary-primary-reset-at": "1781928000",
199+
"x-codex-secondary-limit-name": "Secondary",
196200
}));
197201
expect(outbound).toEqual({
198202
"retry-after": "2",
199203
"x-codex-turn-state": "state",
204+
"x-codex-primary-used-percent": "100.0",
205+
"x-codex-primary-window-minutes": "15",
206+
"x-codex-secondary-primary-reset-at": "1781928000",
207+
"x-codex-secondary-limit-name": "Secondary",
200208
"x-ratelimit-remaining": "4",
201209
});
202210
});
@@ -223,6 +231,24 @@ describe("WS endpoint re-framer (120/132)", () => {
223231
expect(JSON.parse(sent[0]).type).toBe("response.completed");
224232
});
225233

234+
test("cancels a stale successful response body before pumping", async () => {
235+
const { ws, sent } = mockWs();
236+
let cancelled = false;
237+
const body = new ReadableStream<Uint8Array>({
238+
start(controller) {
239+
controller.enqueue(new TextEncoder().encode(
240+
'data: {"type":"response.completed","response":{"id":"stale"}}\n\n',
241+
));
242+
},
243+
cancel() { cancelled = true; },
244+
});
245+
await sendResponseToWebSocket(ws, new Response(body, {
246+
headers: { "content-type": "text/event-stream" },
247+
}), () => false);
248+
expect(sent).toEqual([]);
249+
expect(cancelled).toBe(true);
250+
});
251+
226252
test("sniffs mislabelled SSE responses", async () => {
227253
const { ws, sent } = mockWs();
228254
await sendResponseToWebSocket(ws, new Response(

0 commit comments

Comments
 (0)