Skip to content

Make subprocess LSP enrichment lazy — off the cold/warm-start path#265

Merged
zzet merged 2 commits into
mainfrom
perf/lazy-lsp-enrichment
Jul 7, 2026
Merged

Make subprocess LSP enrichment lazy — off the cold/warm-start path#265
zzet merged 2 commits into
mainfrom
perf/lazy-lsp-enrichment

Conversation

@zzet

@zzet zzet commented Jul 7, 2026

Copy link
Copy Markdown
Owner

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 EagerLSP flag (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_lsp restores the old behaviour.

Measured (full PATH, servers installed, default config)

repo cold→settled lsp-* passes in-process types stamped
reader-rust (rust) 37s (was rust-analyzer 782s→0) 0 rust-types 1,089
Trellis (python) 41s 0 python-types 657, ts-types 979

Quality trade (deliberate, minimal)

Removed from the eager graph: LSP-grade semantic_type stamps 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 a
follow-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_symbolenrichNodeOnDemand complete 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_symbol on 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.

zzet added 2 commits July 7, 2026 21:05
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.
@zzet zzet merged commit 17b789c into main Jul 7, 2026
10 checks passed
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