Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

888 lines
30 KiB
Go

package lsp
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/semantic"
)
// fileExists reports whether path names an existing regular file.
// spawnRubyLSPWithoutGemfile reports whether ruby-lsp should still be
// launched for a workspace that has no Gemfile (and would therefore trigger
// a network `bundle install` for its composed bundle on spawn). Default
// false — skip it and rely on the in-process ruby resolver. Set
// GORTEX_RUBY_LSP_NO_GEMFILE=1 to restore the spawn.
func spawnRubyLSPWithoutGemfile() bool {
v := os.Getenv("GORTEX_RUBY_LSP_NO_GEMFILE")
return v == "1" || strings.EqualFold(v, "true")
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// Router is a daemon-managed pool of LSP providers keyed by ServerSpec.
// It routes requests to the right provider by file extension, spawns
// providers lazily on first touch, and reaps idle ones to bound the
// number of subprocesses kept alive.
//
// Usage shape:
//
// r := NewRouter(workspaceRoot, logger).WithIdleTimeout(10*time.Minute)
// p, err := r.For("path/to/file.rs") // provider for rust-analyzer
// if err != nil { ... }
// d, _ := p.LastDiagnostics(absPath)
//
// Lifecycle:
// - First For() call per spec: ServerSpec.Command must be on PATH
// or one of AlternativeCommands must resolve. Failure returns the
// unresolvable spec name in the error.
// - Subsequent For() calls reuse the cached provider.
// - Close() shuts every provider down deterministically.
// - Reap() (best-effort, called from a tick goroutine when
// WithReaperInterval is set) closes providers idle longer than
// IdleTimeout.
type Router struct {
// defaultWorkspace is the workspace root used by For / ForSpec
// when the caller doesn't supply one explicitly. Multi-repo
// daemons override per request via ForWorkspace / ForSpecWorkspace.
defaultWorkspace string
logger *zap.Logger
// additionalWorkspaceFolders are extra directory roots advertised
// to every LSP server's initialize request (alongside the primary
// root) so a server can resolve cross-package imports.
additionalWorkspaceFolders []string
// enrichExcludeGlobs are user-configured path globs to skip for
// enrichment, propagated to every spawned provider.
enrichExcludeGlobs []string
// enrichSweepMode is the configured per-file enrichment sweep mode
// ("demand" default / "full" / "off"), propagated to every spawned
// provider. Empty means the demand-gated default; the GORTEX_LSP_SWEEP
// env override wins over it at enrichment time.
enrichSweepMode string
mu sync.Mutex
providers map[providerKey]*routedProvider // (spec.Name, workspace) → cached provider
enabled map[string]*ServerSpec // spec.Name → spec marked enabled by config (no spawn until For/ForSpec)
// limits — zero means "no limit / no reaping". maxAlive is guarded by
// r.mu; SetMaxAlive mutates it at runtime (batch enrichment raises the
// cap so concurrent passes don't evict each other's warmed servers).
idleTimeout time.Duration
reaperInterval time.Duration
maxAlive int
// evictions counts LRU evictions (maybeEvictLRULocked) over the life
// of the router. Read via EvictionCount to observe provider churn
// during batch enrichment. Atomic so the accessor needn't take r.mu.
evictions atomic.Uint64
stopReaper chan struct{}
// availability cache — checking exec.LookPath has measurable
// overhead on Windows / WSL filesystems, and the answer is
// stable for the life of the process.
availMu sync.RWMutex
avail map[string]bool // spec.Name → resolved on PATH
// diagHookMu / diagHook installs a single persistent
// publishDiagnostics subscriber across every spawned provider —
// current and future. The MCP server registers itself here at
// boot to forward LSP diagnostics as `notifications/diagnostics`.
diagHookMu sync.RWMutex
diagHook func(specName, absPath string, diags []Diagnostic)
}
type routedProvider struct {
spec *ServerSpec
workspace string
provider *Provider
lastUsed time.Time
// inUse counts callers currently holding this provider for a long
// operation (an enrichment pass). The LRU evictor skips any provider
// with inUse > 0 so a slow in-flight pass is never Close()d mid-use by
// another repo's concurrent spawn. Guarded by r.mu.
inUse int
}
// providerKey identifies a (spec, workspace) pair in the cache. Each
// (spec, workspace) combination gets its own LSP subprocess so a
// multi-repo daemon doesn't conflate which workspace a server was
// initialised against (Provider.EnsureClient is idempotent — only the
// first call's workspace root sticks).
type providerKey struct {
specName string
workspace string
}
// NewRouter constructs an empty Router. defaultWorkspace is the
// directory passed to LSP servers as `rootUri` when the caller uses
// For / ForSpec without specifying a workspace. Multi-repo daemons
// override on a per-request basis via ForWorkspace / ForSpecWorkspace.
func NewRouter(defaultWorkspace string, logger *zap.Logger) *Router {
if logger == nil {
logger = zap.NewNop()
}
abs, _ := filepath.Abs(defaultWorkspace)
return &Router{
defaultWorkspace: abs,
logger: logger,
providers: make(map[providerKey]*routedProvider),
enabled: make(map[string]*ServerSpec),
avail: make(map[string]bool),
}
}
// RegisterSpec marks spec as enabled — the Router will return it from
// EnabledSpecs and accept it as a target for ForSpec, but no LSP
// subprocess is spawned until the first For/ForSpec call. Call this at
// boot for every server the user has opted into via config.
//
// Idempotent — re-registering the same spec is a no-op.
func (r *Router) RegisterSpec(spec *ServerSpec) {
if spec == nil {
return
}
r.mu.Lock()
r.enabled[spec.Name] = spec
r.mu.Unlock()
}
// RegisterAvailable iterates every spec in the global registry and
// registers each one whose command (or any AlternativeCommands entry)
// resolves on the daemon's PATH. The `disabled` set is checked first
// — listed names are skipped unconditionally so users keep precise
// per-spec opt-out without needing to know the registry contents.
//
// Returns the list of names that were actually registered, in
// registration order. Idempotent against RegisterSpec — re-running
// over already-registered specs is a no-op.
//
// Why this is safe-by-default: RegisterSpec only marks the spec as
// eligible — no subprocess is spawned until something calls
// ForSpec / ForSpecWorkspace. Routers that no caller queries cost
// the daemon nothing beyond a cached PATH lookup.
func (r *Router) RegisterAvailable(disabled map[string]bool) []string {
specs := AllSpecs()
var registered []string
for _, spec := range specs {
if spec == nil {
continue
}
if disabled[spec.Name] {
continue
}
// An explicit .gortex.yaml entry may have already registered a
// SpecWithOverrides copy of this spec (a different pointer)
// carrying custom command / args / env. The auto-pass must
// defer to that override rather than clobber it with the
// pristine built-in spec. A spec already registered as the
// built-in itself is harmless to re-register.
r.mu.Lock()
existing, already := r.enabled[spec.Name]
r.mu.Unlock()
if already && existing != spec {
continue
}
if !r.specAvailable(spec) {
continue
}
r.RegisterSpec(spec)
registered = append(registered, spec.Name)
}
sort.Strings(registered)
return registered
}
// EnabledSpecs returns every spec previously registered via
// RegisterSpec, sorted by name. The slice may include specs whose
// command is not on PATH — call Available(spec) to filter.
func (r *Router) EnabledSpecs() []*ServerSpec {
r.mu.Lock()
out := make([]*ServerSpec, 0, len(r.enabled))
for _, s := range r.enabled {
out = append(out, s)
}
r.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// EnabledSpecNames returns just the names of enabled specs. Used by the
// semantic.Manager interface bridge so the package boundary stays clean
// (semantic.Manager can't import lsp without a cycle).
func (r *Router) EnabledSpecNames() []string {
specs := r.EnabledSpecs()
out := make([]string, len(specs))
for i, s := range specs {
out[i] = s.Name
}
return out
}
// ProviderForSpec returns the lazy-spawned LSP provider as a
// semantic.Provider interface. Used by semantic.Manager.EnrichAll to
// drive batch enrichment without taking a hard dependency on the lsp
// package. Returns an error if the spec is not enabled or not on PATH.
func (r *Router) ProviderForSpec(name string) (semantic.Provider, error) {
r.mu.Lock()
spec, ok := r.enabled[name]
r.mu.Unlock()
if !ok {
return nil, fmt.Errorf("LSP spec %q not registered", name)
}
return r.ForSpec(spec)
}
// ProviderForSpecWorkspace returns the lazy-spawned LSP provider for the
// named spec scoped to a specific workspace root, so each repo gets its own
// provider instance keyed by (spec, workspace) instead of sharing the
// default-workspace one. Used by per-repo enrichment so concurrent passes
// across repos do not share a single Provider's connection / document caches.
func (r *Router) ProviderForSpecWorkspace(name, workspace string) (semantic.Provider, error) {
r.mu.Lock()
spec, ok := r.enabled[name]
r.mu.Unlock()
if !ok {
return nil, fmt.Errorf("LSP spec %q not registered", name)
}
// forSpecWorkspace with pin=true increments inUse inside the same locked
// section it publishes/looks up the provider, closing the spawn→pin race.
// The caller MUST pair this with ReleaseSpecWorkspace.
return r.forSpecWorkspace(spec, workspace, true)
}
// SpecAvailable reports whether the named spec is registered AND its
// command resolves on PATH. Pure read — no subprocess spawn. Caches
// the PATH-lookup result like specAvailable does for ForSpec.
func (r *Router) SpecAvailable(name string) bool {
r.mu.Lock()
spec, ok := r.enabled[name]
r.mu.Unlock()
if !ok {
return false
}
return r.specAvailable(spec)
}
// SpecLanguages returns the language codes the named spec serves.
// Pure metadata read — never spawns a subprocess. Returns nil for
// unregistered specs.
func (r *Router) SpecLanguages(name string) []string {
r.mu.Lock()
spec, ok := r.enabled[name]
r.mu.Unlock()
if !ok || spec == nil {
return nil
}
out := make([]string, len(spec.Languages))
copy(out, spec.Languages)
return out
}
// SpecPriority returns the spec's default priority (lower number wins
// when multiple specs serve the same language). Returns 99 (a
// fallback "any provider beats unknown") for unregistered specs.
func (r *Router) SpecPriority(name string) int {
r.mu.Lock()
spec, ok := r.enabled[name]
r.mu.Unlock()
if !ok || spec == nil {
return 99
}
return spec.Priority
}
// WithIdleTimeout sets how long a provider can be idle before Reap()
// will shut it down.
func (r *Router) WithIdleTimeout(d time.Duration) *Router {
r.idleTimeout = d
return r
}
// WithAdditionalWorkspaceFolders sets extra directory roots advertised
// to every LSP server's initialize request alongside the primary
// workspace root, enabling cross-package resolution. Builder-style.
func (r *Router) WithAdditionalWorkspaceFolders(folders []string) *Router {
r.additionalWorkspaceFolders = folders
return r
}
// WithEnrichExcludeGlobs sets user-configured path globs that every spawned
// provider skips for enrichment (on top of the built-in generated/vendored
// heuristic). Builder-style.
func (r *Router) WithEnrichExcludeGlobs(globs []string) *Router {
r.enrichExcludeGlobs = globs
return r
}
// WithEnrichSweepMode sets the per-file enrichment sweep mode ("demand"
// default / "full" / "off") propagated to every spawned provider. An empty
// value keeps the demand-gated default; the GORTEX_LSP_SWEEP env override
// still wins over whatever is set here. Builder-style.
func (r *Router) WithEnrichSweepMode(mode string) *Router {
r.enrichSweepMode = mode
return r
}
// WithReaperInterval starts a background reaper that calls Reap() at
// the given cadence. Idempotent — calling twice replaces the previous
// reaper. A zero duration disables reaping.
func (r *Router) WithReaperInterval(d time.Duration) *Router {
r.mu.Lock()
if r.stopReaper != nil {
close(r.stopReaper)
r.stopReaper = nil
}
if d > 0 {
stop := make(chan struct{})
r.stopReaper = stop
go r.reaperLoop(d, stop)
}
r.reaperInterval = d
r.mu.Unlock()
return r
}
// WithMaxAlive caps the number of concurrent live providers. When
// exceeded, the least-recently-used provider is evicted. Construction-
// only and unlocked — use SetMaxAlive to change the cap at runtime.
func (r *Router) WithMaxAlive(n int) *Router {
r.maxAlive = n
return r
}
// SetMaxAlive changes the live-provider cap at runtime under r.mu.
// Raising the cap lets a batch of concurrent enrichment passes keep more
// warmed language servers alive at once instead of churning through the
// LRU evictor; lowering it evicts any now-excess (unpinned) providers so
// the invariant len(providers) <= maxAlive is restored. A provider held
// in-use by an in-flight pass is never evicted, matching the LRU rule.
func (r *Router) SetMaxAlive(n int) {
r.mu.Lock()
r.maxAlive = n
r.maybeEvictLRULocked()
r.mu.Unlock()
}
// MaxAlive returns the current live-provider cap. Zero means unbounded.
func (r *Router) MaxAlive() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.maxAlive
}
// EvictionCount returns the number of LRU evictions the router has
// performed over its lifetime. Used to observe provider churn across a
// batch enrichment window (sample before / after).
func (r *Router) EvictionCount() uint64 {
return r.evictions.Load()
}
// workspaceKey resolves a workspace string to the cache key form
// ForSpecWorkspace uses (default-substituted, absolutised).
func (r *Router) workspaceKey(specName, workspace string) providerKey {
if workspace == "" {
workspace = r.defaultWorkspace
}
if abs, err := filepath.Abs(workspace); err == nil {
workspace = abs
}
return providerKey{specName: specName, workspace: workspace}
}
// ReleaseSpecWorkspace marks a provider previously obtained via
// ProviderForSpecWorkspace as no longer in active use, so the LRU evictor /
// reaper may reclaim it again. Pairs one-to-one with ProviderForSpecWorkspace.
func (r *Router) ReleaseSpecWorkspace(name, workspace string) {
key := r.workspaceKey(name, workspace)
r.mu.Lock()
if rp := r.providers[key]; rp != nil && rp.inUse > 0 {
rp.inUse--
}
r.mu.Unlock()
}
// For returns the provider responsible for the given file path under
// the router's defaultWorkspace. Convenience wrapper for single-
// workspace callers; multi-repo daemons should use ForWorkspace and
// pass the per-file workspace root.
func (r *Router) For(relPath string) (*Provider, error) {
return r.ForWorkspace(relPath, r.defaultWorkspace)
}
// ForWorkspace returns the provider responsible for the given file
// path under the given workspace root. Cache key is (spec, workspace)
// so the same LSP spec gets a separate subprocess per workspace —
// preventing the multi-repo bug where Provider.EnsureClient (which is
// idempotent) would otherwise leave every workspace pinned to the
// rootURI of whichever request happened to spawn the server first.
func (r *Router) ForWorkspace(relPath, workspace string) (*Provider, error) {
spec := SpecForPath(relPath)
if spec == nil {
return nil, fmt.Errorf("no LSP server registered for %s", filepath.Ext(relPath))
}
return r.ForSpecWorkspace(spec, workspace)
}
// ForSpec returns the provider for a named spec under the router's
// defaultWorkspace. Convenience wrapper.
func (r *Router) ForSpec(spec *ServerSpec) (*Provider, error) {
return r.ForSpecWorkspace(spec, r.defaultWorkspace)
}
// ForSpecWorkspace returns the provider for a named spec under the
// given workspace root, spawning it on first call. The (spec,
// workspace) tuple uniquely identifies the cached Provider.
func (r *Router) ForSpecWorkspace(spec *ServerSpec, workspace string) (*Provider, error) {
return r.forSpecWorkspace(spec, workspace, false)
}
// forSpecWorkspace is ForSpecWorkspace with an optional in-use pin. When pin
// is true the returned provider's inUse count is incremented in the SAME
// locked section that looks it up or publishes it — so a concurrent spawn's
// LRU eviction can never Close a freshly-returned-but-not-yet-pinned provider
// (the spawn→pin TOCTOU). A pinned fetch MUST be paired with ReleaseSpecWorkspace.
func (r *Router) forSpecWorkspace(spec *ServerSpec, workspace string, pin bool) (*Provider, error) {
if !r.specAvailable(spec) {
return nil, fmt.Errorf("LSP server %q not available on PATH", spec.Name)
}
if workspace == "" {
workspace = r.defaultWorkspace
}
if abs, err := filepath.Abs(workspace); err == nil {
workspace = abs
}
key := providerKey{specName: spec.Name, workspace: workspace}
r.mu.Lock()
rp, ok := r.providers[key]
if ok {
rp.lastUsed = time.Now()
if pin {
rp.inUse++
}
r.mu.Unlock()
return rp.provider, nil
}
r.mu.Unlock()
// Spawn outside the lock — initialize() blocks on stdio I/O.
p := NewProviderFromSpec(spec, r.logger)
p.workspaceFolders = r.additionalWorkspaceFolders
p.excludeGlobs = r.enrichExcludeGlobs
p.sweepMode = r.enrichSweepMode
// ruby-lsp (and any spec opting in) runs a `bundle install` for a composed
// bundle on spawn unless BUNDLE_GEMFILE is set; point it at the workspace's
// own Gemfile when present so enrichment skips that install.
if spec.UseWorkspaceBundleGemfile {
if gemfile := filepath.Join(workspace, "Gemfile"); fileExists(gemfile) {
p.env = append(append([]string(nil), p.env...), "BUNDLE_GEMFILE="+gemfile)
} else if !spawnRubyLSPWithoutGemfile() {
// No Gemfile: ruby-lsp runs a `bundle install` for its composed
// bundle on spawn — a multi-second network round-trip to
// rubygems.org — for a workspace where Ruby is typically only test
// fixtures. Skip the LSP; the in-process ruby-types resolver still
// covers Ruby. This is a per-workspace skip (NOT markSpawnFailed),
// so a sibling repo that does carry a Gemfile still spawns ruby-lsp.
// GORTEX_RUBY_LSP_NO_GEMFILE=1 forces the spawn.
return nil, fmt.Errorf("ruby-lsp: no Gemfile in %s; skipping composed-bundle install", workspace)
}
}
if err := p.EnsureClient(workspace); err != nil {
// A binary that resolves on PATH but cannot launch (e.g. a rustup
// `rust-analyzer` shim whose toolchain lacks the component) would
// otherwise be re-attempted on every repo. Mark it unavailable so the
// router stops retrying it for this session.
r.markSpawnFailed(spec.Name, err)
return nil, fmt.Errorf("spawn %s: %w", spec.Name, err)
}
// Attach the diagnostics hook (if any) before publishing to the
// providers map so we don't drop the first publishDiagnostics
// burst some servers emit during workspace warmup.
r.attachDiagnosticsHook(spec.Name, p)
r.mu.Lock()
defer r.mu.Unlock()
// Race: another goroutine may have spawned it while we were
// initializing. Prefer the existing one and shut down our duplicate.
if existing, ok := r.providers[key]; ok {
existing.lastUsed = time.Now()
if pin {
existing.inUse++
}
go func() { _ = p.Close() }()
return existing.provider, nil
}
newRP := &routedProvider{
spec: spec,
workspace: workspace,
provider: p,
lastUsed: time.Now(),
}
// Pin BEFORE maybeEvictLRULocked runs so the just-published provider is
// never the eviction victim, and is protected the instant it is reachable.
if pin {
newRP.inUse = 1
}
r.providers[key] = newRP
r.maybeEvictLRULocked()
return p, nil
}
// SetDiagnosticsHook installs a persistent subscriber called for every
// `textDocument/publishDiagnostics` any router-managed provider emits.
// Pass nil to detach.
//
// Calling SetDiagnosticsHook on a router that already owns providers
// re-attaches the new hook to every existing provider in addition to
// installing it for future spawns. Passing nil clears the per-provider
// hook on every existing provider.
//
// The hook MUST NOT block — it runs on the LSP client message-pump
// goroutine.
func (r *Router) SetDiagnosticsHook(hook func(specName, absPath string, diags []Diagnostic)) {
r.diagHookMu.Lock()
r.diagHook = hook
r.diagHookMu.Unlock()
// Re-attach to every live provider so the change takes effect
// without requiring a restart.
r.mu.Lock()
live := make([]*routedProvider, 0, len(r.providers))
for _, rp := range r.providers {
live = append(live, rp)
}
r.mu.Unlock()
for _, rp := range live {
r.attachDiagnosticsHook(rp.spec.Name, rp.provider)
}
}
// attachDiagnosticsHook installs the router-level hook on a single
// provider, capturing the spec name in the closure so subscribers can
// distinguish the source LSP. No-ops when the router has no hook set.
func (r *Router) attachDiagnosticsHook(specName string, p *Provider) {
r.diagHookMu.RLock()
hook := r.diagHook
r.diagHookMu.RUnlock()
if hook == nil {
p.SetDiagnosticsHook(nil)
return
}
p.SetDiagnosticsHook(func(absPath string, diags []Diagnostic) {
hook(specName, absPath, diags)
})
}
// DiagnosticsEntry is one (spec, file, diagnostics) row in a snapshot.
type DiagnosticsEntry struct {
SpecName string
AbsPath string
Diagnostics []Diagnostic
}
// DiagnosticsSnapshot returns the most recent publishDiagnostics
// payload across every alive provider, flattened into a single slice.
// Used to replay current state to a freshly-subscribed MCP client.
func (r *Router) DiagnosticsSnapshot() []DiagnosticsEntry {
r.mu.Lock()
live := make([]*routedProvider, 0, len(r.providers))
for _, rp := range r.providers {
live = append(live, rp)
}
r.mu.Unlock()
var out []DiagnosticsEntry
for _, rp := range live {
snap := rp.provider.DiagnosticsSnapshot()
for path, diags := range snap {
out = append(out, DiagnosticsEntry{
SpecName: rp.spec.Name,
AbsPath: path,
Diagnostics: diags,
})
}
}
return out
}
// Available reports whether at least one of the spec's commands is on
// PATH. Negative results are cached, but a future PATH change between
// calls is the caller's problem.
func (r *Router) Available(spec *ServerSpec) bool {
return r.specAvailable(spec)
}
// AvailableSpecs lists every spec resolvable on the current PATH. Use
// at startup to log which servers will spin up later.
func (r *Router) AvailableSpecs() []*ServerSpec {
out := make([]*ServerSpec, 0)
for _, s := range AllSpecs() {
if r.specAvailable(s) {
out = append(out, s)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// specAvailable returns true when one of spec.Command +
// spec.AlternativeCommands resolves on PATH, or — for a passive spec
// — when the connect block validates (Validate() == nil). A passive
// spec has no binary to look up; its "availability" is whether the
// configured endpoint is well-formed (the actual dial happens on
// first ensureClient).
func (r *Router) specAvailable(spec *ServerSpec) bool {
if spec == nil {
return false
}
r.availMu.RLock()
v, cached := r.avail[spec.Name]
r.availMu.RUnlock()
if cached {
return v
}
avail := false
switch {
case spec.Connect != nil:
// Passive attach — no binary to find. Treat as available
// when the connect block validates; the dial is exercised
// lazily on first ensureClient.
avail = spec.Connect.Validate() == nil
default:
if _, err := exec.LookPath(spec.Command); err == nil {
avail = true
} else {
for _, alt := range spec.AlternativeCommands {
if _, err := exec.LookPath(alt.Command); err == nil {
avail = true
break
}
}
}
}
r.availMu.Lock()
r.avail[spec.Name] = avail
r.availMu.Unlock()
return avail
}
// markSpawnFailed records that a spec's server process failed to start, so
// the availability cache reports it unavailable and the router stops
// retrying it for the life of the daemon (until restart). Enrichment is
// best-effort: a binary that resolves on PATH but cannot launch should be
// dropped once with a clear warning rather than re-attempted on every repo.
// The warning is emitted only on the transition to unavailable so a single
// failure is reported once, not once per repo.
func (r *Router) markSpawnFailed(specName string, err error) {
r.availMu.Lock()
prev, known := r.avail[specName]
r.avail[specName] = false
r.availMu.Unlock()
if known && !prev {
return // already marked unavailable — don't re-log
}
r.logger.Warn("LSP server failed to start; skipping its language enrichment this session",
zap.String("spec", specName),
zap.Error(err),
)
}
// LanguageIDForPath proxies to the package-level helper for callers
// that hold a router but not a Provider.
func (r *Router) LanguageIDForPath(path string) string { return LanguageIDForPath(path) }
// Reap closes any provider idle for longer than IdleTimeout. Returns
// "spec@workspace" identifiers for reaped entries.
func (r *Router) Reap() []string {
if r.idleTimeout <= 0 {
return nil
}
cut := time.Now().Add(-r.idleTimeout)
r.mu.Lock()
var victims []*routedProvider
for key, rp := range r.providers {
if rp.inUse > 0 {
continue // a provider held by an in-flight pass is not idle
}
if rp.lastUsed.Before(cut) {
victims = append(victims, rp)
delete(r.providers, key)
}
}
r.mu.Unlock()
names := make([]string, 0, len(victims))
for _, v := range victims {
names = append(names, formatProviderKey(v.spec.Name, v.workspace))
_ = v.provider.Close()
}
if len(names) > 0 {
r.logger.Info("LSP router reaped idle providers", zap.Strings("names", names))
}
return names
}
// maybeEvictLRULocked evicts least-recently-used providers until the
// live count is within maxAlive (or no unpinned provider remains to
// evict). Caller must hold r.mu. It loops so a SetMaxAlive that lowers
// the cap by more than one settles in a single call; the fast path
// (already within cap) returns before scanning.
func (r *Router) maybeEvictLRULocked() {
for r.maxAlive > 0 && len(r.providers) > r.maxAlive {
var oldest *routedProvider
var oldestKey providerKey
for key, rp := range r.providers {
if rp.inUse > 0 {
continue // never evict a provider held by an in-flight pass
}
if oldest == nil || rp.lastUsed.Before(oldest.lastUsed) {
oldest = rp
oldestKey = key
}
}
if oldest == nil {
return // every over-cap provider is pinned in-use; nothing to evict
}
delete(r.providers, oldestKey)
r.evictions.Add(1)
go func() { _ = oldest.provider.Close() }()
r.logger.Info("LSP router evicted LRU provider",
zap.String("name", formatProviderKey(oldestKey.specName, oldestKey.workspace)))
}
}
// formatProviderKey renders a (spec, workspace) pair into a stable
// human-readable identifier used in logs, Stats, and Names.
func formatProviderKey(specName, workspace string) string {
if workspace == "" {
return specName
}
return specName + "@" + workspace
}
func (r *Router) reaperLoop(d time.Duration, stop chan struct{}) {
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-t.C:
r.Reap()
case <-stop:
return
}
}
}
// Close shuts down every active provider. Safe to call multiple times.
func (r *Router) Close() error {
r.mu.Lock()
if r.stopReaper != nil {
close(r.stopReaper)
r.stopReaper = nil
}
provs := r.providers
r.providers = make(map[providerKey]*routedProvider)
r.mu.Unlock()
var firstErr error
for _, rp := range provs {
if err := rp.provider.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// Stats reports the live provider names and their last-used times.
// Intended for debug / status endpoints. Spec is the LSP server name;
// Workspace is the rootURI the server is initialised against.
type RouterStat struct {
Spec string `json:"spec"`
Workspace string `json:"workspace"`
LastUsed time.Time `json:"last_used"`
}
// Stats returns one entry per live (spec, workspace) provider.
func (r *Router) Stats() []RouterStat {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]RouterStat, 0, len(r.providers))
for key, rp := range r.providers {
out = append(out, RouterStat{
Spec: key.specName,
Workspace: key.workspace,
LastUsed: rp.lastUsed,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Spec != out[j].Spec {
return out[i].Spec < out[j].Spec
}
return out[i].Workspace < out[j].Workspace
})
return out
}
// Names returns "spec@workspace" identifiers for live providers
// (helper for tests + status output). Sorted for stable output.
func (r *Router) Names() []string {
r.mu.Lock()
defer r.mu.Unlock()
names := make([]string, 0, len(r.providers))
for k := range r.providers {
names = append(names, formatProviderKey(k.specName, k.workspace))
}
sort.Strings(names)
return names
}
// SupportedLanguages returns the set of languages the router can serve
// (any spec with at least one alt command on PATH). Used to advertise
// capability to MCP clients on startup.
func (r *Router) SupportedLanguages() []string {
seen := make(map[string]bool)
for _, s := range r.AvailableSpecs() {
for _, l := range s.Languages {
seen[l] = true
}
}
out := make([]string, 0, len(seen))
for l := range seen {
out = append(out, l)
}
sort.Strings(out)
return out
}
// MarshalDescription returns a human-readable status for one router,
// used by the daemon's `gortex daemon status` command.
func (r *Router) MarshalDescription() string {
stats := r.Stats()
var b strings.Builder
fmt.Fprintf(&b, "lsp-router default=%s alive=%d\n", r.defaultWorkspace, len(stats))
for _, s := range stats {
fmt.Fprintf(&b, " %s@%s last_used=%s\n", s.Spec, s.Workspace, s.LastUsed.Format(time.RFC3339))
}
return b.String()
}
// DefaultWorkspace returns the workspace root used by For / ForSpec
// when the caller doesn't supply one explicitly. Exposed for status /
// debug surfaces.
func (r *Router) DefaultWorkspace() string { return r.defaultWorkspace }