`codex exec` can hang indefinitely before the SSE stream starts (response-header wait has no timeout/retry) — Windows/Linux
What issue are you seeing?
Title: codex exec hangs indefinitely mid-run on a dead pooled connection (socket in CLOSE_WAIT, no read timeout fires, no retry) — Windows
Summary
During a long non-interactive codex exec run, the agent worked normally for ~23 minutes
(steady tool calls and file writes), then stalled completely for 70+ minutes. Inspection
showed the process alive but fully idle, with its only TCP connection to the backend inCLOSE_WAIT: the server had closed the connection, but codex never detected it and kept
waiting for a response that could never arrive. No reconnect was attempted. The documentedstream_idle_timeout_ms default (300000 ms) never fired over 70+ minutes, so the hung code
path appears not to be covered by that timeout.
Environment
- codex-cli 0.142.5 (npm
@openai/codex, latest at time of writing) - Windows 11 Pro 10.0.26100
- Auth: ChatGPT account (subscription), model
gpt-5.5,model_reasoning_effort = "high" - Config: no custom
model_providersentries, no timeout/retry overrides (defaults) windows.sandbox = "unelevated"
Invocation
Prompt piped via stdin (stdin is written once and closed — this is not the
non-TTY-stdin hang of #20919), stdout/stderr redirected to log files:
Get-Content -Raw -Encoding UTF8 prompt.md |
codex -c windows.sandbox="unelevated" --ask-for-approval never exec `
--skip-git-repo-check --sandbox workspace-write --color never --ephemeral `
-o response.md -C <workdir> - > stdout.log 2> stderr.log
Timeline (all times local, same day)
| Time | Observation |
|---|---|
| 06:31:00 | codex process starts (PID 56488) |
| 06:31:01 | single TCP connection opened to backend (Cloudflare edge, 172.64.155.209:443) |
| 06:31:08 | codex spawns its long-lived PowerShell AST-parser helper (normal) |
| 06:31 → 06:54:35 | normal operation: continuous workspace file writes (a new .rs module, ~224 lines added to another, Python edits, test files), 17.28 s CPU accumulated |
| 06:54:35 | last file write — stall begins |
| 08:01 → 08:03 | inspection (see evidence): process alive, CPU delta 0.00 s over 20 s, no new writes, -o response file never created |
| 08:07 | process killed externally after diagnosis; exit code -1; stdout/stderr logs still 0 bytes |
Evidence at hang time (~70 min into the stall)
Get-NetTCPConnection -OwningProcess <pid>:
````
State RemoteAddress RemotePort CreationTime
Bound 0.0.0.0 0 06:31:01
CloseWait 172.64.155.209 443 06:31:01
One connection, in CloseWait since it was the only one ever opened (HTTP/2 reuse for
all requests). The remote peer had sent FIN; codex never closed its side and opened no
replacement connection. No retries, no reconnects.
- Process CPU: 17.28 s total; delta 0.00 s over a 20 s sample.
- Children: only the PowerShell AST-parser helper daemon (idle; expected).
- stdout/stderr logs: 0 bytes (also 0 during the healthy phase, so stderr progress appears
buffered when redirected — makes external liveness detection harder).
Expected behavior
Either of:
- The idle/read timeout (
stream_idle_timeout_ms, default 300000 ms) aborts the dead read
within ~5 minutes and the existing retry logic (stream_max_retries /
request_max_retries) reconnects; or
- the connection pool detects/validates the half-closed connection (e.g. HTTP/2 keepalive
PING with a timeout, or pool max-idle eviction) before reusing it.
Actual behavior
The process waits indefinitely (70+ minutes observed here; a run with the same signature —
process alive, 0 CPU delta, 0 bytes output, -o file never written — was also seen on an
earlier version on 2026-06-11, so this is recurring). Anecdotally such runs sometimes
"self-recover" after roughly 2 hours, which matches the Windows TCP keepalive default
(7200 s) finally erroring the socket rather than any application-level timeout.
Hypothesis
The stall point appears to be a request issued on a reused pooled HTTP/2 connection that
the server had already closed: writing the request succeeds locally, and codex then waits
for response headers on a path that is not guarded by stream_idle_timeout_ms (which seems
to apply to active SSE reads, not to the header/connect phase of a pooled connection).
Suggested fixes
- Apply an overall per-request timeout that also covers the response-header wait on reused
connections; feed failures into the existing retry logic.
- Enable HTTP/2 keepalive PINGs (with timeout) and/or a pool max-idle age so half-closed
connections are evicted instead of reused.
- Consider unbuffered/line-buffered stderr progress when stdio is redirected, so external
supervisors can detect liveness.
Workaround we use
An external watchdog: kill the codex process when CPU delta ≈ 0 and no workspace/response
activity occurs for N minutes, then re-dispatch the run. Works, but wastes the partial run
and requires babysitting infrastructure.
What steps can reproduce the bug?
Timeline (all times local, same day)
| Time | Observation |
|---|---|
| 06:31:00 | codex process starts (PID 56488) |
| 06:31:01 | single TCP connection opened to backend (Cloudflare edge, 172.64.155.209:443) |
| 06:31:08 | codex spawns its long-lived PowerShell AST-parser helper (normal) |
| 06:31 → 06:54:35 | normal operation: continuous workspace file writes (a new .rs module, ~224 lines added to another, Python edits, test files), 17.28 s CPU accumulated |
| 06:54:35 | last file write — stall begins |
| 08:01 → 08:03 | inspection (see evidence): process alive, CPU delta 0.00 s over 20 s, no new writes, -o response file never created |
| 08:07 | process killed externally after diagnosis; exit code -1; stdout/stderr logs still 0 bytes |
Evidence at hang time (~70 min into the stall)
Get-NetTCPConnection -OwningProcess <pid>:
````
State RemoteAddress RemotePort CreationTime
Bound 0.0.0.0 0 06:31:01
CloseWait 172.64.155.209 443 06:31:01
One connection, in CloseWait since it was the only one ever opened (HTTP/2 reuse for
all requests). The remote peer had sent FIN; codex never closed its side and opened no
replacement connection. No retries, no reconnects.
- Process CPU: 17.28 s total; delta 0.00 s over a 20 s sample.
- Children: only the PowerShell AST-parser helper daemon (idle; expected).
- stdout/stderr logs: 0 bytes (also 0 during the healthy phase, so stderr progress appears
buffered when redirected — makes external liveness detection harder).
What is the expected behavior?
Expected behavior
Either of:
- The idle/read timeout (
stream_idle_timeout_ms, default 300000 ms) aborts the dead read
within ~5 minutes and the existing retry logic (stream_max_retries /
request_max_retries) reconnects; or
- the connection pool detects/validates the half-closed connection (e.g. HTTP/2 keepalive
PING with a timeout, or pool max-idle eviction) before reusing it.
Additional information
_No response_
10 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Seeing the same "no timeout ever fires" behaviour on a very different setup, which may help
narrow the code path. Also traced where the gap appears to be in the source.
Another environment, same shape
ubuntu-latest), non-interactivecodex exec[model_providers.azure],wire_api = "responses"stream_idle_timeout_ms= 300000Differences from the original report: Linux instead of Windows, Azure instead of ChatGPT auth, and
it stalls at the very start of the turn rather than 23 minutes in.
Log of one occurrence (the job was killed by a 15-minute CI cap, so the stall was still going):
Four occurrences in two days. Every time, a plain rerun of the same commit finished in ~1 minute.
Where the gap appears to be
stream_idle_timeoutis only applied after a response is obtained —codex-rs/codex-api/src/endpoint/responses.rs:And the HTTP client has no overall deadline to cover that
.await—codex-rs/http-client/src/client_builder.rsexposesconnect_timeoutonly (defaultNone);there is no
.timeout()and noread_timeout.That matches both reports: the 300s idle default cannot fire because the SSE stream has not started
yet, and nothing else bounds the wait, so the process waits forever and has to be killed.
(For the original report's
CLOSE_WAITobservation: a connection closed by the peer while awaitingresponse headers would land in exactly this unguarded window.)
Workaround, for anyone hitting this in CI
Wrapping the call so a hang is cut short and retried, rather than raising the job's cap:
One note if you do this: with
--kill-after, a process that ignores TERM exits 137, not 124 —and a process wedged on a model call is exactly that kind, so treating only 124 as "timed out"
misses the common case.
Thanks @isamu — this is very useful corroboration. I have updated the title to reflect the cross-platform scope. The Linux/Azure reproduction rules out Windows and ChatGPT authentication as necessary conditions and points to an unbounded phase in the shared Responses HTTP path.
I checked
rust-v0.145.0as well.ResponsesClient::stream_encoded()awaitsstream_encoded_json_with(...).awaitbeforespawn_response_stream(...)is created, sostream_idle_timeoutcannot cover the wait for the HTTP response headers. The lower layer does have an optional per-request timeout, butProvider::build_request()initializes it toNone, and this Responses request does not override it.ReqwestTransport::stream()can therefore remain blocked inbuilder.send().await; because no error is produced, the retry policy has nothing to retry. The same sequencing is still present on currentmain.That is consistent with the original
CLOSE_WAITobservation, although it does not by itself prove that every occurrence has the same socket-level trigger.I would not close this as a duplicate of #30526 yet. That issue has a related symptom, but it concerns
app-server, includes the additional thread-lifecycle failure (thread not found), and contains no transport or pre-header evidence. The two issues may share this lower-level gap, so cross-linking is useful, but equivalence has not been established.The appropriate fix appears to be a retryable, per-attempt deadline for establishing the HTTP stream through receipt of the response headers, followed by the existing activity-based SSE idle timeout. A general reqwest request timeout would also cover the response-body lifetime and could incorrectly cap a healthy long-lived SSE stream.
Thanks also for documenting the
137versus124behavior of the CI workaround.I'm surprised this is not a bigger issue affecting many users
Adding an independent reproduction from the interactive Codex TUI. This looks like the same general unbounded pre-SSE/stream-wait failure, although I was not able to capture the affected socket state in time, so I cannot confirm
CLOSE_WAITfor this specific turn.Environment
0.146.0codex execgpt-5.6-solxhigh019fb23e-0d89-77a1-ba83-36f5f5fde3f6The affected thread was relatively large:
170,064tokens258,40012%There was no
429or quota-exhaustion error.Timeline
Times below are local time, UTC+8.
18:00:00— User prompt was accepted and the turn started.18:00:16— First reasoning output item was recorded.18:00:28— Second reasoning output item was recorded.18:02:11— The user interrupted the stalled turn. The interrupt was accepted and the previous task was marked aborted.18:02:12— A follow-up message was accepted and a new turn started.``
text
``run_turn
-> run_sampling_request
-> try_run_sampling_request
No tool call was active, and the hooks completed normally. This does not appear to have been caused by a shell command, approval request, or project hook.
A separate analytics request failed near the beginning of the first affected turn:
That is not proof that the model request failed for the same reason, but it is a contemporaneous connectivity signal.
By the time the session was inspected a few minutes later, the process associated with this thread was no longer present. No corresponding Windows Application Error or Application Hang event was found, so I cannot determine whether it exited normally, was closed by the user, or terminated through an unlogged path.
Why this may be related
The second turn appears to have stalled after request preparation but before the first SSE event, which is consistent with the timeout gap described in this issue.
This may also overlap with #32366, which reports a long-running
gpt-5.6-solthread where the Responses request produced no stream events. The difference here is that this reproduction occurred in Codex CLI0.146.0interactive TUI.The session remains recoverable with:
I can submit in-product feedback from the affected session if an uploaded trace would help.
Thanks @rullerzhou-afk — this is useful, especially because it occurred in
0.146.0through the interactive TUI. Yes, please submit in-product feedback from the affected session and reference #31376 plus session ID019fb23e-0d89-77a1-ba83-36f5f5fde3f6. The uploaded trace is likely the best way to identify the exact wait phase.I would keep the mechanism classified as related but not yet confirmed identical. In the first turn, the reasoning items at
18:00:16and18:00:28show that its response stream had already started. Only about 103 seconds elapsed between the last recorded item and the interrupt, which is below the default 300-second stream-idle timeout. That first turn therefore does not demonstrate the pre-response-header, unbounded wait reported here.The second turn is the relevant candidate, but
run_sampling_request -> try_run_sampling_requestends above the transport layer. In0.146.0, the built-in OpenAI provider supports WebSockets, so the absence of response events does not reveal which transport was active. The most decisive trace markers would be:transport="responses_http"versustransport="responses_websocket";Request completed ... status=200 OKor response headers were recorded;If HTTP was still waiting for response headers, this directly reinforces #31376. If HTTP had already received
200 OKbut no events followed, it is closer to #32366. If WebSocket was active, it points instead to the related connect/reuse/send or first-event path. The missing process may also make lifecycle markers in the trace useful.The analytics request failure is valuable contemporaneous connectivity evidence, but, as you noted, it is a separate request path. In-product feedback is preferable to posting raw logs publicly because response headers can contain sensitive values.
Thanks for capturing this and for offering the trace.
Confirming a closely related failure signature on Windows with additional system-level evidence.
Environment
26.803.10989.0Observed behavior
The entire PC becomes severely bogged down even though CPU, physical RAM, commit, disk latency/queueing, GPU utilization, and VRAM all retain substantial headroom. Windows has repeatedly logged global ephemeral-port exhaustion:
An elevated endpoint snapshot taken about 83 minutes after that event—after most transient pressure had already cleared—found:
codex.exePID 8832 owned 34 of the machine's 35CLOSE_WAITsocketsCLOSE_WAITage was approximately 78 minutes; oldest approximately 117 minutes172.64.155.209:443; 2 targeted104.18.32.47:443CLOSE_WAITsockets in the same snapshot49152-65535, 16,384 ports), so this was not caused by a narrowed rangeThis appears consistent with the failure described here: the remote peer closes a pooled connection, but Codex does not close its side or recover cleanly. The concentration and age of the stale sockets also suggest that the issue may accumulate across concurrent/long-running Codex tasks and potentially contribute to ephemeral-port exhaustion rather than only hanging one individual run.
I cannot restart the app immediately because several long-running tasks are in flight. I have full system-performance and endpoint-owner captures and can provide them privately to OpenAI; I am not attaching them publicly because they contain process metadata and endpoint addresses.
Live recurrence after a full Windows reboot, with the desktop app left running under sustained concurrent agentic work:
Get-NetTCPConnectionstate totals were:Bound=148,Established=67,TimeWait=29,Listen=26,CloseWait=26.codex.exeprocess (PID 2272) owned:91BOUND records24CLOSE_WAIT records13ESTABLISHED recordsnvcontainer.exeandbackgroundTaskHost.exe).49152-65535(16,384 ports).Important nuance: this particular live check does not show the global TCP pool currently exhausted; the endpoint table contains only hundreds of records, not approximately 16K unique dynamic ports. It does show that the highly concentrated Codex CLOSE_WAIT signature returns quickly after reboot and again coincides with severe degradation. The earlier Windows Event ID 4231 records may reflect transient spikes that have already drained by the time a manual snapshot is taken.
The app cannot be restarted immediately because multiple local tasks are still in flight. This makes a task-level timeout/cleanup path especially important: stale pre-header/half-closed requests should be failed and retried or canceled individually rather than requiring termination of the shared desktop
codex.exeprocess.Traced this to the root cause the report already hypothesized: the responses/SSE request path never sets a per-request timeout, and the shared
reqwest::ClientBuildernever enables HTTP/2 keep-alive.Confirmed on current
main:Provider::build_requestalways setstimeout: None(codex-rs/codex-api/src/provider.rs), soReqwestTransport::stream'sbuilder.send().await(codex-rs/http-client/src/transport.rs) has no timeout applied for the header-wait phase.HttpClientBuilder::base_reqwest_builder()(codex-rs/http-client/src/client_builder.rs), which every outbound Codex HTTP client goes through (responses API, MCP HTTP transports, etc. — traced viacreate_client_for_route/create_client), never callshttp2_keep_alive_interval/http2_keep_alive_timeout/http2_keep_alive_while_idle.So a pooled HTTP/2 connection the peer silently closes (
CLOSE_WAIT, matching the report'sGet-NetTCPConnectionevidence) can be reused for a new request and hang forever: nothing below the application layer ever notices the dead socket.Fix: enable HTTP/2 keep-alive pings (bounded ack timeout, including while idle in the pool) on
HttpClientBuilder::base_reqwest_builder(), matching the report's suggested fix #2 ("Enable HTTP/2 keepalive PINGs (with timeout) and/or a pool max-idle age"). This bounds detection of a dead connection to roughlyinterval + timeoutinstead of the Windows TCP-keepalive fallback (~2h) the report observed, and applies uniformly to every Codex outbound HTTP client since they all funnel through this one builder.Also added a regression test (
codex-rs/http-client/src/client_builder_tests.rs) that accepts a TCP connection and then never reads or writes anything (simulating a peer that silently disappears), and asserts a request over that connection fails within ~150ms once keep-alive detects it, instead of hanging. It doesn't need a real HTTP/2 server: anhttp2_prior_knowledgeclient drives its own keep-alive timer as soon as it opens the socket, independent of whether the peer ever responds.Kept this first change dependency-free (no new crates), so I intentionally didn't add a lower-level test that spins up a real
h2server to reproduce the exact "healthy connection reused, then goesCLOSE_WAIT" sequence end-to-end — happy to add that as a follow-up (via theh2crate as a dev-dependency) if maintainers want tighter coverage.Verified:
cargo check -p codex-http-clientand downstream (cargo check -p codex-core -p codex-api -p codex-login)just fmtjust test -p codex-http-client(76/76 passed, including the new test)just fix -p codex-http-clientBranch: https://github.com/Excelius-Wang/codex/compare/main...Excelius-Wang:codex:fix/http2-keep-alive-dead-connections
I don't have collaborator access on this repo (PR creation here is restricted to collaborators), so I can't open a PR directly — posting the diff here in case a maintainer wants to pick it up.
Thanks @Excelius-Wang — this is a useful candidate fix, and the shared client builder is a sensible layer to evaluate. I reviewed the linked diff at
69c972e.One important distinction from the Windows captures:
CLOSE_WAITmeans the peer's FIN has already reached the local TCP stack and the local process has not completed its side of the close. The added test accepts a TCP connection and then neither reads nor writes for five seconds; it exercises keep-alive detection of an unresponsive/black-holed peer, but it does not reproduce a previously healthy HTTP/2 connection that later receives a FIN and remains inCLOSE_WAIT.That makes HTTP/2 keep-alive a valuable mitigation candidate, but I would not call the observed field root cause confirmed yet. Exact regression coverage would ideally:
A separate bounded pre-header timeout/cancellation path may still be needed. A blanket reqwest request timeout also covers the long-lived response body and could terminate legitimate SSE streams.
I can validate a maintainer/nightly Windows build against the recurring
CLOSE_WAITcounts once one is available.