Skip to content

fix(codex): keep oversized Responses turns off the WS transport - #2473

Merged
lidge-jun merged 2 commits into
lidge-jun:devfrom
olddonkey:fix/codex-ws-oversized-frame
Aug 24, 2026
Merged

fix(codex): keep oversized Responses turns off the WS transport#2473
lidge-jun merged 2 commits into
lidge-jun:devfrom
olddonkey:fix/codex-ws-oversized-frame

Conversation

@olddonkey

@olddonkey olddonkey commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #2426.

The Codex backend closes the socket on any inbound message of 16 MiB or more without sending a Responses terminal event. codexWsUpstreamFetch only fell back to SSE when the upgrade failed, so a close after open became a bare 502 upstream_server_error with no fallback — and a thread that crossed the ceiling could never recover, because every retry resent the same oversized frame.

I measured the boundary against the live endpoint on 2026-08-23:

16,777,000 B = 16.000 MiB -> OK   (6.6s, response.completed)
16,777,300 B = 16.000 MiB -> FAIL (1.0s, socket closed before any event)

Exactly 16 MiB = 16,777,216 B, reproducible byte-for-byte. It is not a Bun send-side cap — a local Bun WS server with maxPayloadLength: 512 MiB accepts a 20 MiB frame from the same client — so the ceiling is the backend's, consistent with close code 1009. #2426's control observation shows the same body still succeeding over HTTP SSE on 2.28.0.

The fix: size the response.create frame before dialing and take the SSE path when it does not fit. Deciding before the socket opens is what keeps the resend safe — after open the caller already holds a streaming Response, and retrying there could double-generate the turn (the concern raised in #2426).

Two supporting changes in the same path:

  • Carry the WS close code and reason into the stream error. A 1009 was indistinguishable from an ordinary network drop, and neither usage.jsonl nor /api/logs recorded the real cause — the only place the truth survived was the client's rollout file. An oversized close now says so.
  • Apply the provider's upstreamHttpVersion pin to the SSE fallback. providerFetch passed the raw base fetch as the fallback, so any WS turn that fell back lost the operator's protocol pin. That was already true for the existing fallbacks; this change makes the fallback a routine path, so it is fixed here rather than left as a latent hole.

Not in scope, and worth separate issues: an image budget / downscaling for inline input_image payloads (what actually keeps threads away from the ceiling — under full replay ~11 pasted screenshots is enough to cross it), and suppressing account rotation on a 1009-class refusal.

Verification

Exact head f093bf06f, rebased onto a60d51748

  • ./node_modules/.bin/bun test tests/ws-upstream.test.ts tests/upstream-http-version.test.ts tests/request-pacing.test.ts tests/cursor-adapter.test.ts tests/agent-task-recovery.test.ts tests/agent-task-recovery-security.test.ts — 113 passed, 1 runtime-specific skip.
  • ./node_modules/.bin/bun run typecheck — passed.
  • ./node_modules/.bin/bun run privacy:scan — passed.
  • git diff --check upstream/dev...HEAD — passed.
  • bun run test:changed is not available on current dev; that script is introduced by the still-open feat(test): add test:changed and make it the local check during implementation #2429, so it was not reported as passed.
  • ./node_modules/.bin/bun run test — 2 failures across 14,574 tests / 907 files in 636.51s:
    • tests/key-login-live-update.test.ts reproduces identically on untouched dev at a60d51748;
    • the Codex shim delayed-redispatch timing test failed under the full-suite load, then passed 1/1 in an isolated exact-head rerun.
  • No WS, Responses, fetch-helper, pacing, or adjacent recovery test failed. Because the exact-head full-suite invocation itself was not green, the PR remains Draft and local-CI/readiness stay unchecked.
  • The measured upstream 16 MiB boundary and the real-thread replay evidence in the Summary are retained as historical reproduction evidence; they were not re-probed against a live paid upstream during this rebase.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. — No user-facing surface changes; transport selection is internal.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. — The new error text carries only the WS close code and the backend's own close reason, and still passes through redactSecretString at the log layer. No auth or credential paths touched; the fallback reuses the same authenticated fetch the non-WS branch already used.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • Bug Fixes
    • Preserved the configured upstream HTTP version for standard and fallback requests.
    • Automatically falls back to HTTP streaming when WebSocket requests exceed the supported size.
    • Added UTF-8-aware request size validation with a safety margin.
    • Improved WebSocket error messages, including oversized payloads and unexpected connection closures.
  • Reliability
    • Maintained connection preconnect behavior across supported request paths.
    • Preserved WebSocket usage for requests within the supported size limit.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fcbab0c0-24f9-4e41-bf47-9ff59f6eaeb2

📥 Commits

Reviewing files that changed from the base of the PR and between 5a3d32c and 566a471.

📒 Files selected for processing (2)
  • src/server/responses/ws-upstream.ts
  • tests/ws-upstream.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The change wraps provider HTTP fetches, enforces a UTF-8 byte limit for Codex WebSocket create frames, routes oversized requests through HTTP SSE, and reports upstream WebSocket close details.

Changes

Codex WebSocket safety and fallback

Layer / File(s) Summary
Provider HTTP fetch wrapper
src/server/responses/fetch-helpers.ts
providerFetch applies withUpstreamHttpVersion and forwards preconnect for normal requests and Codex WebSocket HTTP fallback requests.
Create-frame limit and HTTP fallback
src/server/responses/ws-upstream.ts, tests/ws-upstream.test.ts
Codex create frames use a UTF-8 byte limit set 64 KiB below the 16 MiB backend ceiling. Oversized frames use HTTP SSE. Tests cover UTF-8 sizing, boundaries, HTTP-version preservation, fallback, and fitting WebSocket frames.
Upstream close diagnostics
src/server/responses/ws-upstream.ts, tests/ws-upstream.test.ts
Pre-terminal WebSocket closures now report close codes and reasons. Close code 1009 receives a specific oversized-frame message. Tests cover codes 1009 and 1006.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🟡 Moderate · up to 566a4

The change routes oversized request frames to SSE and preserves close diagnostics and protocol settings. The current head is not merge-ready because the full test run was not green and readiness remains unchecked; one failure reproduces on the base branch and the other passed in isolation, so this is a readiness risk rather than an identified functional regression.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant wsUpstream
  participant providerFetch
  participant HTTP_SSE
  participant WebSocket
  Client->>wsUpstream: submit response.create request
  wsUpstream->>wsUpstream: measure UTF-8 frame size
  alt frame exceeds configured limit
    wsUpstream->>providerFetch: use wrapped HTTP fetch
    providerFetch->>HTTP_SSE: send HTTP SSE request
  else frame fits configured limit
    wsUpstream->>WebSocket: open WebSocket connection
    WebSocket-->>wsUpstream: close event with code and reason
  end
Loading

Suggested reviewers: ingwannu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: oversized Codex Responses turns now avoid the WebSocket transport.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed; the review readiness checklist is complete.

Review readiness checklist

  • ✅ All CI tests are green on my local testing.
  • ✅ I pushed my PR to the latest dev commit.
  • ✅ I resolved all correct Codex and CodeRabbit findings.
  • ✅ My PR is ready for review.

4/4 boxes ticked.

This pull request is already Ready for Review.
The review-ready label marks this PR as ready; review automation runs independently.
Maintainers: @lidge-jun @Ingwannu

@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 63 / 80

설명: 이 풀 리퀘스트는 2426 을 고친다. 코덱스 위 웹소켓이 열여섯 메가 이상 장을 1009 로 닫고, 열린 뒤에는 에스에스로 안 돌아가서 긴 일이 502 로 영원히 죽는 구멍이다. 작성자는 올드동키다. 오늘 열다섯 시 서른여섯 분에 열렸다. 베이스는 지금 HEAD c44e43f00 다. 초안이다. 점검 네 칸이 비어 있다. 위생은 통과다. Closes 2426 이다. 파일 셋, 더하기 181, 빼기 7 이다. 지금 CURRENT dev HEAD 는 c44e43f00 이다. 이번 시간에 origin/dev 는 438b9cc77 에서 여기로 왔다. 새 머지는 2469 와 2453 이다. package.json 은 2.27.0 이다. src/config.ts 는 3238줄이다. src/runtime 폴더는 지금 HEAD 에 없다. combo-stream-preflight.ts 는 171줄이고 HEAD 에 있다. gui/src/combo-workspace-data.ts 는 589줄이다. src/providers/default-aliases.ts 와 model-presets.ts 는 아직 없다. src/codex/history-provider.ts 는 1557줄이다. src/server/responses/fetch-helpers.ts 는 137줄이다. src/codex/history-manifest.ts 는 112줄이다.

지금 HEAD 를 열었다. src/server/responses/ws-upstream.ts 82줄 shouldUseCodexWsUpstream 은 번이 되고, 주소가 맞고, 포스트이고, 본문이 글자이고, 맨 위 stream 이 참이면 위 웹소켓을 고른다. 본문 크기를 보는 칸은 없다. 125줄이 stream 을 지우고 type 을 response.create 로 붙인 한 장을 만든다. 그 다음에도 크기 가드가 없다. 282줄 close 처리는 사건 인자를 안 받는다. 300줄은 열린 뒤에 끊기면 그 문장만 내고 에스에스로 안 돌아간다. src/server/responses/fetch-helpers.ts 71줄이 조건이 참이면 위 길로 보낸다. 72줄 실패 대체 인자는 제공자 원본 base 다. 위 길이 에스에스로 내려가도 운영자가 고른 upstreamHttpVersion 핀이 빠진다. 28줄 MAX_CODEX_WS_FRAME_BYTES 와 29줄 MAX_CODEX_WS_QUEUE_BYTES 는 받는 쪽 한도다. 나가는 열여섯 메가와 다른 칸이다. tests/ws-upstream.test.ts 는 나가는 크기 시험이 없다.

이 PR 이 하는 일은 예전 리뷰가 말한 첫 바퀴와 같다. 125줄에서 장을 만든 뒤에, 소켓을 열기 전에 장 바이트를 잰다. 한도는 열여섯 메가에서 예순넷 키로를 뺀 값이다. 산 측정에서 16777000 은 살고 16777300 은 죽었다. 이 한도는 그 아래다. 넘으면 소켓을 안 열고 처음부터 에스에스다. 유티에프8 바이트로 잰다. 글자 수로 안 잰다. 닫힘 코드 1009 는 이제 큰 장 거절이라고 적는다. 다른 코드도 번호가 붙는다. fetch-helpers 는 실패 대체를 핀이 있는 에이치티티피 fetch 로 바꾼다. 시험 여덟이 더해진다. 큰 장은 소켓을 안 연다. 맞는 장은 위를 탄다. 1009 문장과 1006 문장을 잠근다.

범위는 맞다. 그림 줄이기와 턴 안 증분은 안 넣었다. 1009 에서 계정 회전을 막는 것도 안 넣었다. 그건 다음 기차가 맞다. 크기 가드를 shouldUseCodexWsUpstream 에 넣지 않은 것도 맞다. 실제로 나가는 글은 125줄 장이다. 본문 원본을 재면 type 칸만큼 어긋난다. 점검 네 칸은 비어 있다. 다만 베이스 커밋은 지금 HEAD 와 같다. 최신 칸이 비어 있어도 이미 최신 위에 있다. 로컬 시험 넷이 실패했다고 적혀 있다. 작성자는 부모 커밋에서도 같다고 했다. 깃허브 초록을 이 PR 만으로 단정하지 않는다. 지금 머지하지 말 것. 착지 전에 2426 을 닫지 말 것.

사용자 길이로는 긴 일이 열여섯 메가에서 영원히 502 로 끝나서 63. 초안이라 지금 착지하면 안 된다. 2471 은 같은 구멍의 닫힌 복사본이다. 다시 열지 말 것. 산 추적은 2426 이다. 카탈로그 팁은 Ox Alpha x-preview-f-free + deepseek-v4-flash-vision-exp. Cursor 정적 카탈로그는 opus-4-8-fast / opus-5-fast. 2334 CursorCredentialRouter 는 여전히 src/providers/cursor-pool.ts 모듈+테스트만 있고 어댑터에 연결되지 않았다. 2332 H2 는 discovery 전용. 2320 overflow + 2342 는 이미 dev. 2188 사이드카는 이미 dev. 2361 __omit__ 는 이미 HEAD 다. src/lib/bounded-body.ts 128줄은 이미 죽은 신호에서 취소 없이 던진다. 2453 이 착지해서 wait 허용 목록은 이제 밑줄 yield_time_ms 이다. types.ts/config.ts 가르기는 이 구멍을 바꾸지 않는다. 닫고 다시 밑지 말 것. 이 파일은 ws-upstream.ts 다. 2426 나가는 크기 가드는 이 PR 이 막는다. 착지 전에는 HEAD 구멍이 남는다.

src/server/responses/ws-upstream.ts 라인 82 - HEAD 의 shouldUseCodexWsUpstream 은 본문 크기를 안 본다. 이 PR 도 여기가 아니라 장 만든 뒤에 잰다
src/server/responses/ws-upstream.ts 라인 125 - 실제로 나가는 장이다. 이 PR 이 그 다음에서 한도를 본다
src/server/responses/ws-upstream.ts 라인 282 - HEAD 의 close 처리는 사건 인자를 안 받는다. 이 PR 이 1009 와 이유를 붙인다
src/server/responses/ws-upstream.ts 라인 300 - HEAD 는 열린 뒤에 끊기면 에스에스로 안 돌아간다. 이 PR 의 기본은 열기 전 가드다
src/server/responses/fetch-helpers.ts 라인 72 - HEAD 의 실패 대체는 제공자 원본 base 다. 핀이 빠진다. 이 PR 이 핀 있는 에이치티티피로 바꾼다
tests/ws-upstream.test.ts - HEAD 는 나가는 열여섯 메가 시험이 없다. 이 PR 이 여덟을 더한다
이슈 2426 - 이 PR 이 닫겠다고 적었다. 착지 전에는 이슈를 닫지 않는다
점검 네 칸 - 모두 비어 있다. 초안이다. 지금 머지하지 말 것

메인테이너의 판단이 필요한 지점

  • 초안 점검을 채우기 전에 머지할지. 지금은 머지하지 말 것
  • 한도를 125줄 장 바이트로 잴지. 재는 편이 맞다. 본문 원본이 아니다
  • 그림 줄이기와 1009 회전 막기를 이 기차에 넣을지. 넣지 말 것. 다음 기차다
  • 착지 전에 2426 을 닫을지. 닫지 말 것
  • 메인 2.31.0 핫픽스인지, origin/dev 에 먼저 넣을지. 지금 HEAD 에도 같은 구멍이 있다
  • 2471 을 다시 열지 말 것. 서식 없음으로 닫혔고 2426 이 산 추적이다

너의 추천
초안으로 둔다. 지금 머지하지 말 것. 점검 네 칸과 깃허브 초록이 채워진 뒤에 본다. 착지하면 2426 을 이 PR 로 닫는다. 한도는 장 바이트다. 넘으면 처음부터 에스에스다. 1009 는 계정을 돌리지 말 것. 그건 다음 기차다. 그림 줄이기도 다음 기차다. 2471 은 다시 열지 말 것. 2210 과 2279 와 2463 과 2464 과 2465 와 2472 는 닫지 않는다. 호출 길을 넓히지 말 것. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.

이 댓글은 grok-bot이 작성했습니다

@olddonkey
olddonkey force-pushed the fix/codex-ws-oversized-frame branch from 5727e6c to 5a3d32c Compare August 24, 2026 08:30
@github-actions
github-actions Bot marked this pull request as ready for review August 24, 2026 08:31
lidge-jun added a commit to L-Y-J/opencodex that referenced this pull request Aug 24, 2026
Opens the docs-only cycle for the next release train. The planning note this
started from targeted v2.31.1; that baseline is void because v2.32.0 shipped
from main on 2026-08-24. This unit re-derives the baseline from live git state
and plans the train as v2.32.1, bugfix-only.

The first draft got the branch relationship wrong: it read a one-way
--is-ancestor result as divergence. An independent audit re-ran both directions
and dev turns out to be an ancestor of main, 0 ahead and 27 behind, with a
one-line tree delta. wp1 is therefore a fast-forward, not a backmerge, and the
correction is recorded in the document rather than quietly fixed.

Three audit rounds moved two other things. lidge-jun#2427 was reordered from first to
last: changing the test runner before the runtime fixes would make every later
failure ambiguous between a real regression and parallel-execution flakiness.
And lidge-jun#2472's regression got its own work-phase (wp9) once the audit pointed out
the plan had made it a mandatory gate while assigning nobody to write it.

Contents: 000 baseline/scope/roadmap, 001 verbatim reviewer-lane evidence, and
one diff-level decade doc per implementation phase (010 wp1, 020 wp3/lidge-jun#2483,
030 wp4/lidge-jun#2481, 040 wp5/lidge-jun#2473, 050 wp6/lidge-jun#2477, 060 wp7/lidge-jun#2476, 070 wp2/lidge-jun#2427,
080 wp8 freeze, 090 wp9/lidge-jun#2472).

No code changes. No promotion, tag, or publish.
The Codex backend closes the socket on any inbound message of 16 MiB or
more without sending a Responses terminal event, which reached clients as
a bare 502 upstream_server_error. Because the wrapper only fell back to
SSE when the *upgrade* failed, a thread that crossed the ceiling could
never recover: every retry resent the same oversized frame.

Measured against the live endpoint on 2026-08-23: 16,777,000 B completed,
16,777,300 B closed the socket in ~1s, reproducibly. The same body still
succeeds over HTTP SSE, so the limit belongs to this transport alone.

Size the `response.create` frame before dialing and take the SSE path when
it does not fit. Deciding before the socket opens is what keeps the resend
safe -- after open the caller already holds a streaming Response, and a
retry there could double-generate the turn.

Two supporting changes:

- Carry the WS close code and reason into the stream error. A 1009 was
  previously indistinguishable from a network drop, and nothing in
  usage.jsonl or /api/logs recorded the real cause.
- Apply the provider's `upstreamHttpVersion` pin to the SSE fallback. The
  fallback is a routine path now, and serving a turn over HTTP while
  silently dropping the operator's protocol pin is wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@olddonkey
olddonkey force-pushed the fix/codex-ws-oversized-frame branch from 5a3d32c to f093bf0 Compare August 24, 2026 18:37
@olddonkey
olddonkey marked this pull request as draft August 24, 2026 18:37
The sizing helper already had unit tests, but nothing proved the real
serialized frame routes correctly one byte on each side of the limit.
That gap matters because the request body is not the frame: `stream` is
deleted and `type` is added before sending, so padding sized against the
body sits eleven bytes away from what is actually transmitted. An
off-by-one would live exactly there and pass every existing test.

These two build padding so the serialized frame is exactly limit-1 and
exactly limit, then assert the whole path: one socket and one send of the
expected byte length under, zero sockets and one SSE call at it. Flipping
the gate from >= to > fails the second one, so it catches a real
off-by-one at the transport level rather than only in the helper.

Two comment corrections while here. The close-code comment claimed the
named 1009 message makes the failure diagnosable from the logs; it does
not. The eager relay turns any stream error into a generic
`upstream_reset` synthetic terminal without feeding it back through the
inspector, so `/api/logs` retains only `streamAborted`. The message
reaches the client and stops there, and the comment now says so rather
than promising observability the code does not deliver.

The margin comment described 64 KiB as absorbing a future append. There
is no append. It is a conservative cushion, and the useful thing to
record is what it actually covers: RFC 6455 framing is 14 bytes at this
payload size — an 8-byte extended length plus a 4-byte client mask — so
even a backend counting frame headers has ~65.5 KiB of room.

Tests: 59 pass, 1 skip across ws-upstream, sse-failed-tail, and
upstream-http-version. tsc --noEmit clean.
@lidge-jun

Copy link
Copy Markdown
Owner

This is the right shape for the defect, and the call order is what makes it work: the frame is serialized at ws-upstream.ts:180, measured at :189, and the SSE fallback returns at :190new WebSocket is not reached until :213. An oversized turn therefore cannot open a socket, which is what makes the double-generation failure unreachable rather than merely unlikely.

I pushed one commit (566a4714d) adding tests and correcting two comments. No behavior change.

The test gap. The sizing helper had unit coverage, but nothing proved the real serialized frame routes correctly at the boundary. That distinction is load-bearing here: the request body is not the frame, because stream is deleted and type is added before sending.

body   51 bytes: {"model":"gpt-5.6-luna","stream":true,"padding":""}
frame  62 bytes: {"model":"gpt-5.6-luna","padding":"","type":"response.create"}

Padding sized against the body sits 11 bytes away from what is transmitted, so an off-by-one would live exactly there and pass every existing test. The two new cases build padding so the serialized frame is exactly limit - 1 and exactly limit, then assert the whole path — one socket and one send of the expected byte length under, zero sockets and one SSE call at it. Flipping the gate from >= to > fails the second one, so it catches a transport-level off-by-one rather than only a helper-level one.

Two comments were promising more than the code delivers.

The close-code comment said naming 1009 makes the failure diagnosable from the logs. It does not. The eager relay converts any stream error into a generic upstream_reset synthetic terminal without feeding it back through the inspector, so /api/logs retains only streamAborted. Verified end to end: the client sees your specific message, the client code is still upstream_reset, and the log keeps neither. The message reaches the caller and stops there — the comment now says exactly that, and notes typed observability is deliberately out of scope for a transport fix.

The margin comment described 64 KiB as absorbing a future append; there is no append. It is a conservative cushion, so I recorded what it actually covers: RFC 6455 framing at this payload size is 14 bytes (8-byte extended length + 4-byte client mask), leaving roughly 65.5 KiB of room even if the backend counted frame headers.

On the fast path. I went looking for a defect in the length * 3 bound, expecting lone surrogates to break it. They do not — Buffer.byteLength encodes an unpaired surrogate as U+FFFD at exactly 3 bytes per code unit, the bound's worst case rather than past it, and JSON.stringify escapes them to ASCII before they reach the frame anyway. The optimization is sound for every JavaScript string.

Verification: 59 pass / 1 skip / 0 fail across ws-upstream, sse-failed-tail, and upstream-http-version; tsc --noEmit clean. Boundary behavior confirmed exact across ASCII, 3-byte, and 4-byte encodings.

Approving once Cross-platform CI is green.

@github-actions
github-actions Bot marked this pull request as ready for review August 24, 2026 21:00

@lidge-jun lidge-jun left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All required jobs are green at 566a4714d.

The macOS suite needed one re-run, failing Cursor discovery bounded retry > retries a transient timeout once with a fresh session — a timing-bounded test in a file that does not import either changed module, and 41 pass / 0 fail locally on this branch. That is the third distinct macOS timing flake observed across this release train, each in a different subsystem; none has been attributable to the diff under test.

@lidge-jun
lidge-jun merged commit 84ade0f into lidge-jun:dev Aug 24, 2026
46 of 48 checks passed
aljjang95 pushed a commit to aljjang95/opencodex that referenced this pull request Aug 24, 2026
…e-jun#2473)

* fix(codex): keep oversized Responses turns off the WS transport

The Codex backend closes the socket on any inbound message of 16 MiB or
more without sending a Responses terminal event, which reached clients as
a bare 502 upstream_server_error. Because the wrapper only fell back to
SSE when the *upgrade* failed, a thread that crossed the ceiling could
never recover: every retry resent the same oversized frame.

Measured against the live endpoint on 2026-08-23: 16,777,000 B completed,
16,777,300 B closed the socket in ~1s, reproducibly. The same body still
succeeds over HTTP SSE, so the limit belongs to this transport alone.

Size the `response.create` frame before dialing and take the SSE path when
it does not fit. Deciding before the socket opens is what keeps the resend
safe -- after open the caller already holds a streaming Response, and a
retry there could double-generate the turn.

Two supporting changes:

- Carry the WS close code and reason into the stream error. A 1009 was
  previously indistinguishable from a network drop, and nothing in
  usage.jsonl or /api/logs recorded the real cause.
- Apply the provider's `upstreamHttpVersion` pin to the SSE fallback. The
  fallback is a routine path now, and serving a turn over HTTP while
  silently dropping the operator's protocol pin is wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(codex): pin the transport boundary at the adjacent byte

The sizing helper already had unit tests, but nothing proved the real
serialized frame routes correctly one byte on each side of the limit.
That gap matters because the request body is not the frame: `stream` is
deleted and `type` is added before sending, so padding sized against the
body sits eleven bytes away from what is actually transmitted. An
off-by-one would live exactly there and pass every existing test.

These two build padding so the serialized frame is exactly limit-1 and
exactly limit, then assert the whole path: one socket and one send of the
expected byte length under, zero sockets and one SSE call at it. Flipping
the gate from >= to > fails the second one, so it catches a real
off-by-one at the transport level rather than only in the helper.

Two comment corrections while here. The close-code comment claimed the
named 1009 message makes the failure diagnosable from the logs; it does
not. The eager relay turns any stream error into a generic
`upstream_reset` synthetic terminal without feeding it back through the
inspector, so `/api/logs` retains only `streamAborted`. The message
reaches the client and stops there, and the comment now says so rather
than promising observability the code does not deliver.

The margin comment described 64 KiB as absorbing a future append. There
is no append. It is a conservative cushion, and the useful thing to
record is what it actually covers: RFC 6455 framing is 14 bytes at this
payload size — an 8-byte extended length plus a 4-byte client mask — so
even a backend counting frame headers has ~65.5 KiB of room.

Tests: 59 pass, 1 skip across ws-upstream, sse-failed-tail, and
upstream-http-version. tsc --noEmit clean.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants