perf(ws): open a dedicated socket instead of queueing concurrent turns - #250
Conversation
The Codex WebSocket transport pools one connection per `x-shunt-inbound-client:x-claude-code-session-id` key and takes its `turn_lock` with an unbounded `lock_owned().await`, so a second request on the same key parked until the first turn's terminal event. Turn serialization is required for continuation state (`previous_response_id` is valid only on the connection that produced it), but the behavior under contention should be "open another path", not "wait indefinitely". `begin` now takes the turn slot with `try_lock_owned`. A busy pooled socket sends the concurrent turn over a dedicated connection that is deliberately left out of the pool: `run_turn` only pools a connection carrying a `pool_key`, `evict` becomes a no-op for it, and the reader exits and closes the socket after its single turn. That turn carries no continuation — the parallelism-for-continuation trade — while the session's pooled socket keeps its continuation state and keeps serving subsequent turns. Pool invalidation is now identity-checked (`Arc::ptr_eq`) so a connection that lost the pool slot in a cold-start race cannot evict the healthy socket that replaced it on its way out. Closes #248
There was a problem hiding this comment.
Code Review
This pull request introduces support for concurrent turns on Codex WebSocket connections (issue #248). Instead of serializing concurrent requests on a single pooled connection and making them wait, a busy turn slot now triggers the creation of a dedicated, unpooled, one-shot connection for the concurrent turn, trading continuation reuse for parallelism. Additionally, it implements identity-checked invalidation (invalidate_pool_entry) to ensure that a stale or exiting connection only evicts itself from the pool and does not accidentally evict a newer, healthy replacement connection. Comprehensive unit tests have been added to verify these concurrent turn and race-condition behaviors. There are no review comments to assess, and I have no additional feedback to provide.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThe PR replaces queued concurrent Codex WebSocket turns with bounded, dedicated overflow connections.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| src/adapters/responses/codex_ws.rs | Adds bounded one-shot connections for pooled-socket contention, identity-safe pool invalidation, and comprehensive lifecycle and concurrency tests. |
| docs/m7-codex-websocket.md | Documents non-blocking pooled reuse, dedicated overflow behavior, the 64-connection ceiling, and HTTP fallback. |
Sequence Diagram
sequenceDiagram
participant Request
participant Pool
participant PooledWS as Pooled WebSocket
participant Admission as Overflow Admission
participant OverflowWS as Dedicated WebSocket
participant HTTP
Request->>Pool: begin(session key)
alt pooled socket is idle
Pool->>PooledWS: claim turn slot and probe
PooledWS-->>Request: stream turn with continuation
else pooled socket is busy
Pool->>Admission: claim overflow slot
alt slot available
Admission->>OverflowWS: open one-shot connection
OverflowWS-->>Request: stream turn without continuation
OverflowWS->>Admission: close and release slot
else ceiling reached
Admission-->>Request: pre-stream transport failure
Request->>HTTP: transparently retry over HTTP
end
end
Reviews (2): Last reviewed commit: "perf(ws): bound overflow connections wit..." | Re-trigger Greptile
Cap simultaneously live dedicated overflow sockets at 64 so a same-session burst cannot translate without bound into reader tasks and upstream handshakes. Refuse excess overflow before any response.create frame is sent. The existing BeforeHeaders classification therefore retries the turn over HTTP without reintroducing WebSocket turn queueing. Addresses the Greptile review thread on PR #250.
|



Summary
Closes #248.
With the opt-in Codex WebSocket transport (
websocket = true, default off), concurrent requests sharing one WS pool key ran strictly one turn at a time:codex_ws::begintook the pooled connection'sturn_lockwith an unboundedlock_owned().await, so the second request parked until the first turn's terminal event.Serializing turns on one socket is required —
previous_response_idis only valid on the connection that produced it — but the behavior under contention should be "open another path", not "wait indefinitely". This implements the issue's option 1.beginnow takes the turn slot withtry_lock_owned. An idle pooled socket is probed and reused exactly as before; a busy one sends the concurrent turn over a dedicated connection.pool_key = None), which the existing machinery turns into three guarantees:run_turnonly callspool_insertfor a connection carrying apool_key, so it can never replace the session's healthy entry;evict()is a no-op for it, so its death can't evict that entry; andrun_connectionexits and closes the socket after its single turn, so neither the task nor the connection leaks.reused == false) — the parallelism-for-continuation trade — while the pooled socket keeps its continuation state and keeps serving subsequent turns.MAX_OVERFLOW_CONNECTIONS = 64dedicated sockets may be live at once (RAII slot owned by theConnection, released when it drops after its single turn). At the ceilingbeginrefuses before any frame is sent, so the turn rides the existingAdapterFailure::BeforeHeaderspath and spills over to HTTP rather than waiting — added in response to Greptile review.Arc::ptr_eq). Previously a connection that lost the pool slot in a cold-start race would evict the winner's healthy entry when its reader exited.No config key, endpoint, CLI, or provider/model semantics change; the transport itself is already opt-in.
Milestone / spec
M7 —
docs/m7-codex-websocket.md§5 "Connection pool" (reuse gate + new "Concurrent turns" bullet).Checklist
cargo buildpassescargo test --all-features --workspacepasses (959 unit + all integration groups green; new behavior covered)cargo clippy --all-targets --all-features -- -D warningscleancargo fmt --all --checkcleancodex_ws.rswas already over (2.2k, transport + its test module); this PR does not add a new oversized filedocs/updated (docs/m7-codex-websocket.md§5)README.mddocuments no turn-serialization behavior;site/documents only thewebsocketflag itself, and no config key/default/endpoint/CLI/provider semantics changed — no edit needed.wiki/untouched (generated).Notes for reviewers
concurrent_turn_opens_a_dedicated_connection_instead_of_waitingdrives real loopback sockets: turn 1 pools connection 1, turn 2 reuses it and is held open mid-stream by the mock, then a thirdbeginon the same key must return inside a 5s timeout. Revertingtry_lock_ownedto the old blockinglock_owned().awaitfails it at exactly the timeout (Elapsed(())); with the fix it passes. It further asserts the overflow socket is a second accept, closes after its single turn, and that turn 4 still lands on connection 1 (3 frames on connection 1, 1 on connection 2, 2 accepts total).replaced_connection_cannot_evict_current_pool_entry.pool_insertkeeps its last-writer-wins semantics — deliberately unchanged.pub fn invalidate_pool_keyhad no callers left anywhere insrc/,tests/, orbenches/after the conversion toinvalidate_pool_entry.shunt.codex_continuation) should be added to answer the issue's open question about how often concurrent agents actually share onex-claude-code-session-id. Left out here to keep the diff to the fix; happy to add as a follow-up.Summary by cubic
Open a dedicated WebSocket for concurrent turns instead of queueing on the pooled session socket, with a safety cap that falls back to HTTP under heavy bursts. This removes stalls under contention; no API or config changes (transport stays opt-in via
websocket = true).New Features
Bug Fixes
Written for commit f219f65. Summary will update on new commits.