fix: bound cursor conversation caches and per-stream buffers - #1228
Merged
Conversation
code-yeongyu
force-pushed
the
fix/mem-cursor-blob-eviction
branch
2 times, most recently
from
August 31, 2026 07:35
9af8be4 to
ec2e7b4
Compare
code-yeongyu
force-pushed
the
fix/mem-cursor-blob-eviction
branch
from
August 31, 2026 07:55
ec2e7b4 to
5a117f9
Compare
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.
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.tsconversationStateCache/conversationBlobStoresare now session-scoped: cache keys are tracked againstoptions.sessionId(threaded by the SDK for every coding-agent stream) and aregisterSessionResourceCleanuphandler drops that session's entries oncleanupSessionResources(sessionId)— the same seam codex already uses.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).getBlobArgs), and a miss is answered with an unsetblobData— 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).conversationId -> sessionIdmap) 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 withoutsessionId) share one bucket.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 buffers —
ttsr/manager.ts,ttsr/index.tscheckDeltabuffers now keep a tail window sized atmax(1024, 4 × longest rule pattern)instead of the whole turn's stream (which also removes the O(n²) concat churn), and buffers clear onmessage_endinstead of waiting for the nextturn_start.3. Small hygiene
anthropic-messages.ts: the unsigned-thinking text-replay fallback Set is cleared for the session oncleanupSessionResources.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-idgenerationsmap (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 survivescleanupSessionResourcestoday); 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 threegoal-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 commit2bc7ca085):getBlobArgswith 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).c85e3062d:packages/aicursor scope 21 files / 145 tests passed, and the wholepackages/aisuite 257 files / 2486 tests passed (25 files / 870 tests skipped as usual).Residual
enforceConversationTotalBlobLimit()now caps the blob bytes of all cached conversations atPI_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 fromcontext.messageson the next turn, so a shed cold conversation costs re-serialization, not context.openai-codex-responses.tshas 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.
message_end.claude-sdk-oauthsession registry uses a monotonic generation counter instead of an unbounded per-id map.Written for commit 5a117f9. Summary will update on new commits.