feat(oauth): keep parallel conversations cached instead of dropping them at a fixed limit - #236
Open
bman654 wants to merge 1 commit into
Open
feat(oauth): keep parallel conversations cached instead of dropping them at a fixed limit#236bman654 wants to merge 1 commit into
bman654 wants to merge 1 commit into
Conversation
…hem at a fixed limit The pools that hold a reusable ChatGPT connection per live conversation were capped at 48 nursery and 64 established heads, and a cap that bound silently discarded a conversation whose next turn then resent its whole history uncached. Every number was a guess per machine and per workload; head reuse in a 27.6-hour ledger was identical at every cap from 8 to unlimited. The default is now unbounded; the idle TTLs are the retention policy. Running out of file descriptors on a connection open is detected, from the socket's EMFILE/ENFILE code or, because a hostname dial fails inside getaddrinfo first and surfaces as ENOTFOUND, from a one-descriptor probe on any other pre-open failure, and handled as load shedding: every idle pooled head is terminated (not closed, so the descriptor is freed synchronously), the request takes the existing one-shot transport retry against the freed descriptors, and the user is told once per process, on the parent-notice channel, which limit was hit, how many pooled connections were registered, and the remedy. Busy heads, open sockets and isolated sockets are never shed; when nothing is idle the request fails once with the same actionable message. CLODEX_WS_MAX_CONNECTIONS and CLODEX_WS_MAX_NURSERY_CONNECTIONS remain as an optional cap on the idle pool and now accept any positive integer. Closes #222
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes for users
When many Claude Code agents run at once on a ChatGPT/Codex-plan login, clodex keeps one open connection per live conversation so each follow-up turn is served from OpenAI's prompt cache. That pool had a fixed size (48 new-conversation slots, 64 established), and when a busy session filled it clodex silently dropped a conversation whose next turn then resent its entire history uncached — slower, and a waste of cache budget. There is no fixed size any more: clodex keeps every conversation's connection until it goes idle, and if the machine actually runs out of file descriptors it closes idle connections to make room and tells you once how to raise the limit. If you want a hard cap anyway,
CLODEX_WS_MAX_CONNECTIONS/CLODEX_WS_MAX_NURSERY_CONNECTIONSstill set one (any positive integer now; the old 1024 ceiling is gone).Closes #222.
Problem and root cause
RESPONSES_WS_MAX_CONNECTIONS/RESPONSES_WS_MAX_NURSERY_CONNECTIONSwere read only by the>=inevictOldestIdleGeneration; nothing preallocates. Over a 27.6-hour local ledger the established pool peaked at 28 against a cap of 32 in organic traffic, the ten real cap evictions displaced heads idle 217–284 s (approaching the 5-minute nursery TTL), and head reuse was identical at every cap from 8 to unlimited. A cap that binds costs a full uncached resend; one that never binds costs nothing. The pools are process-wide, so any fan-out of concurrent subagent conversations in oneclodex claudeorclodex serverreaches them.The change
Number.POSITIVE_INFINITY;evictOldestIdleGenerationfires only under a finite env or programmatic cap.ws_head_decision.maxConnections/maxNurseryConnectionsserialize asnullwhen unbounded (the one known ledger consumer,analyze-cache.sh, tolerates it). The idle TTLs (5 min nursery, 30 min established) and hard TTL are the retention policy, unchanged.shedIdleConnectionsForDescriptors, called fromcreateConnection's existing socketerrorhandler before the ordinary one-shot transport retry). Every idle pooled head is terminated — not closed:close()holds the descriptor through the close handshake or ws's 30 s timer, whileterminate()destroys the socket and Node closes the fd synchronously insideuv_close— oldest first. Busy heads, open sockets and isolated sockets are untouched. The retry then dials against the freed descriptors; if nothing was idle it fails once with an actionable message. Bounded by the single retry, never a loop.getaddrinfofails first — Node reportsENOTFOUNDwith no cause. So on any pre-open socket error that is notEMFILE/ENFILE, the handler probes one descriptor (fs.openSync(os.devNull), closed at once) and treats a thrownEMFILE/ENFILEas exhaustion. A genuineENOTFOUNDwith descriptors available (1,338 in the local ledgers) stays on its normal path. Errors on an already-open socket never probe: that socket holds its own descriptor, and after output there is no retry a shed could help.clodex claude): which limit, how many pooled connections were registered and shed, and the remedy —ulimit -nin the launching shell or the service limit forEMFILE; forENFILE(the kernel file table) close other programs or raise the system-wide limit. Later occurrences go to the debug log and aws_descriptor_exhaustiondiagnostic (code,detectedBy: error_code | descriptor_probe,socketErrorCode,heldConnections,shedConnections).Left out: no heap-pressure signal. The pacer (60 dials/min) times the TTLs bounds idle occupancy at roughly 300 nursery / 1,800 established heads at ~0.73 MiB each, against an observed organic peak of 28; if a real workload gets near that, memory deserves its own signal rather than a guessed cap. Also worth knowing: Node raises
RLIMIT_NOFILE's soft limit to the hard limit at startup (a stock macOS shell's 256 is soft; Node saw 245,749), so exhaustion is reachable only for a service or container with a clamped hard limit — this is a backstop, not the ordinary bound.Evidence
pnpm typecheck && pnpm test && pnpm build: 118 files / 2664 tests, isolatedCLODEX_HOME, dead ambient proxy,CLAUDE_CODE_ENTRYPOINT=cli, node 24.14.1, withCLODEX_WS_MAX_*unset.wsunder a hardulimit -n 40, server in a separate normal-limit process, 60 fresh conversations, env caps unset. Numeric-address route (ws://127.0.0.1): EMFILE at 28 registered, 28 shed, retry recovered in the same tick, 60/60 turns ok, one notice, second exhaustion logged but not re-announced. Hostname route (ws://localhost, the shape production uses): the socket reportsENOTFOUND; the probe classifies it (detectedBy: descriptor_probe,socketErrorCode: ENOTFOUND), 26 shed, 60/60 ok. Before the probe existed the hostname run was 26 ok / 34 failed with zero sheds. Two traps hit while building this: an in-process server exhausts its ownaccept()first (client seesECONNRESET, notEMFILE), and a shell that exportsCLODEX_WS_MAX_NURSERY_CONNECTIONSpins the old cap — which is the escape hatch working.tests/responses-websocket.test.ts(172 tests), each red on exactly the named tests: skip the exhaustion branch (3), caps back to 48/64 (2),close()instead ofterminate()(2), shed busy heads too (1), shed nursery only (1), newest-first (1), notice every time (2), raw error message kept (1), no descriptor probe (1), probe trusted blindly /ENOTFOUNDblanket-classified (1), ENFILE remedy saysulimit(1), probe not gated to pre-open sockets (1).response.output_item.done; the established-head test drives a real second turn (previous_response_idasserted,selectedGeneration: 'established') with an injected clock to pin oldest-first.ENOTFOUNDmasking and the missing established-head test were its findings.Not verified: a real ChatGPT session at a clamped hard limit; the probe's behaviour on Windows (
os.devNullis\\.\nul;openSyncon it is supported, but not exercised here).Failure and rollback behavior
If detection misfires in the non-exhaustion direction (probe succeeds while the dial was starved), behaviour is the pre-change one: the request fails with the socket's error after the ordinary retry. If it fires under real exhaustion with nothing idle, the request fails once with the actionable message. Nothing persisted changes; reverting the commit restores the 48/64 caps and the 1024 env ceiling.