High-reasoning Responses WebSocket idle timeout is a false dead-stream (model resumed after 8.5 min of no text frames)
What version of the Codex App are you using (From “About Codex” dialog)?
ChatGPT desktop 26.814.41407 (bundled codex-cli 0.148.0-alpha.15)
What subscription do you have?
ChatGPT Pro (plan_type=pro in local rate-limit snapshots). Auth mode: ChatGPT (not API key).
What platform is your computer?
Darwin 24.6.0 arm64 arm
macOS 15.7.7 (24G720)
What issue are you seeing?
On gpt-5.6-sol with reasoning_effort=high, a turn that is still generating can go several minutes with no Responses text frames. The client treats that silence as a dead websocket:
WARN codex_core::responses_retry
stream disconnected - retrying sampling request (n/5) ...
sampling_error=stream disconnected before completion: idle timeout waiting for websocket
The UI stays active with frozen token counts. There is no user-visible disconnect. After 5 silent retries the turn is wasted. Interrupt shows turn_aborted.
This is not (only) a dropped TCP connection. Protocol ping/pong do not reset the idle timer (they are swallowed in the websocket pump and never reach ws_stream.next()). The watchdog is tokio::time::timeout(stream_idle_timeout, ws_stream.next()) with default stream_idle_timeout_ms = 300_000. Only a text frame resets it.
stream_idle_timeout_ms / supports_websockets cannot be changed for the built-in openai provider (model_providers.openai is reserved / or_insert ignores overrides).
What steps can reproduce the bug?
- Codex Desktop, ChatGPT login, model
gpt-5.6-sol, reasoning high. - Run a long turn: many
execcalls, then a long think/write (large patch / gate file). Transport iswss://chatgpt.com/backend-api/codex/responses. - After the last commentary + tool result, the model goes quiet. No
agent_reasoning/ tool / token snapshot advances for >5 minutes. - Client logs
idle timeout waiting for websocketand retries the entire sampling request (1/5…5/5). Chat still looks “working”.
Controlled experiment (same machine, same thread, same model): a localhost reverse proxy in front of https://chatgpt.com/backend-api/codex injected a text frame every 45s while upstream was silent:
{"type":"codex.client.keepalive"}
Codex accepted the frames and ignored them as unknown events:
DEBUG codex_api::sse::responses
unhandled responses event: "codex.client.keepalive"
No idle timeout fired. After ~8.5 minutes of no real model output (18:06:20Z → 18:14:56Z on 2026-08-20), the same in-flight sampling request emitted a real custom_tool_call / patch_apply_end and continued. Frozen token counts then moved.
That is the opposite of a dead stream: the websocket and the server were still in the turn; the 300s text-frame watchdog was a false positive.
Earlier incident on the same thread without the keepalive (same day):
| UTC | What happened |
|---|---|
| 17:00:02 | Last real commentary (“now freezing the … gate”) |
| 17:07:34 / 17:13:11 / 18:18:50 | idle timeout waiting for websocket retries 1/5, 2/5, 3/5 |
| 17:21:49 | User interrupt; turn_aborted; promised file never written |
Thread id: 01a02010-e5b0-75f1-b3b7-3e0c54879f59
Turn (keepalive run): 01a02059-3cbe-70d2-9c1b-881ae7f1cfa4
Context at last good request: ~99k / 258400 input tokens (~38%). rate_limit_reached_type was null (weekly usage was high, ~95%, but not marked reached).
Same idle timeout waiting for websocket pattern on other gpt-5.6-sol high/medium Desktop turns the same day (websocket closed by server before response.completed and Connection reset without closing handshake also occur, but those are distinct).
What is the expected behavior?
- Do not treat in-progress high-reasoning silence (no Responses text frames, websocket still open) as a dead stream.
- Server or client should emit a liveness text/event that the idle waiter counts, or the idle timer should not apply until the socket is actually closed / ping fails.
- If you keep a 300s watchdog: surface it in the UI (“waiting for model, last frame Ns ago”) instead of silent
retrying sampling request (n/5)with frozen tokens. - Allow
stream_idle_timeout_ms(and/or websocket opt-out) on the built-in ChatGPTopenaiprovider. Today the only documented knobs are on custom providers.
A faster dead-stream detector (#38638, #17003) would have killed this turn at 5 minutes. The model resumed at 8.5 minutes. Faster detection of true RSTs is still good; collapsing “no text frames” into “dead” is not.
Additional information
Related: #38638, #27625, #28579, #24260, #23807, #26696, #33051.
#23807 is a 300s stall between completed responses (turn state machine). This report is idle timeout during an open sampling stream while gpt-5.6-sol high is thinking and sending no text.
#38638 matches the log line and the “cannot override built-in provider” constraint; the keepalive experiment is extra evidence that the 300s timeout is often a false dead-stream, not only slow detection of a real drop.
Code references on main (behavior matches 0.148.0-alpha.15):
DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000incodex-rs/model-provider-info/src/lib.rstimeout(idle_timeout, ws_stream.next())→"idle timeout waiting for websocket"incodex-rs/codex-api/src/endpoint/responses_websocket.rs- Ping/Pong handled inside the pump and not forwarded to
next() - Unknown JSON events: parse failure or
unhandled responses event→continue(timer already reset)
3 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Not a duplicate of #38638 — same log line, opposite diagnosis.
#38638 treats
idle timeout waiting for websocketas a dead stream that takes 300s to notice, and asks for faster detection / HTTPS fallback on the first retry.This issue is that the same 300s watchdog is a false dead-stream during in-progress high-reasoning generation. The websocket is still open; the model is just not emitting Responses text frames. Protocol ping/pong do not reset the timer.
Evidence from a controlled keepalive: injecting
{"type":"codex.client.keepalive"}(client logsunhandled responses event: "codex.client.keepalive") prevented the idle timeout. The same in-flight sampling request then produced a real tool/patch after ~8.5 minutes of no model text (18:06:20Z→18:14:56Z). Faster detection as requested in #38638 would have killed that turn at 5 minutes.Please keep this open (or fold the keepalive result into #38638 if you prefer one thread). The user-visible hang is shared; the fix is not “detect death faster,” it is “do not treat high-reasoning silence as death,” plus a UI signal instead of silent
retrying sampling request (n/5).openai/codex does not accept external PRs (
docs/contributing.md), so this is a proposed patch for the team rather than a pull request.What to change
The 300s watchdog is on application text frames, not websocket liveness.
In
codex-rs/codex-api/src/endpoint/responses_websocket.rs:WsStreamanswersPingwithPongand drops bothPingandPong. They never reachws_stream.next().run_websocket_response_streamdoestimeout(idle_timeout, ws_stream.next())withidle_timeout = provider.stream_idle_timeout(default 300_000 ms).gpt-5.6-solhigh) becomesidle timeout waiting for websocket, then silentretrying sampling request (n/5).A localhost proxy that injected
{"type":"codex.client.keepalive"}every 45s (unknown event →unhandled responses event,continue) stopped the timeout. The same in-flight request then emitted a real tool/patch after 8.5 minutes of no model text. So the stream was not dead.Suggested fix (do not just raise 300s)
Split two clocks:
Pingon an interval; treatPing/Pongas activity; fail only if ping/write fails or the socket closes. That also satisfies #38638 / #17003 (detect real death faster than 5 minutes).response.create: do not retry the whole sampling request just because nooutput_itemarrived for 300s. Optional: UI “last frame Ns ago”. Optional safety cap near the existing 60-minutewebsocket_connection_limit_reached.Do not only bump
DEFAULT_STREAM_IDLE_TIMEOUT_MS. That makes true RSTs slower to notice.Minimal code shape in the existing pump:
and in the read loop,
Ping/Pong→continue(already almost there, but those messages never arrive today). Plus a heartbeatSend { Message::Ping(..) }every ~15–20s.Config gap
[model_providers.openai] stream_idle_timeout_ms = …is ignored:merge_configured_model_providersusesor_insertforopenai. Docs document the key, but it cannot be set for ChatGPT Desktop. Allowing timeout/retry overrides on the built-in id would at least let users opt into a longer application wait without a proxy. That is a workaround, not a substitute for (1)+(2).Happy to clarify logs from thread
01a02010-e5b0-75f1-b3b7-3e0c54879f59/ turn01a02059-3cbe-70d2-9c1b-881ae7f1cfa4if useful.