You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Define a graceful drain policy for in-flight turns during shutdown #30
Current dev handles process shutdown in src/cli.ts:
SIGINT / SIGTERM call a shutdown handler;
the handler calls server.stop(true), removes the pid file, restores native Codex when not running as OCX_SERVICE, then exits.
The request paths already have good client-cancel handling in several places (abortSignal, upstream aborts, WebSocket close aborts, etc.). This issue is specifically about the process shutdown path, not normal client disconnects.
Concern
When the proxy is stopped while a long streaming /v1/responses or WebSocket turn is active, it is not obvious what contract users should expect:
should opencodex wait briefly for active turns to finish?
should it reject new requests but let existing streams drain up to a deadline?
should it actively abort in-flight upstream requests and emit/record an incomplete/aborted terminal outcome?
should ocx stop behave differently from Ctrl+C or service stop?
Right now the code appears to stop and exit immediately from the CLI shutdown handler. That is simple, but for Codex users a mid-turn stop can look like a random reconnect/stall rather than an intentional proxy shutdown.
Possible approach
A small, bounded policy might be enough:
Track active HTTP/WebSocket turns in the server.
On shutdown, stop accepting new work.
Wait up to a short configurable deadline, e.g. 5-10 seconds, for active streams to finish.
After the deadline, abort remaining upstream controllers and record/log a terminal shutdown/incomplete outcome where possible.
Keep ocx stop --force or service-manager hard kill behavior available for cases where the process must exit immediately.
This does not need to be a big reliability framework; the main value is making shutdown behavior explicit and preventing in-flight turns from being cut off without any diagnostic signal.
Question
Do you think opencodex should support a bounded graceful drain on shutdown, or is immediate shutdown the intended behavior? If you agree with adding a small drain policy, would you prefer to implement it yourself, or should I prepare a focused PR after the current dev merge settles?
There's no tracking of in-flight turns at the server level. Individual request paths do handle client-side disconnects well — bridge.ts has cancel() → clientCancelled → upstream abort, and ws-bridge.ts cancels the reader on WebSocket close — but those are reactive (client goes away), not proactive (server initiating shutdown while streams are live).
What I think makes sense
Your "possible approach" is close to what I'd want. A few specifics:
1. Track active turns — a Set<AbortController> (or similar) at the server level, incremented on /v1/responses entry and decremented on stream close/abort. Lightweight, no per-request overhead beyond set add/delete.
2. Shutdown sequence:
SIGINT/SIGTERM → set a "draining" flag → reject new /v1/responses with 503 + Retry-After: 5
Wait up to shutdownTimeoutMs (default ~5s, configurable in config.json) for the active set to empty
After deadline: abort remaining upstream controllers via the tracked set, log which turns were interrupted
Then proceed with current cleanup (pid, restore, exit)
3. ocx stop --force — skip the drain, same as today's behavior. Service manager SIGKILL after the configured timeout also acts as a hard kill.
4. Diagnostic signal — when a turn is aborted by shutdown, emit a response.failed SSE event with last_error: { type: "server_shutdown", message: "..." } before closing the stream, so Codex sees a clean terminal event instead of a dropped connection.
What I'd skip
No persistent queue, no turn replay, no checkpoint/resume. This is a proxy, not a message broker.
No separate drain behavior for WebSocket vs HTTP/SSE — both use the same timeout.
No ocx stop vs Ctrl+C distinction — both should drain. Only --force or SIGKILL should skip.
Sizing
This is maybe 80-120 lines of change across server.ts (active set + reject-when-draining) and cli.ts (timed wait loop). The bridge already emits response.failed so we'd reuse that path.
If you want to prepare a PR for this, go for it — happy to review. The scope is small enough that it shouldn't conflict with other work. Main thing to watch: Bun.serve().stop() behavior with closeActiveConnections: true vs false and how it interacts with a drain loop.
Active turn tracking: Set<AbortController> tracks all in-flight HTTP streams and WebSocket pumps at stream-lifetime granularity (not handleResponses return)
Graceful drain: SIGINT/SIGTERM//api/stop → set draining flag → reject new /v1/responses with 503 + Retry-After: 5 → wait up to shutdownTimeoutMs (default 5s, configurable in config.json) → abort remaining turns → clean exit
trackStreamLifetime() wraps Response bodies to register/unregister turns on stream close/cancel/error
WebSocket turns register at pump start, unregister in finally
New shutdownTimeoutMs config field (optional, default 5000)
Tests: 4 new tests in tests/shutdown-drain.test.ts
Activity
lidge-jun commentedon Jun 25, 2026
Thanks for the thorough write-up. I agree this is worth addressing — here's where things stand on
devand my take on a bounded approach.Current shutdown behavior
The shutdown path (
src/cli.ts:203-214) is immediate:There's no tracking of in-flight turns at the server level. Individual request paths do handle client-side disconnects well —
bridge.tshascancel()→clientCancelled→ upstream abort, andws-bridge.tscancels the reader on WebSocket close — but those are reactive (client goes away), not proactive (server initiating shutdown while streams are live).What I think makes sense
Your "possible approach" is close to what I'd want. A few specifics:
1. Track active turns — a
Set<AbortController>(or similar) at the server level, incremented on/v1/responsesentry and decremented on stream close/abort. Lightweight, no per-request overhead beyond set add/delete.2. Shutdown sequence:
SIGINT/SIGTERM→ set a "draining" flag → reject new/v1/responseswith503+Retry-After: 5shutdownTimeoutMs(default ~5s, configurable inconfig.json) for the active set to empty3.
ocx stop --force— skip the drain, same as today's behavior. Service managerSIGKILLafter the configured timeout also acts as a hard kill.4. Diagnostic signal — when a turn is aborted by shutdown, emit a
response.failedSSE event withlast_error: { type: "server_shutdown", message: "..." }before closing the stream, so Codex sees a clean terminal event instead of a dropped connection.What I'd skip
ocx stopvsCtrl+Cdistinction — both should drain. Only--forceorSIGKILLshould skip.Sizing
This is maybe 80-120 lines of change across
server.ts(active set + reject-when-draining) andcli.ts(timed wait loop). The bridge already emitsresponse.failedso we'd reuse that path.If you want to prepare a PR for this, go for it — happy to review. The scope is small enough that it shouldn't conflict with other work. Main thing to watch:
Bun.serve().stop()behavior withcloseActiveConnections: truevsfalseand how it interacts with a drain loop.fix: graceful shutdown drain + passthrough tee relay (Bun#32111)
lidge-jun commentedon Jun 25, 2026
Implemented in
1fb4fdbondev:Set<AbortController>tracks all in-flight HTTP streams and WebSocket pumps at stream-lifetime granularity (nothandleResponsesreturn)/api/stop→ set draining flag → reject new/v1/responseswith 503 +Retry-After: 5→ wait up toshutdownTimeoutMs(default 5s, configurable inconfig.json) → abort remaining turns → clean exittrackStreamLifetime()wraps Response bodies to register/unregister turns on stream close/cancel/errorfinallyshutdownTimeoutMsconfig field (optional, default 5000)tests/shutdown-drain.test.tsFull suite: 360/360 pass, tsc clean.
fix: graceful shutdown drain + passthrough tee relay (Bun#32111)
fix(devin): resolve models through catalog family metadata (carry #6091…