Skip to content

fix: bound cursor conversation caches and per-stream buffers - #1228

Merged
code-yeongyu merged 7 commits into
mainfrom
fix/mem-cursor-blob-eviction
Aug 31, 2026
Merged

fix: bound cursor conversation caches and per-stream buffers#1228
code-yeongyu merged 7 commits into
mainfrom
fix/mem-cursor-blob-eviction

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Fixes #1024

What

Three memory-hygiene fixes in one pass (2.1 / 2.3 / 2.4 of the memory-audit fix shortlist):

1. Cursor per-conversation caches (#1024)packages/ai/src/api/cursor-agent.ts, cursor-conversation-rotation.ts

  • The module-lifetime conversationStateCache / conversationBlobStores are now session-scoped: cache keys are tracked against options.sessionId (threaded by the SDK for every coding-agent stream) and a registerSessionResourceCleanup handler drops that session's entries on cleanupSessionResources(sessionId) — the same seam codex already uses.
  • Wire-id rotation now deletes the pre-rotation key after migrating state/blobs to the new id (the old key is never read again; keeping it was pure leak).
  • Defensive bounds: count cap on cached conversations (PI_CURSOR_CONVERSATION_CACHE_LIMIT, default 64), an LRU byte cap per conversation blob store (PI_CURSOR_CONVERSATION_BLOB_LIMIT_BYTES, default 256 MiB) and a process-global blob ceiling across all stores (PI_CURSOR_CONVERSATION_TOTAL_BLOB_LIMIT_BYTES, default 1 GiB).
  • Eviction can never break a live request (review follow-up):
    • Blob pinning + true LRU. Cursor resolves history blob ids mid-turn over the KV channel (getBlobArgs), and a miss is answered with an unset blobData — silent context loss or a failed turn. Every blob the in-flight request stores (buildGrpcRequest) or the server reads back is now pinned for the lifetime of that request's stream, and the byte cap evicts unpinned blobs only. .get() promotes recency, so a blob the server just asked for is no longer sitting at the eviction end. If the pinned working set alone exceeds the cap, the store stays temporarily over budget and logs a warning once, rather than breaking the request; the pins drop and the cap re-applies when the stream settles (including between retry attempts).
    • Per-session count cap. The count cap now runs against the keys owned by the requesting session (via the existing conversationId -> sessionId map) instead of the process-global maps, so session B's 65th conversation can no longer forget session A's live conversation key (A's retry/resume would have re-entered through the same global map and fallen back to fresh empty state). Within a session, a conversation with a request in flight is never an eviction candidate; keys with no owning session (raw SDK use without sessionId) share one bucket.
  • Rotation records (the third map named in cursor: unbounded per-conversation maps leak in long-lived processes #1024) are count-bounded (PI_CURSOR_ROTATION_RECORD_LIMIT, default 512, trimmed from memory and the persisted file, oldest first).
  • getCursorConversationCacheStats() exposes sizes for verification/diagnostics.

2. TTSR stream buffersttsr/manager.ts, ttsr/index.ts

  • checkDelta buffers now keep a tail window sized at max(1024, 4 × longest rule pattern) instead of the whole turn's stream (which also removes the O(n²) concat churn), and buffers clear on message_end instead of waiting for the next turn_start.

3. Small hygiene

  • anthropic-messages.ts: the unsigned-thinking text-replay fallback Set is cleared for the session on cleanupSessionResources.
  • openai-responses.ts: the session-websocket idle expiry re-arms when it fires while the socket is busy, and evicts a busy entry whose socket already died, so a lost release can no longer pin a cached websocket for process lifetime.
  • claude-sdk-oauth/session-registry.ts: the per-id generations map (set-only, never deleted) is replaced by a monotonic counter — same never-reuse guarantee for stale-async gating, zero residue.

Tests (TDD; remote runner, never local)

RED first (commit 5e3e731bd, tests only): ai 4 files / 9 tests failed; coding-agent 1 file / 2 tests failed. The anthropic cleanup case is a behavioral RED (the Set survives cleanupSessionResources today); the new-seam cases fail on the missing exports by construction.

GREEN after the fixes:

  • packages/ai (7 files, 55 tests): cursor-conversation-cache-eviction, cursor-conversation-rotation, cursor-conversation-rotation-stream, anthropic-unsigned-thinking-replay, openai-responses-websocket-expiry, openai-responses-websocket, cursor-agent.
  • packages/coding-agent (6 files, 77 tests): ttsr/manager, claude-sdk-oauth-session-registry, ttsr-extension, all three goal-ttsr-*-race.

New coverage pins: session-scoped eviction leaves only the surviving session's entries; rotation deletes the old key; count cap evicts oldest-first; blob byte cap holds under a 200 KB payload; rotation records trim from memory and disk; busy-then-idle TTL re-arm; dead-socket eviction while busy; tail-window match survival; generation never reused across close/reopen.

Second RED/GREEN cycle for the two eviction blockers (cursor-conversation-blob-pinning.test.ts, tests-only commit 2bc7ca085):

  • RED (3 failed / 1 passed): live-reference eviction answered 23 of the request's getBlobArgs with an empty blob (expected [ '5', '6', '8', ... ] to equal []); expected [ 'b-3', 'b-4' ] to include 'a-1' (session B's overflow evicted session A); expected [ 'fill-2', 'fill-3' ] to include 'live-1' (a session's own overflow evicted its in-flight conversation).
  • GREEN after c85e3062d: packages/ai cursor scope 21 files / 145 tests passed, and the whole packages/ai suite 257 files / 2486 tests passed (25 files / 870 tests skipped as usual).
  • New pins: every referenced blob resolves during a stream whose history exceeds the byte cap; pins release when the stream settles so the cap applies again; the process-global ceiling holds across four cached conversations; the count cap now evicts oldest-first within one session.

Residual

  • Process-global ceiling decision (reviewer's 64 × 256 MiB ≈ 16 GiB point): added, not documented away. Making the count cap per session removes the cross-session eviction bug but multiplies the theoretical ceiling by the session count, so a per-conversation cap alone cannot bound the process. enforceConversationTotalBlobLimit() now caps the blob bytes of all cached conversations at PI_CURSOR_CONVERSATION_TOTAL_BLOB_LIMIT_BYTES (default 1 GiB), applied after each request build and after each stream settles. It sheds cold conversations before live ones, never drops a pinned blob, and forgets a conversation whose store empties; if the remainder is entirely pinned by in-flight requests it warns instead of breaking them. Cursor re-stores the history it needs from context.messages on the next turn, so a shed cold conversation costs re-serialization, not context.
  • Bounds are still ceilings, not elimination; in the coding-agent the session-cleanup path is the real reclaim for disposed sessions.
  • openai-codex-responses.ts has the same busy-early-return expiry shape; it is out of this PR's scope and untouched.

Summary by cubic

Fixes #1024 by bounding cursor conversation caches and per-stream buffers so long-lived sessions no longer leak memory.

  • Cursor conversation state, blob stores, and rotation records get count caps; blob stores also get byte caps enforced per owning session with a process-global ceiling, in-flight requests pin their blobs and live conversations so eviction never breaks a stream, and rotations delete the pre-rotation key.
  • Anthropic unsigned-thinking replay fallbacks clear on session cleanup, and OpenAI session websockets re-arm idle expiry when busy and drop dead sockets.
  • TTSR stream buffers keep only a tail window (at least 1024 chars or 4× the longest rule pattern) and clear on message_end.
  • The claude-sdk-oauth session registry uses a monotonic generation counter instead of an unbounded per-id map.

Written for commit 5a117f9. Summary will update on new commits.

Review in cubic

@code-yeongyu
code-yeongyu force-pushed the fix/mem-cursor-blob-eviction branch 2 times, most recently from 9af8be4 to ec2e7b4 Compare August 31, 2026 07:35
@code-yeongyu
code-yeongyu force-pushed the fix/mem-cursor-blob-eviction branch from ec2e7b4 to 5a117f9 Compare August 31, 2026 07:55
@code-yeongyu
code-yeongyu merged commit 721b9a5 into main Aug 31, 2026
17 checks passed
@code-yeongyu
code-yeongyu deleted the fix/mem-cursor-blob-eviction branch August 31, 2026 08:04
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.

cursor: unbounded per-conversation maps leak in long-lived processes

1 participant