Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
24 changes: 24 additions & 0 deletions docs/lsp.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ 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.

### Sweep modes

The per-file sweep (phase 5) is gated by a **sweep mode**:
Expand Down Expand Up @@ -471,3 +488,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
41 changes: 27 additions & 14 deletions internal/semantic/lsp/doc_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ 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 honestly reports zero.
sendOpens bool

mu sync.Mutex
perClient map[*Client]*clientDocs
Expand Down Expand Up @@ -65,6 +71,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 +122,25 @@ 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--
s.evictions++
if s.sendOpens {
_ = s.p.enrichCloseDoc(c, evPath)
s.curOpen--
s.evictions++
}
}

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 +173,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
183 changes: 183 additions & 0 deletions internal/semantic/lsp/enrich_defconfirm_breaker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package lsp

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/zzet/gortex/internal/graph"
)

// breakerFixture seeds nSites caller files, each with one sited ambiguous
// edge to the same declaration, so the definition pass has one group per
// file. The definition handler is the test's to wire.
func breakerFixture(t *testing.T, repoRoot string, g graph.Store, nSites int) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "def.go"),
[]byte("package p\nfunc target() {}\n"), 0o644))
g.AddNode(&graph.Node{ID: "def.go::target", Kind: graph.KindFunction, Name: "target",
FilePath: "def.go", StartLine: 2, EndLine: 2, Language: "go"})
for i := 0; i < nSites; i++ {
rel := fmt.Sprintf("call%03d.go", i)
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, rel),
[]byte("package p\nfunc caller() { target() }\n"), 0o644))
callerID := rel + "::caller"
g.AddNode(&graph.Node{ID: callerID, Kind: graph.KindFunction, Name: "caller",
FilePath: rel, StartLine: 2, EndLine: 2, Language: "go"})
g.AddEdge(&graph.Edge{
From: callerID, To: "def.go::target", Kind: graph.EdgeCalls,
FilePath: rel, Line: 2,
Confidence: 0.7, ConfidenceLabel: "INFERRED", Origin: graph.OriginTextMatched,
})
}
}

// Under the heavy opt-out the definition pass carries the whole confirm load,
// so it must feed the targeted failure-streak breaker: a server that errors
// every definition request has to abort the pass after the streak limit, not
// grind through every sited target at one timeout each.
func TestLSP_Enrich_DefinitionPassTripsBreakerOnDeadServer(t *testing.T) {
t.Setenv(SweepEnv, "full")
t.Setenv("GORTEX_LSP_BREAKER", "5")
const nSites = 40
const maxParallel = 2

repoRoot := t.TempDir()
g := graph.New()
server := newFakeLSPServer()
breakerFixture(t, repoRoot, g, nSites)

var defs atomic.Int64
server.handle("textDocument/definition", func(json.RawMessage) (any, *jsonRPCError) {
defs.Add(1)
return nil, &jsonRPCError{Code: -32603, Message: "boom"}
})
server.handle("textDocument/hover", func(json.RawMessage) (any, *jsonRPCError) { return nil, nil })

p, cleanup := providerWithFakeServerParallel(t, server, []string{"go"}, maxParallel)
defer cleanup()
p.noHeavyRequests = true

require.NoError(t, runEnrich(t, p, g, repoRoot, 10*time.Second))

// Streak limit plus the requests already in flight across maxParallel
// goroutines when the breaker trips.
assert.LessOrEqual(t, defs.Load(), int64(5+maxParallel),
"a dead server must trip the targeted breaker, not answer every site")
}

// The trip must come from transport failures only. A server that ANSWERS
// every definition request with an empty result is healthy — no verdict for
// the edge, but the breaker stays disarmed and every site gets asked.
func TestLSP_Enrich_DefinitionPassEmptyAnswersDoNotTrip(t *testing.T) {
t.Setenv(SweepEnv, "full")
t.Setenv("GORTEX_LSP_BREAKER", "5")
const nSites = 40

repoRoot := t.TempDir()
g := graph.New()
server := newFakeLSPServer()
breakerFixture(t, repoRoot, g, nSites)

var defs atomic.Int64
server.handle("textDocument/definition", func(json.RawMessage) (any, *jsonRPCError) {
defs.Add(1)
return []Location{}, nil
})
server.handle("textDocument/hover", func(json.RawMessage) (any, *jsonRPCError) { return nil, nil })

p, cleanup := providerWithFakeServerParallel(t, server, []string{"go"}, 2)
defer cleanup()
p.noHeavyRequests = true

require.NoError(t, runEnrich(t, p, g, repoRoot, 10*time.Second))

assert.Equal(t, int64(nSites), defs.Load(),
"answered-but-empty is not a failure; every site must still be asked")
}

// A PRODUCTIVE definition pass must survive the productivity checkpoint.
// Under the heavy opt-out the def pass carries the whole confirm load, and
// on a warm graph it is the only yield source until the hover phase — if its
// confirm/add/rebind arms do not feed usefulYield, the checkpoint reads a
// pass that is settling hundreds of edges as zero-yield and cancels it at
// the first tick after request volume flows.
func TestLSP_Enrich_DefinitionPassYieldSurvivesProductivityCheckpoint(t *testing.T) {
t.Setenv(SweepEnv, "full")
t.Setenv("GORTEX_LSP_PRODUCTIVITY_WINDOW", "150ms")
const nSites = 300

repoRoot := t.TempDir()
g := graph.New()
server := newFakeLSPServer()
breakerFixture(t, repoRoot, g, nSites)

server.handle("textDocument/definition", func(json.RawMessage) (any, *jsonRPCError) {
// Slow enough that the pass spans several checkpoint windows while
// confirming at ~30 edges per window — far above the 10-per-window
// floor a productive pass must sustain.
time.Sleep(5 * time.Millisecond)
return []Location{{
URI: pathToURI(filepath.Join(repoRoot, "def.go")),
Range: Range{Start: Position{Line: 1, Character: 5}, End: Position{Line: 1, Character: 11}},
}}, nil
})
server.handle("textDocument/hover", func(json.RawMessage) (any, *jsonRPCError) { return nil, nil })

p, cleanup := providerWithFakeServerParallel(t, server, []string{"go"}, 2)
defer cleanup()
p.noHeavyRequests = true

require.NoError(t, runEnrich(t, p, g, repoRoot, 60*time.Second))

confirmed := 0
for _, e := range g.AllEdges() {
if e.Kind == graph.EdgeCalls && e.Confidence == 1.0 {
confirmed++
}
}
assert.Equal(t, nSites, confirmed,
"a pass confirming hundreds of edges must not be cut by the productivity checkpoint")
}

// The heavy opt-out must also cover the on-demand path: ConfirmSymbolRefs
// rides callHierarchy/incomingCalls, the same FindReferences machinery the
// sweep skips. A long-lived daemon answering find_usages against a leaky
// server would otherwise accumulate the leak one query at a time.
func TestLSP_ConfirmSymbolRefs_NoHeavySkipsIncoming(t *testing.T) {
repoRoot := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "svc.go"),
[]byte("package p\nfunc served() {}\n"), 0o644))
g := graph.New()
n := &graph.Node{ID: "svc.go::served", Kind: graph.KindFunction, Name: "served",
FilePath: "svc.go", StartLine: 2, EndLine: 2, Language: "go"}
g.AddNode(n)

server := newFakeLSPServer()
var prepares, incomings atomic.Int64
server.handle("textDocument/prepareCallHierarchy", func(json.RawMessage) (any, *jsonRPCError) {
prepares.Add(1)
return []CallHierarchyItem{{Name: "served"}}, nil
})
server.handle("callHierarchy/incomingCalls", func(json.RawMessage) (any, *jsonRPCError) {
incomings.Add(1)
return []CallHierarchyIncomingCall{}, nil
})

p, cleanup := providerWithFakeServerParallel(t, server, []string{"go"}, 2)
defer cleanup()
p.noHeavyRequests = true

confirmed, err := p.ConfirmSymbolRefs(g, repoRoot, n)
require.NoError(t, err)
assert.Zero(t, confirmed)
assert.Zero(t, incomings.Load(),
"incomingCalls must never be issued under the heavy opt-out")
}
Loading
Loading