Skip to content

feat(oauth): keep parallel conversations cached instead of dropping them at a fixed limit - #236

Open
bman654 wants to merge 1 commit into
mainfrom
fix/ws-pool-emfile
Open

feat(oauth): keep parallel conversations cached instead of dropping them at a fixed limit#236
bman654 wants to merge 1 commit into
mainfrom
fix/ws-pool-emfile

Conversation

@bman654

@bman654 bman654 commented Sep 13, 2026

Copy link
Copy Markdown
Owner

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_CONNECTIONS still 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_CONNECTIONS were read only by the >= in evictOldestIdleGeneration; 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 one clodex claude or clodex server reaches them.

The change

  • Both defaults are Number.POSITIVE_INFINITY; evictOldestIdleGeneration fires only under a finite env or programmatic cap. ws_head_decision.maxConnections / maxNurseryConnections serialize as null when 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.
  • Descriptor exhaustion on a dial is handled as load shedding (shedIdleConnectionsForDescriptors, called from createConnection's existing socket error handler 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, while terminate() destroys the socket and Node closes the fd synchronously inside uv_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.
  • Detection is not just the error code. The shipped route is a hostname, and with a full descriptor table getaddrinfo fails first — Node reports ENOTFOUND with no cause. So on any pre-open socket error that is not EMFILE/ENFILE, the handler probes one descriptor (fs.openSync(os.devNull), closed at once) and treats a thrown EMFILE/ENFILE as exhaustion. A genuine ENOTFOUND with 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.
  • The user is told once per process on the parent-notice channel (stderr is muted under clodex claude): which limit, how many pooled connections were registered and shed, and the remedy — ulimit -n in the launching shell or the service limit for EMFILE; for ENFILE (the kernel file table) close other programs or raise the system-wide limit. Later occurrences go to the debug log and a ws_descriptor_exhaustion diagnostic (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, isolated CLODEX_HOME, dead ambient proxy, CLAUDE_CODE_ENTRYPOINT=cli, node 24.14.1, with CLODEX_WS_MAX_* unset.
  • Real ws under a hard ulimit -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 reports ENOTFOUND; 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 own accept() first (client sees ECONNRESET, not EMFILE), and a shell that exports CLODEX_WS_MAX_NURSERY_CONNECTIONS pins the old cap — which is the escape hatch working.
  • Mutations, full-file runs on 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 of terminate() (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 / ENOTFOUND blanket-classified (1), ENFILE remedy says ulimit (1), probe not gated to pre-open sockets (1).
  • Tests are staged through real fetch calls and response.output_item.done; the established-head test drives a real second turn (previous_response_id asserted, selectedGeneration: 'established') with an injected clock to pin oldest-first.
  • Reviewed before push by a 3-lens / 2-refuter / synthesis panel and a re-review of the fix-up delta; the hostname ENOTFOUND masking 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.devNull is \\.\nul; openSync on 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.

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle EMFILE so connection pools can grow to what the machine supports, instead of capping at an arbitrary number

1 participant