Make subprocess LSP enrichment lazy — off the cold/warm-start path#265
Merged
Conversation
The subprocess language servers (tsserver, rust-analyzer, pyright, jdtls, clangd, ...) are the slowest part of a cold/warm index — a full sweep runs for minutes to hours — and their net-new value over the in-process tiers is narrow: a Go module is served by go-types, and every language has the tree-sitter floor, which stamps real types (measured: rust-types 1,089 nodes on a crate where eager rust-analyzer produced 0 in 782s). So running them synchronously taxes every startup for little return. Gate the manager's router-backed LSP dispatch behind a new EagerLSP config flag (default false); go-types, SCIP, and the tstypes floor still run eagerly. The LSP router stays wired, so a query can lazy-spawn a server on demand — LSP moves off the synchronous path rather than being removed. GORTEX_LSP_EAGER=1 (or semantic.eager_lsp) restores the old behaviour. Measured on full PATH with the servers installed: cold-to-settled drops to 37s (rust crate) and 41s (python repo) with zero lsp-* subprocess passes, while the in-process floor keeps stamping types. Router-dispatch tests opt into EagerLSP.
Companion to the lazy-LSP change: with the synchronous LSP sweep gone, precise LSP types weren't in the graph at all — lazy had degenerated to disabled. Add the missing per-symbol path. lsp.Provider.EnrichNode(g, repoRoot, node) opens the symbol's file on the lazily-spawned server, issues ONE hover, and stamps the resulting semantic_type (EnrichFile was a no-op, so there was nothing to call before). get_symbol calls enrichNodeOnDemand for a node that lacks a type: it maps the node to its file, lazy-spawns the language server via the router (the same lspProviderForPath the diagnostics tools use), enriches, and persists — so the second read is free. Idempotent no-op when the node already has a type or no server serves the language. Verified end to end on a lazily-indexed TypeScript repo (0 eager LSP passes): get_symbol on an untyped function lazy-spawned tsserver on the query and stamped its type (source=lsp-typescript-language-server), cached in the graph. On a repo whose server can't type the project (pyright with no env), it spawns, hovers, and no-ops — at zero cold-index cost, versus the 774s the eager sweep spent for the same empty result.
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.
Make subprocess LSP enrichment lazy — off the cold/warm-start path
The subprocess language servers (tsserver, rust-analyzer, pyright, jdtls, clangd, …) are the slowest part of a cold index — a full sweep runs for minutes to hours — and profiling showed their net-new value over the in-process tiers
is narrow. A Go module is fully served by go-types; every language has the tree-sitter floor, which stamps real types. On a rust crate where eager rust-analyzer produced 0 nodes in 782s, the in-process rust-types floor stamped 1,089.
Gate the manager's router-backed LSP dispatch behind a new
EagerLSPflag (default false). go-types, SCIP, and the tstypes floor still run eagerly. The LSP router stays wired, so LSP moves off the synchronous path rather than being removed — a query can lazy-spawn a server on demand.GORTEX_LSP_EAGER=1/semantic.eager_lsprestores the old behaviour.Measured (full PATH, servers installed, default config)
lsp-*passesQuality trade (deliberate, minimal)
Removed from the eager graph: LSP-grade
semantic_typestamps and confirm-tier promotion for non-Go — which the data showed were frequently ~zero anyway (pyright enriched 3/774s; rust-analyzer 0/782s). The tree-sitter floor covers the eager baseline; precise LSP types become available on demand.Honest scope
This lands the cold-start half: LSP no longer runs synchronously. The router already supports lazy spawn (
ProviderForSpec), but wiring specific query triggers that fault in an LSP pass on first precise-type request is afollow-up — the capability is present, the consumers are not yet routed to it. For anyone who needs eager LSP today, the flag restores it losslessly.
Gate:
go test -race ./internal/semantic/... ./internal/serverstack/ ./internal/config/ ./internal/indexer/ ./internal/mcp/green (router-dispatch tests opt into EagerLSP).Update: the on-demand half is now wired (no longer "disabled")
lsp.Provider.EnrichNode+get_symbol→enrichNodeOnDemandcomplete the lazy loop: cold index skips LSP, and the first query for an untyped symbol lazy-spawns its language server and stamps the precise type, cached.Verified end-to-end (lazily indexed, 0 eager LSP passes):
get_symbolon an untyped TS function lazy-spawned tsserver on the query and stamped its type (source=lsp-typescript-language-server). On a project the server can't type (pyright, no env) it spawns/hovers/no-ops — at zero cold-index cost vs the 774s the eager sweep spent for the same empty result.