@@ -57,6 +57,66 @@ import {
5757} from "./codex-routing" ;
5858import { 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.
62122const 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` ) ;
0 commit comments