Skip to content

Add lazy-loading and LRU cache for context-based lookups#62

Merged
JesseHerrick merged 7 commits into
mainfrom
headless-session-load-context
May 25, 2026
Merged

Add lazy-loading and LRU cache for context-based lookups#62
JesseHerrick merged 7 commits into
mainfrom
headless-session-load-context

Conversation

@JesseHerrick
Copy link
Copy Markdown
Member

@JesseHerrick JesseHerrick commented May 22, 2026

"Headless" LSP clients such as Claude Code don't actually open files when doing lookups or changing them. This means that some of our tricks for looking things up via the context of the files breaks.

This PR adds lazy disk-fallback to the document store so LSP clients that don't drive the full didOpen/didChange/didClose lifecycle (e.g. Claude Code) can still query definitions, hovers, references, and other text-dependent handlers. Use-injected lookups in particular: lookupThroughUse, lookupThroughUseOf. Alias merging also previously returned nil for any file the editor hadn't explicitly opened.

The goal of these changes is enablement work for a proper Claude Code plugin: #59.

What changed

  • DocumentStore.GetOrLoad(uri): lazy disk read for any file:// URI that isn't already in the store. Disk-loaded entries are marked transient: true and tracked in a Least Recently Used (LRU) cache. Non-file:// URIs (e.g. untitled:) return (false) without touching disk to avoid the uriToPath panic.
  • LRU eviction: transient entries are capped (default 50). Editor-owned buffers (Set via didOpen) are never counted, never reordered, never evicted. Set cleanly promotes a transient entry to editor-owned, dropping its LRU bookkeeping.
  • Cap is configurable: new maxTransientDocuments initialization option (default 50, accepts integer or JSON number, clamps negatives to 0). Documented in README.md.
  • Handler migration: 18 read-only handlers (Definition, Hover, References, Completion, CodeAction, Declaration, DocumentHighlight, DocumentSymbol, FoldingRanges, Implementation, PrepareRename, Rename, SignatureHelp, TypeDefinition, PrepareCallHierarchy, …) now call GetOrLoad. Formatting deliberately keeps Get — it only makes sense for in-memory editor buffers.

Correctness notes

  • File I/O happens outside the write lock; a re-check inside the lock returns the existing entry if a concurrent Set or GetOrLoad populated the URI first.
  • bumpLRU is a no-op if the URI was promoted to editor-owned between the RLock and Lock, so the fast-path race is safe.
  • evictTransientLocked closes the cached tree-sitter tree before deletion - no leak.
  • Race-clean under go test -race.

Note

Medium Risk
Touches core document lifecycle, concurrent tree-sitter memory safety, and many LSP code paths; behavior change is intentional but wide in scope.

Overview
Adds lazy disk loading for LSP documents so clients that never send didOpen (e.g. Claude Code) can still run definition, hover, references, completion, and related handlers. DocumentStore.GetOrLoad reads file:// URIs from disk, marks them transient, and keeps them in an LRU (default cap 50, tunable via maxTransientDocuments in initializationOptions). didOpen buffers stay editor-owned: not counted, not evicted; Set promotes a transient entry and drops LRU tracking.

GetTree now returns a release callback and uses refcounted tree-sitter trees so LRU eviction cannot free C memory while another handler is walking the AST (fixes a use-after-free). GetIfOpen distinguishes real editor buffers from transient cache for readFileText / getFileLine. Most read-only handlers switched from Get to GetOrLoad; formatting still uses Get only. README documents the new option; broad tests cover LRU, promotion, and concurrent eviction.

Reviewed by Cursor Bugbot for commit 7cf2266. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread internal/lsp/documents.go
Cross-URI LRU eviction in GetOrLoad could call ts_tree_delete on a
tree another LSP handler was actively walking (RootNode, queries),
causing a use-after-free on C-allocated memory that the Go race
detector cannot observe. GetTree now returns a release closure;
Set/Close/CloseAll/eviction mark the tree retired but defer the
free until the last holder releases.
Comment thread internal/lsp/documents.go
Comment thread internal/lsp/server.go Outdated
Comment thread internal/lsp/server.go
…-owned

Add HasOpen(uri) to DocumentStore that returns true only for
non-transient entries (i.e. those added via Set/didOpen).
Transient entries loaded from disk via GetOrLoad now correctly
return false from HasOpen.

Update readFileText and getFileLine to use HasOpen instead of Get
for the open-check, so rename operations branch correctly on the
open flag — transient files are written directly to disk rather
than sent as LSP text edits for buffers the editor never opened.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e978adf. Configure here.

Comment thread internal/lsp/server.go Outdated
…FileLine

HasOpen + Get were two separate RLock acquisitions. If Close removed
the document between them, Get returned ("", false) which was
discarded via _, leaving callers with empty text and open=true.

GetIfOpen returns both text and editor-owned status under a single
RLock, matching the atomicity of the original single Get call.
@JesseHerrick JesseHerrick merged commit bd830d2 into main May 25, 2026
5 checks passed
@JesseHerrick JesseHerrick deleted the headless-session-load-context branch May 25, 2026 17:16
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.

1 participant