Long CLI threads exceed Responses WebSocket message limit before auto-compaction (close code 1009)

Open 💬 5 comments Opened Aug 16, 2026 by wegfawefgawefg

What version of Codex CLI is running?

codex-cli 0.147.0

What subscription do you have?

ChatGPT subscription (exact tier unavailable in the CLI diagnostics)

Which model were you using?

Codex model with a reported 258,400-token context window

What platform is your computer?

Linux 7.0.0-28-generic x86_64 x86_64

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

Linux terminal, no multiplexer relevant to the reproduction

Codex doctor report

Not included because the issue is isolated to one large thread and the WebSocket peer supplied a definitive close code.

What issue are you seeing?

A long CLI thread consistently fails over from the Responses WebSocket transport to HTTPS, while fresh/small threads on the same client and network use WebSockets normally.

The stock CLI reports:

Falling back from WebSockets to HTTPS transport. stream disconnected before completion: websocket closed by server before response.completed

The current model-visible context reported by the client was approximately 225,029 tokens out of 258,400. The observed auto-compaction threshold was 244,800 tokens, so auto-compaction had not yet run.

To identify the otherwise-discarded close-frame details, I built the exact rust-v0.147.0 source and made a diagnostic-only change in responses_websocket.rs to include the received Message::Close(frame) code and reason in the existing error. The same request then reported:

websocket closed by server before response.completed (with code 1009 and reason Utf8Bytes(b""))

RFC 6455 defines close code 1009 as "Message Too Big." This strongly indicates that the serialized request/history exceeds the WebSocket peer's per-message limit before reaching Codex's auto-compaction threshold.

The client retries the apparently unchanged oversized request five times. Every WebSocket attempt closes, after which HTTPS fallback succeeds and the turn completes. This makes long threads slow but still usable.

Fresh threads do not reproduce the issue. A JSON parsing error would normally produce close code 1007 or a protocol/application error rather than 1009.

What steps can reproduce the bug?

  1. Use or resume a sufficiently long Codex CLI thread. In this reproduction, current model-visible context was about 225k tokens.
  2. Send a small text-only prompt such as test again.
  3. Observe five WebSocket reconnect attempts followed by fallback to HTTPS.
  4. Instrument the existing Message::Close(frame) branch to retain the close-frame metadata.
  5. Observe peer close code 1009 with an empty reason.
  6. Send the same prompt in a fresh thread and observe that WebSocket transport works normally.

The raw rollout and terminal captures are intentionally not attached because they contain private project and authentication-related data. The affected rollout was about 5.38 GB cumulatively on disk, but this report does not assume the full rollout is sent; the relevant observation is the 225k model-visible context plus the peer's close code.

What is the expected behavior?

  • Measure or budget the serialized WebSocket request before sending it.
  • If it exceeds the supported message size, compact first or switch directly to HTTPS.
  • Do not retry an unchanged request after receiving close code 1009.
  • Preserve and display WebSocket close-frame code/reason in normal diagnostics.
  • Keep the WebSocket size threshold and auto-compaction threshold mutually compatible.

Additional information

Possibly related: #32512 documents a 16 MiB WebSocket per-message ceiling in the separate Desktop SSH-handoff path. This report concerns the ordinary CLI Responses sampling path, not artifact transfer.

No installed Codex files were modified. The diagnostic build came from the exact rust-v0.147.0 tag, and only the temporary error formatting was changed so the already-received close frame would be visible.

View original on GitHub ↗

5 Comments

shleder · 11 days ago

The 5.38 GB rollout + long-thread transport fallback makes this a useful boundary case for codex-rescue field validation. The tool won’t fix the WebSocket 1009 / transport-size mismatch; what I want to test is whether it can discover and diagnose the persisted session safely without loading/replaying unknown actions.

If the affected thread is still the latest local one, could you try:

pipx install codex-rescue
codex-rescue sessions
codex-rescue doctor --latest

Given the rollout size, please start with sessions + doctor only. A clean/healthy result is useful too because false-positive resistance is part of what I’m testing.

Please share only sanitized output, versions, timings/exit codes. No raw rollout/SQLite, prompts, credentials, or unredacted paths. Repo: https://github.com/shleder/codex-rescue

jdcodes1 · 11 days ago

Excellent forensics — the patched-build close-code capture pins this. Confirming the code side on main @ 1f41cc5d92 and adding the classification/fix seams.

The close frame really is discarded. The Responses WebSocket receive loop pattern-matches the close frame away and emits a fixed string:

https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/codex-api/src/endpoint/responses_websocket.rs#L817-L821

Message::Close(_)ApiError::Stream("websocket closed by server before response.completed"). Your diagnostic patch (including code + reason) is essentially the first fix as-is — without it, 1009 is indistinguishable from a flaky network drop, which is why the stock CLI's behavior looks so irrational.

Why five identical retries follow. The request is serialized and sent as a single Message::Text (responses_websocket.rs#L882, #L904). Once the failure is an ApiError::Stream, the retry machinery treats it like any dropped stream: stream_max_retries same-transport retries, then the transport fallback (core/src/responses_retry.rs#L86-L100 — the gate has no semantic check on the error). For 1009 the payload is byte-for-byte identical on every retry, so all five attempts are deterministic failures; only the HTTPS fallback (no equivalent per-message cap) can succeed. This is the same classification gap as #38831 (response.incomplete → generic Stream error → blind retries + fallback): semantically terminal information is flattened into "stream broke".

Why compaction never saves you. Auto-compaction triggers on token thresholds (your observed 244,800 of a 258,400 window), but the WS peer's limit is a byte cap on the serialized message. Tokens and bytes correlate loosely at best — histories heavy in JSON structure, tool output, or base64 content hit the byte cap well before the token threshold, opening exactly the window you measured (~225k tokens, under the compaction threshold, over the message cap). No token-tuned threshold can close a byte-defined gap.

Fix outline, smallest first:

  1. Include close code + reason in the error (your patch). Also worth mapping WsError::ConnectionClosed separately from a received close frame — today both collapse into unlabeled Stream errors (#L567-L569).
  2. Classify a received close 1009 as deterministic-for-this-payload: skip same-transport retries and go straight to HTTPS fallback. That alone removes the five wasted attempts and most of the latency you're seeing.
  3. Byte-aware pre-flight: the client knows the serialized request size at #L904 before sending. Comparing it against a configured/known WS message cap and preferring HTTPS (or triggering early compaction) up front turns the failure mode into a non-event, and gives auto-compaction a byte-based trigger to complement the token one.

Your reproduction (small prompt on a ~225k-token thread) is directly reusable as the regression test for (2)/(3): assert one WS attempt max and no user-visible retry storm when the serialized request exceeds the cap.

wegfawefgawefg · 11 days ago

when i instrumented it with heavier logging, it just said it was too big 5 times in a row.
The solution is pretty simple. the client just needs to truncate the message under the cap when it sends it in that's all.
I do not think the right behaviour is to skip retries or instantly default to http requests when the message goes above a certain size.
Hell, subdividing the message would be fine, but if the cap is about throughput anyways, truncation seems right to me.

That being said that call is for OpenAI to make, not me.

I had codex chase a little local patch, but its trivial and openai should probably make one go into mainstream codex-cli on next version.
I would guess a lot of users ran into this same issue and may not even realize it.

jdcodes1 · 11 days ago

Fair pushback on the retry-skipping point — one consideration on truncation, though: the WS message here isn't a stream of independent data, it's one serialized JSON request containing the conversation history. Truncating it under the cap byte-wise would produce invalid JSON (or, if done structurally, silently drop history items the model was supposed to see — changing model behavior with no user-visible signal). The system already has a semantically-safe mechanism for "history too big": compaction, which summarizes rather than drops, and is model-visible. That's why I'd frame the byte-triggered path as "compact early (or choose the transport without the cap)" rather than literal truncation — same trigger you're proposing, but routed through the mechanism that preserves request validity. Subdivision would need server-side reassembly semantics the Responses WS protocol doesn't currently expose. Agreed the final call is OpenAI's; either way the first step is the same one your patch demonstrated — stop discarding the close code.

shleder · 1 day ago

On the truncation debate: the WS payload here is one serialized JSON request containing prior turns, so byte-level truncation would corrupt the request rather than trim it gracefully. The workable seam is a client-side pre-flight byte budget over the serialized turn set - reject or shrink before send instead of discovering the cap at frame time.