CLI reuses dead Responses WebSocket after network loss during idle, breaking the next turn

Open 💬 2 comments Opened Jul 14, 2026 by TamLem

What version of Codex CLI is running?

codex-cli 0.144.4

The same liveness logic is still present on the current main branch as of 2026-07-15.

What subscription do you have?

ChatGPT account authentication. The specific subscription tier does not appear material to this transport-liveness failure.

Which model were you using?

gpt-5.6-sol

What platform is your computer?

Linux 6.8.0-124-generic x86_64 x86_64

Ubuntu 22.04, npm installation.

What terminal emulator and version are you using (if applicable)?

VTE 6800, TERM=xterm-256color. No terminal multiplexer was involved.

Codex doctor report

A full report is not included because the available diagnostic run occurred inside a network-restricted harness after applying a local prototype patch, so its reachability result is not representative of the original failure.

Relevant redacted fields:

{
  "schemaVersion": 1,
  "codexVersion": "0.144.4",
  "authMode": "chatgpt",
  "model": "gpt-5.6-sol",
  "modelProvider": "openai",
  "proxyEnvVars": "none",
  "platform": "linux-x86_64",
  "installMethod": "npm"
}

What issue are you seeing?

An already-open interactive CLI chat can stop continuing normally after this sequence:

  1. A turn completes.
  2. The chat remains open but idle.
  3. Internet connectivity is interrupted long enough for the Responses WebSocket pump to exit.
  4. Connectivity returns before the user sends another prompt.
  5. The next prompt is sent in the same open chat.

This is not about codex resume, restoring a process, or reconnecting during an active streamed response. The process and TUI remain open throughout; the failure occurs on the first ordinary turn after an idle network interruption.

The cached cross-turn Responses WebSocket is treated as live even though its background pump task has already terminated. The next turn therefore attempts to reuse a dead connection and can fail instead of transparently opening a new connection and continuing with the existing conversation history.

The source-level failure path appears to be:

  • ModelClientSession caches a ResponsesWebsocketConnection across turns.
  • WsStream owns a command channel and a background pump_task.
  • The pump exits when the idle socket encounters a transport error/EOF.
  • ResponsesWebsocketConnection::is_closed() currently only checks whether stream: Option<WsStream> is None.
  • A finished pump can remain stored as Some(WsStream), so the cached connection is incorrectly reported as open.

Current implementation:

pub async fn is_closed(&self) -> bool {
    self.stream.lock().await.is_none()
}

What steps can reproduce the bug?

  1. Start an interactive Codex CLI session using the Responses WebSocket transport.
  2. Send a prompt and wait until the turn completes.
  3. Leave the CLI open and idle.
  4. Disconnect the machine from the internet long enough for the underlying WebSocket pump to observe EOF or a transport error and exit.
  5. Restore internet connectivity without closing or resuming the Codex process.
  6. Send another prompt in the same open chat.
  7. Observe that the next turn can fail/break continuity because the cached dead WebSocket is reused.

A deterministic regression test can avoid manipulating the host network:

  1. Script a WebSocket server to accept the first connection and complete one response.
  2. Close that connection while the client is idle.
  3. Wait for the client pump task to finish.
  4. Submit a second turn through the same ModelClientSession.
  5. Assert that the client creates a second WebSocket connection.
  6. Assert that the new request does not reuse a previous_response_id tied to the dead connection and instead carries the full prompt history needed to continue the conversation.

What is the expected behavior?

When the background WebSocket pump has exited, the cached connection should be considered closed.

The next prompt in the still-open chat should transparently establish a fresh Responses WebSocket and continue the same conversation using the existing retry/full-history path. The user should not need to restart or resume the session.

Additional information

A minimal fix is to include pump/channel liveness in the cached-connection check:

impl WsStream {
    fn is_closed(&self) -> bool {
        self.tx_command.is_closed() || self.pump_task.is_finished()
    }
}

pub async fn is_closed(&self) -> bool {
    self.stream
        .lock()
        .await
        .as_ref()
        .is_none_or(WsStream::is_closed)
}

A local prototype with this change compiles successfully as a 0.144.4 release and passes CLI --version / --help smoke checks. A regression test was also prepared for the two-turn/two-connection behavior, but the test suite was not run locally.

Duplicate searches performed before filing included:

  • “websocket idle internet disconnect conversation continuity”
  • “network disconnect inactivity stale websocket next turn”
  • “chat session breaks after internet reconnect idle”
  • source identifiers ResponsesWebsocketConnection, pump_task, and is_closed

No matching issue or pull request was found. Related reports about remote-control enrollment, app-server client state, active-stream disconnect loops, or IPv6 handshake fallback concern different transports or failure timing.

View original on GitHub ↗

2 Comments

i69699 · 25 days ago

I independently reproduced this on Codex Desktop/CLI 0.146.0-alpha.9.2 on Windows.

The observed sequence matches this report:

  • the cached Responses WebSocket pump exited after an idle normal close
  • the next turn was marked as reusing the authenticated connection
  • sending through tx_command failed with Connection closed normally
  • the outer sampling retry then opened a fresh WebSocket and succeeded, without falling back to HTTP

I verified that current main at bb5054fe47abe73ecbbd454751066a28c89f4bb9 still has the stale liveness check and prepared the minimal patch described above:

  • WsStream::is_closed() checks both tx_command.is_closed() and pump_task.is_finished()
  • ResponsesWebsocketConnection::is_closed() delegates to that stream-level check
  • a regression test verifies that a connection reports closed once its pump stops

Patch commit: https://github.com/i69699/codex/commit/d6a407a69d5eaba0bd40e5e17ec69f1431d84d7a

Validation on Windows:

  • cargo +stable nextest run --no-fail-fast -p codex-api: 158/158 passed
  • cargo +stable clippy --fix --tests --allow-dirty -p codex-api: passed with no warnings

The branch is ready if a maintainer would like to invite a PR.

bombaris34 · 18 days ago

I implemented and tested a broader version of the proposed fix.

Proposed branch: https://github.com/bombaris34/codex/tree/agent/fix-websocket-reconnection-fallback
Commit: https://github.com/bombaris34/codex/commit/66166d182bf9421eab5479ff880b98eec2e61735

In addition to treating a closed command channel or finished pump task as a closed cached connection, the patch:

  • preserves transport-failure origin through the internal error mapping;
  • treats WebSocket I/O, close, send, timeout, and handshake failures as transport failures;
  • falls back to HTTP only for transport failures, not server-declared response.failed errors;
  • keeps one global retry budget, giving fallback one final HTTP attempt instead of resetting the counter;
  • preserves immediate fallback for HTTP 426, existing wait-for-network behavior, healthy connection reuse, and sticky HTTP fallback;
  • adds a low-cardinality transport_failure field to existing retry logs.

It adds no heartbeat, probe, or other steady-state network traffic.

Validation:

  • just test -p codex-protocol: 273 passed
  • just test -p codex-api: 158 passed
  • focused WebSocket fallback, stale-connection reconnect, healthy-reuse, and terminal server-error tests passed
  • scoped Clippy for codex-protocol, codex-api, and codex-core passed
  • just fmt passed

This is also relevant to the intermittent reconnect symptoms reported in #37649. GitHub currently prevents this account from opening a PR because external contributions are invitation-only, but the branch is ready for a maintainer to review or invite as a PR.