Skip to content

Commit 1fb4fdb

Browse files
committed
fix: graceful shutdown drain + passthrough tee relay (Bun#32111)
Graceful shutdown (#30): - Track active HTTP/WS turns via Set<AbortController> - On SIGINT/SIGTERM/api-stop: reject new /v1/responses with 503, drain up to shutdownTimeoutMs (default 5s), then abort remaining - trackStreamLifetime() wraps Response bodies for stream-bounded turn lifetime (not handleResponses-bounded) - WebSocket turns tracked at pump start/end Bun#32111 workaround (#31): - Passthrough SSE uses body.tee() + native relay — branch[0] returned directly to Response (Bun native, no JS Sink.write), branch[1] consumed in background for terminal-outcome inspection - Avoids the async-pull segfault on Windows where controller.enqueue() hits a freed buffer after client abort Closes #30, closes #31.
1 parent 5ed3dcd commit 1fb4fdb

4 files changed

Lines changed: 230 additions & 18 deletions

File tree

src/cli.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { restoreLegacyOpenaiHistory } from "./codex-history-provider";
66
import { codexAutoStartEnabled, getConfigDir, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
77
import { findAvailablePort } from "./ports";
88
import { serviceCommand, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
9-
import { startServer } from "./server";
9+
import { drainAndShutdown, startServer } from "./server";
1010
import { maybeShowStarPrompt } from "./star-prompt";
1111

1212
const args = process.argv.slice(2);
@@ -200,14 +200,15 @@ async function handleStart(options: { block?: boolean } = {}) {
200200
const server = startServer(port);
201201
writePid(process.pid);
202202

203+
const config = loadConfig();
203204
const shutdown = () => {
204205
console.log("\n🛑 Shutting down opencodex proxy...");
205-
server.stop(true);
206-
removePid();
207-
// Under the service (OCX_SERVICE), a restart re-injects on start — don't churn Codex config.
208-
// `ocx service stop/uninstall` restore explicitly.
209-
if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
210-
process.exit(0);
206+
void (async () => {
207+
await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
208+
removePid();
209+
if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
210+
process.exit(0);
211+
})();
211212
};
212213

213214
process.on("SIGINT", shutdown);

src/server.ts

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,66 @@ import {
5757
} from "./codex-routing";
5858
import { registerCodexWebSocket, unregisterCodexWebSocket } from "./codex-websocket-registry";
5959

60+
// ---------------------------------------------------------------------------
61+
// Active turn tracking + graceful shutdown drain
62+
// ---------------------------------------------------------------------------
63+
64+
const activeTurns = new Set<AbortController>();
65+
let draining = false;
66+
67+
export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
68+
export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
69+
export function isDraining(): boolean { return draining; }
70+
export function getActiveTurnCount(): number { return activeTurns.size; }
71+
72+
export function trackStreamLifetime(
73+
body: ReadableStream<Uint8Array>,
74+
ac: AbortController,
75+
): ReadableStream<Uint8Array> {
76+
registerTurn(ac);
77+
const reader = body.getReader();
78+
return new ReadableStream<Uint8Array>({
79+
async pull(controller) {
80+
try {
81+
const { done, value } = await reader.read();
82+
if (done) { unregisterTurn(ac); controller.close(); return; }
83+
controller.enqueue(value);
84+
} catch (err) {
85+
unregisterTurn(ac);
86+
try { controller.error(err); } catch { /* already closed */ }
87+
}
88+
},
89+
cancel(reason) {
90+
unregisterTurn(ac);
91+
ac.abort(reason);
92+
reader.cancel(reason).catch(() => {});
93+
},
94+
});
95+
}
96+
97+
let _serverRef: ReturnType<typeof Bun.serve> | undefined;
98+
99+
export async function drainAndShutdown(
100+
server: ReturnType<typeof Bun.serve> | undefined,
101+
timeoutMs: number,
102+
): Promise<void> {
103+
const s = server ?? _serverRef;
104+
draining = true;
105+
const deadline = Date.now() + timeoutMs;
106+
while (activeTurns.size > 0 && Date.now() < deadline) {
107+
await Bun.sleep(100);
108+
}
109+
if (activeTurns.size > 0) {
110+
console.log(`⚠️ Aborting ${activeTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
111+
for (const ac of activeTurns) {
112+
ac.abort(new Error("server shutdown"));
113+
}
114+
activeTurns.clear();
115+
}
116+
s?.stop(true);
117+
draining = false;
118+
}
119+
60120
// Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
61121
// installed npm version instead of a stale hardcode.
62122
const VERSION = (() => {
@@ -316,15 +376,28 @@ async function handleResponses(
316376
}
317377
}
318378

319-
const body = isEventStream
320-
? relaySseWithHeartbeat(
321-
upstreamResponse.body,
322-
upstream,
323-
15_000,
324-
terminalBodyWillRecord && recordTerminalOutcomes ? terminalRecorder : undefined,
325-
)
326-
: relayWithAbort(upstreamResponse.body, upstream);
327-
return new Response(body, {
379+
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
380+
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
381+
// native relay, never enters JS Sink.write); branch[1] is consumed in the
382+
// background for terminal-outcome/quota inspection only.
383+
if (isEventStream && upstreamResponse.body) {
384+
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
385+
const turnAc = new AbortController();
386+
const trackedNative = trackStreamLifetime(nativeBody, turnAc);
387+
if (terminalBodyWillRecord && recordTerminalOutcomes && terminalRecorder) {
388+
consumeForInspection(inspectBody, terminalRecorder);
389+
} else {
390+
inspectBody.cancel().catch(() => {});
391+
}
392+
return new Response(trackedNative, {
393+
status: upstreamResponse.status,
394+
headers,
395+
});
396+
}
397+
const body = relayWithAbort(upstreamResponse.body, upstream);
398+
const turnAc = new AbortController();
399+
const tracked = body ? trackStreamLifetime(body, turnAc) : null;
400+
return new Response(tracked, {
328401
status: upstreamResponse.status,
329402
headers,
330403
});
@@ -391,7 +464,9 @@ async function handleResponses(
391464
hideThinkingSummary: parsed.options.hideThinkingSummary,
392465
},
393466
);
394-
return new Response(sseStream, {
467+
const bridgeTurnAc = new AbortController();
468+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
469+
return new Response(trackedSse, {
395470
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
396471
});
397472
}
@@ -608,6 +683,54 @@ export function relaySseWithHeartbeat(
608683
});
609684
}
610685

686+
/**
687+
* Background-consume an SSE stream purely for terminal-outcome inspection (quota tracking).
688+
* Does not produce output; safe to ignore errors (the client-facing stream is separate).
689+
*/
690+
function consumeForInspection(
691+
body: ReadableStream<Uint8Array>,
692+
onTerminal: (status: ResponsesTerminalStatus) => void,
693+
): void {
694+
const reader = body.getReader();
695+
const decoder = new TextDecoder();
696+
let buffer = "";
697+
let reported = false;
698+
const pump = async () => {
699+
try {
700+
for (;;) {
701+
const { done, value } = await reader.read();
702+
if (done) {
703+
buffer += decoder.decode();
704+
if (buffer.trim() && !reported) {
705+
const payload = sseDataPayload(buffer);
706+
if (payload) {
707+
const status = terminalStatusFromSsePayload(payload);
708+
if (status) { reported = true; onTerminal(status); }
709+
}
710+
}
711+
if (!reported) onTerminal("incomplete");
712+
return;
713+
}
714+
buffer += decoder.decode(value, { stream: true });
715+
let next: { block: string; rest: string } | null;
716+
while ((next = nextSseBlock(buffer))) {
717+
buffer = next.rest;
718+
if (!reported) {
719+
const payload = sseDataPayload(next.block);
720+
if (payload) {
721+
const status = terminalStatusFromSsePayload(payload);
722+
if (status) { reported = true; onTerminal(status); }
723+
}
724+
}
725+
}
726+
}
727+
} catch {
728+
if (!reported) onTerminal("incomplete");
729+
}
730+
};
731+
pump();
732+
}
733+
611734
/**
612735
* Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
613736
* (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
@@ -967,7 +1090,10 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
9671090
const { stopServiceIfInstalled } = await import("./service");
9681091
stopServiceIfInstalled();
9691092
restoreNativeCodex();
970-
setTimeout(() => process.exit(0), 200);
1093+
setTimeout(async () => {
1094+
await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
1095+
process.exit(0);
1096+
}, 200);
9711097
return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." });
9721098
}
9731099

@@ -1026,6 +1152,9 @@ export function startServer(port?: number) {
10261152
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
10271153
// handshake-time only, so capture inbound headers and thread them into the pipeline.
10281154
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1155+
if (draining) {
1156+
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(), "Retry-After": "5" } });
1157+
}
10291158
const apiAuthError = requireApiAuth(req, config, "data-plane");
10301159
if (apiAuthError) return apiAuthError;
10311160
if (!isLocalOrigin(req)) {
@@ -1090,6 +1219,9 @@ export function startServer(port?: number) {
10901219
}
10911220

10921221
if (url.pathname === "/v1/responses" && req.method === "POST") {
1222+
if (draining) {
1223+
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(), "Retry-After": "5" } });
1224+
}
10931225
const apiAuthError = requireApiAuth(req, config, "data-plane");
10941226
if (apiAuthError) return apiAuthError;
10951227
if (!isLocalOrigin(req)) {
@@ -1151,6 +1283,8 @@ export function startServer(port?: number) {
11511283

11521284
const payload: Record<string, unknown> = { ...frame };
11531285
delete payload.type;
1286+
const wsTurnAc = new AbortController();
1287+
registerTurn(wsTurnAc);
11541288
void (async () => {
11551289
const logCtx = { model: "unknown", provider: "unknown" };
11561290
const fwd = new Headers({ "content-type": "application/json" });
@@ -1192,6 +1326,7 @@ export function startServer(port?: number) {
11921326
/* socket already gone or send dropped */
11931327
}
11941328
} finally {
1329+
unregisterTurn(wsTurnAc);
11951330
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
11961331
}
11971332
})();
@@ -1203,6 +1338,8 @@ export function startServer(port?: number) {
12031338
},
12041339
});
12051340

1341+
_serverRef = server;
1342+
12061343
console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
12071344
console.log(` POST /v1/responses → provider translation`);
12081345
console.log(` GET /healthz → health check`);

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ export interface OcxConfig {
183183
stallTimeoutSec?: number;
184184
/** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 30000. */
185185
connectTimeoutMs?: number;
186+
/** Graceful shutdown drain timeout (ms). Active turns are aborted after this deadline. Default 5000. */
187+
shutdownTimeoutMs?: number;
186188
/** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
187189
websockets?: boolean;
188190
/** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */

tests/shutdown-drain.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
registerTurn,
4+
unregisterTurn,
5+
isDraining,
6+
getActiveTurnCount,
7+
trackStreamLifetime,
8+
} from "../src/server";
9+
10+
describe("active turn tracking", () => {
11+
test("register/unregister tracks active turns", () => {
12+
const ac1 = new AbortController();
13+
const ac2 = new AbortController();
14+
const before = getActiveTurnCount();
15+
registerTurn(ac1);
16+
registerTurn(ac2);
17+
expect(getActiveTurnCount()).toBe(before + 2);
18+
unregisterTurn(ac1);
19+
expect(getActiveTurnCount()).toBe(before + 1);
20+
unregisterTurn(ac2);
21+
expect(getActiveTurnCount()).toBe(before);
22+
});
23+
24+
test("isDraining() is false by default", () => {
25+
expect(isDraining()).toBe(false);
26+
});
27+
});
28+
29+
describe("trackStreamLifetime", () => {
30+
test("registers on start and unregisters on stream close", async () => {
31+
const enc = new TextEncoder();
32+
const chunks = [enc.encode("hello"), enc.encode("world")];
33+
let i = 0;
34+
const source = new ReadableStream<Uint8Array>({
35+
pull(controller) {
36+
if (i < chunks.length) controller.enqueue(chunks[i++]);
37+
else controller.close();
38+
},
39+
});
40+
const ac = new AbortController();
41+
const before = getActiveTurnCount();
42+
const tracked = trackStreamLifetime(source, ac);
43+
expect(getActiveTurnCount()).toBe(before + 1);
44+
45+
const reader = tracked.getReader();
46+
const dec = new TextDecoder();
47+
let text = "";
48+
while (true) {
49+
const { done, value } = await reader.read();
50+
if (done) break;
51+
text += dec.decode(value, { stream: true });
52+
}
53+
expect(text).toBe("helloworld");
54+
expect(getActiveTurnCount()).toBe(before);
55+
});
56+
57+
test("unregisters on cancel", async () => {
58+
const source = new ReadableStream<Uint8Array>({
59+
pull() {
60+
// never closes — simulate long stream
61+
},
62+
});
63+
const ac = new AbortController();
64+
const before = getActiveTurnCount();
65+
const tracked = trackStreamLifetime(source, ac);
66+
expect(getActiveTurnCount()).toBe(before + 1);
67+
68+
await tracked.cancel("test cancel");
69+
expect(getActiveTurnCount()).toBe(before);
70+
expect(ac.signal.aborted).toBe(true);
71+
});
72+
});

0 commit comments

Comments
 (0)