Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0fa4524
lsp: allow GORTEX_LSP_MAX_PARALLEL to override the spec cap
pbednarcik Aug 19, 2026
3a5dcdf
lsp: thread the max-parallel cap through config
pbednarcik Aug 19, 2026
4af127e
docs: document the config-first max-parallel knob
pbednarcik Aug 19, 2026
d5f4a22
lsp: skip the didOpen lifecycle for servers that serve unopened files
pbednarcik Aug 19, 2026
d19bd92
lsp: config knob for the didOpen lifecycle (semantic.lsp_open_docs)
pbednarcik Aug 19, 2026
b76457d
lsp: wait for the paired didClose in the lifecycle control test
pbednarcik Aug 19, 2026
f50c893
lsp: confirm edges through definition for servers that leak on FindRe…
pbednarcik Aug 19, 2026
0fb81c4
lsp: env override for the heavy-request opt-out (GORTEX_LSP_HEAVY)
pbednarcik Aug 19, 2026
28cea36
Merge branch 'feat/lsp-max-parallel-env' into feat/lsp-defconfirm
pbednarcik Aug 19, 2026
0db62d9
lsp: fan the definition pass out across call-site files
pbednarcik Aug 19, 2026
ee213f8
lsp: break enrich wall time out per phase in the completion log
pbednarcik Aug 20, 2026
c6313b6
lsp: feed the targeted breaker from the definition pass
pbednarcik Aug 20, 2026
6d6e74b
lsp: review round — yield, on-demand gate, fallback recall, docs
pbednarcik Aug 20, 2026
a3b2493
Merge origin/main into feat/lsp-defconfirm
pbednarcik Aug 20, 2026
44d1e65
lsp: pin the declared-member arm on the heavy default path
pbednarcik Aug 20, 2026
3c3ef0e
docs: cover the heavy-request opt-out and GORTEX_LSP_HEAVY
pbednarcik Aug 20, 2026
fd681fe
lsp: fix a comment describing the definition pass as serial
pbednarcik Aug 20, 2026
9b185c2
lsp: review polish — group only on the sweep path, rename shared norm…
pbednarcik Aug 20, 2026
9f8e9e0
lsp: count content-cache evictions under the lifecycle opt-out too
pbednarcik Aug 20, 2026
5dccca6
chore: retrigger CI (lint runner failed downloading its config schema)
pbednarcik Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion docs/lsp.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,13 @@ ambiguous. A pass runs up to five phases:
An answer that agrees with the heuristic target counts as
`edges_confirmed`; an answer naming a different same-name declaration
rewrites the edge (tagged `rebound_from`) and counts as `edges_rebound` —
a correction of the heuristic graph, not a confirmation of it.
a correction of the heuristic graph, not a confirmation of it. One
exception: an answer naming the *declared* dispatch member (interface /
abstract base) while the stored target is one of its concrete
implementations does not rewrite anything — the stored edge is a
devirtualization guess definition cannot vouch for, so the compiler-proven
edge to the declared member is added (`edges_added`) and the guess keeps
its heuristic tier.
4. **References-add pass** — only for servers that expose references but not a
call hierarchy; recovers the caller edges a declaration's references imply.
5. **Per-file sweep** — the whole-repo hover / hierarchy phase. Per function or
Expand Down Expand Up @@ -239,6 +245,65 @@ This matters most for `clangd` without a compilation database: every `didOpen`
triggers a full fallback-preamble + AST rebuild, so reopening the same file
across phases multiplies that cost.

### Servers that skip the lifecycle entirely

A server whose spec sets `NoDidOpen` answers position requests (hover,
references, call hierarchy) for files it loaded from its own workspace,
without any `didOpen` — and for one server family that is worth far more
than the saved notification. csharp-ls schedules read-only requests
concurrently but treats every `didOpen` / `didClose` as an exclusive write:
it waits for all in-flight reads to retire and blocks every read queued
behind it. A pass that interleaves opens with its queries therefore
serializes to single-request throughput no matter how many requests it
keeps in flight — measured on a real C# monorepo as ~8 req/s with the
lifecycle against ~1,000 req/s without it. With `NoDidOpen` the document
session degrades to a pure content cache (file bytes still read from disk
once per file) and the pass sends zero document notifications. The
`GORTEX_LSP_OPEN_DOCS` env var overrides in both directions: `1` forces
the lifecycle back on, `0` skips it for every server.

### Servers that skip the heavy request classes

A server whose spec sets `NoHeavyRequests` never receives the two request
classes that ride its find-references machinery: `textDocument/references`
and `callHierarchy/incomingCalls`. For the one spec that sets it today — C#
(`omnisharp`, including its `csharp-ls` fallback command) — the reason is a
memory leak, not throughput: released csharp-ls builds (≤ 0.26) hold ~10MB
plus a set of OS handles per references round trip and ~0.7MB per
incomingCalls, released only at process exit. A full enrichment pass over a
large repo pushes tens of GB through that leak, and a long-lived daemon
answering usage queries accumulates it one request at a time.

What changes under the opt-out:

- **Edge confirmation moves to definition.** The references confirm pass
(phase 2) is skipped and the definition pass (phase 3) becomes the primary
confirm source, covering every sited ambiguous edge — the same verdicts at
position-request cost. Edge tiers and recall are unaffected.
- **`callHierarchy/incomingCalls` is never sent.** Dispatch fan-out stays
with the graph-side interface-dispatch synthesizer; the outgoing side of
the call hierarchy still runs.
- **The references-add pass (phase 4) is skipped** — it exists for servers
without a call hierarchy and is references-driven by definition.
- **On-demand confirmation is disabled.** The query-time path that upgrades
`find_usages` / `get_callers` answers with a live LSP round trip returns
immediately for these servers, so C# usage queries answer from the stored
graph tiers alone.

The `GORTEX_LSP_HEAVY` env var overrides the spec in both directions, with
the same value vocabulary as `GORTEX_LSP_OPEN_DOCS`: `on` / `1` / `true`
restores references and incomingCalls for an opted-out server, `off` / `0`
/ `false` disables them for every server, and an empty or unrecognised
value falls through to the spec. The knob is deliberately env-only: it
exists to match a specific server *build*, not a workspace, and a durable
config key would outlive the build it was set for.

> **Warning:** set `GORTEX_LSP_HEAVY=on` for C# only on a csharp-ls build
> that carries the FindReferences leak fix
> ([razzmatazz/csharp-language-server#410](https://github.com/razzmatazz/csharp-language-server/pull/410)).
> On any released build up to 0.26, a full heavy pass over a large repo can
> push ~30GB through the leak and OOM the server mid-pass.

### Sweep modes

The per-file sweep (phase 5) is gated by a **sweep mode**:
Expand Down Expand Up @@ -471,3 +536,10 @@ for repositories you trust.
- One `*lsp.Provider` per spec, regardless of how many MCP sessions
hit it. Concurrency is bounded by `ServerSpec.MaxParallel` (6-10
inflight requests per server depending on the spec).
`semantic.lsp_max_parallel` in config overrides the spec's cap for
every router-spawned server — the durable knob for a machine whose
servers multiplex better than the conservative default assumes. The
`GORTEX_LSP_MAX_PARALLEL` env override wins over both for one-run
experiments (and also reaches resolver-pool providers, which take env +
spec only; legacy config-declared providers keep their explicit
setting). Non-positive or unparseable values fall through.
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,21 @@ type SemanticConfig struct {
// passes still run.
// The GORTEX_LSP_SWEEP env override wins over this setting.
LSPSweep string `mapstructure:"lsp_sweep" yaml:"lsp_sweep,omitempty"`
// LSPOpenDocs overrides whether the LSP enrichment pass sends the
// textDocument/didOpen / didClose document lifecycle:
// - "" (DEFAULT): each server spec decides — a Roslyn server that
// answers position requests for never-opened files skips the
// lifecycle (its scheduler treats every didOpen as an exclusive
// write that serializes the pass), everything else keeps it.
// - "on": force the lifecycle for every server (kill switch).
// - "off": skip it for every server (experiment switch).
// The GORTEX_LSP_OPEN_DOCS env override wins over this setting.
LSPOpenDocs string `mapstructure:"lsp_open_docs" yaml:"lsp_open_docs,omitempty"`
// LSPMaxParallel caps concurrent LSP requests per spawned server,
// overriding each spec's registry default (6-10) when positive. A
// machine whose servers multiplex well can raise it without editing
// source. The GORTEX_LSP_MAX_PARALLEL env override wins over this.
LSPMaxParallel int `mapstructure:"lsp_max_parallel" yaml:"lsp_max_parallel,omitempty"`
// EagerLSP runs the subprocess LSP servers during synchronous enrichment.
// Default false: LSP is the slowest part of a cold index and the in-process
// tiers (go-types, tree-sitter floor) cover the fast baseline, so LSP is
Expand Down
9 changes: 9 additions & 0 deletions internal/semantic/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ type Config struct {
// each spawned LSP provider via the router's WithEnrichSweepMode. The
// GORTEX_LSP_SWEEP env override wins over it at enrichment time.
LSPSweep string `mapstructure:"lsp_sweep" yaml:"lsp_sweep,omitempty"`
// LSPOpenDocs mirrors config.SemanticConfig.LSPOpenDocs — the
// didOpen-lifecycle override ("" spec-decides / "on" / "off"). Threaded
// to each spawned LSP provider via the router's WithEnrichOpenDocs. The
// GORTEX_LSP_OPEN_DOCS env override wins over it at enrichment time.
LSPOpenDocs string `mapstructure:"lsp_open_docs" yaml:"lsp_open_docs,omitempty"`
// LSPMaxParallel mirrors config.SemanticConfig.LSPMaxParallel — the
// concurrent-request cap for spawned LSP servers. Zero keeps each
// spec's own default; GORTEX_LSP_MAX_PARALLEL wins over both.
LSPMaxParallel int `mapstructure:"lsp_max_parallel" yaml:"lsp_max_parallel,omitempty"`
// EagerLSP runs the subprocess LSP servers during the synchronous
// enrichment pass. Default false: LSP is the slowest part of a cold index
// (a full gopls/tsserver/rust-analyzer/pyright sweep can run for minutes to
Expand Down
44 changes: 31 additions & 13 deletions internal/semantic/lsp/doc_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import (
type docSession struct {
p *Provider
cap int // simultaneously-open ceiling per client
// sendOpens gates the server-side lifecycle. When false (the provider's
// opensDocs resolved off — see ServerSpec.NoDidOpen) the session keeps
// its entry / LRU machinery purely as a bounded content cache: acquire
// still reads and caches file bytes, but no didOpen / didClose is ever
// sent, and the open-lifecycle telemetry (didOpens, curOpen, peakOpen)
// honestly reports zero. Evictions still count — they track the cache
// churn either way.
sendOpens bool

mu sync.Mutex
perClient map[*Client]*clientDocs
Expand Down Expand Up @@ -65,6 +73,7 @@ func newDocSession(p *Provider) *docSession {
return &docSession{
p: p,
cap: cp,
sendOpens: p.opensDocs,
perClient: map[*Client]*clientDocs{},
openCounts: map[string]int{},
}
Expand Down Expand Up @@ -115,21 +124,28 @@ func (s *docSession) acquire(c *Client, absPath string) ([]byte, func(), error)
evPath := front.Value.(string)
cd.lru.Remove(front)
delete(cd.open, evPath)
_ = s.p.enrichCloseDoc(c, evPath)
s.curOpen--
// Evictions count the cache churn, not the didClose traffic — a
// lifecycle-off session evicts entries all the same and hiding that
// would blind the content-cache telemetry.
s.evictions++
if s.sendOpens {
_ = s.p.enrichCloseDoc(c, evPath)
s.curOpen--
}
}

if err := s.p.enrichOpenDoc(c, absPath, content); err != nil {
return nil, func() {}, err
if s.sendOpens {
if err := s.p.enrichOpenDoc(c, absPath, content); err != nil {
return nil, func() {}, err
}
s.didOpens++
s.openCounts[absPath]++
s.curOpen++
if s.curOpen > s.peakOpen {
s.peakOpen = s.curOpen
}
}
cd.open[absPath] = &docEntry{refs: 1, content: content}
s.didOpens++
s.openCounts[absPath]++
s.curOpen++
if s.curOpen > s.peakOpen {
s.peakOpen = s.curOpen
}
return content, s.releaseFunc(c, absPath), nil
}

Expand Down Expand Up @@ -162,9 +178,11 @@ func (s *docSession) closeAll() {
s.mu.Lock()
defer s.mu.Unlock()
for c, cd := range s.perClient {
for path := range cd.open {
_ = s.p.enrichCloseDoc(c, path)
s.curOpen--
if s.sendOpens {
for path := range cd.open {
_ = s.p.enrichCloseDoc(c, path)
s.curOpen--
}
}
cd.open = map[string]*docEntry{}
cd.lru.Init()
Expand Down
36 changes: 36 additions & 0 deletions internal/semantic/lsp/doc_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,42 @@ func TestDocSession_LRUEvictsPairedAndBounded(t *testing.T) {
assert.Equal(t, 3, opens, "each of the three files was opened once")
}

// A session with the lifecycle off (sendOpens=false) still evicts to keep
// the content cache bounded — and the evictions counter must record that
// churn. Eviction telemetry tracks the cache, not the didClose traffic;
// the lifecycle counters (didOpens, peak) stay honestly zero.
func TestDocSession_NoSendOpens_EvictionsStillCounted(t *testing.T) {
repoRoot := t.TempDir()
files := []string{"a.go", "b.go", "c.go"}
for _, f := range files {
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, f), []byte("package main\n"), 0o644))
}

server := newInstrumentedServer()
p, cleanup := providerWithInstrumentedServer(t, server, []string{"go"}, 1)
defer cleanup()

session := newDocSession(p)
session.cap = 2
session.sendOpens = false

for _, f := range files {
_, release, err := session.acquire(p.client, filepath.Join(repoRoot, f))
require.NoError(t, err)
release()
}

didOpens, _, evictions, peakOpen := session.stats()
assert.Zero(t, didOpens, "no lifecycle — didOpen telemetry stays zero")
assert.Zero(t, peakOpen)
assert.Equal(t, 1, evictions,
"the third acquire evicted the oldest cache entry and must be counted")

_, opens, closes := server.stats()
assert.Zero(t, opens, "no didOpen is ever sent")
assert.Zero(t, closes, "no didClose is ever sent")
}

// Pinned entries are never evicted: holding refs on cap files and acquiring one
// more overshoots cap (no didClose) rather than closing a pinned document.
func TestDocSession_PinnedNeverEvicted(t *testing.T) {
Expand Down
Loading
Loading