diff --git a/docs/lsp.md b/docs/lsp.md index 2ec942605..c02cc02c5 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -171,6 +171,20 @@ touched only intermittently. Override them by editing the `lsp.NewRouter(...).With...` chain in your build if you need a longer warm pool or a tighter memory bound. +### Workspace roots + +Each cached provider is keyed by `(spec, workspace)`, and the workspace +is what the language server is initialised against **and chdir'd into**. +It always comes from the tracked repo's own recorded path — never from +the directory the daemon process happened to be launched in. A daemon +tracking several repos is normally started from none of them, so a +workspace derived from its launch cwd would name a directory that exists +nowhere and fail the spawn. + +The router therefore requires an absolute, existing directory and +rejects anything else before spawning. `gortex daemon --detach` can be +started from anywhere; no repo needs to be the daemon's cwd. + ## Enrichment cost model The resolution path (use 1 at the top of this page) runs as a batch @@ -418,6 +432,12 @@ for repositories you trust. boot but the subprocess failed to initialise (commonly a missing dependency such as `node` for `pyright`, or a workspace-config mismatch). The error surfaces the LSP server's stderr. +- **`workspace ... is not usable as a working directory` error:** the + caller supplied a workspace root that names no directory. The router + refuses it rather than spawning the server there, because that value + becomes the subprocess's working directory. A rejection is local to + the one workspace — the spec stays available for every other tracked + repo. - **Server keeps restarting:** the idle reaper closed it, then the next request re-spawned. Increase `WithIdleTimeout` if this hurts warm-cache benchmarks. diff --git a/internal/mcp/tools_lsp.go b/internal/mcp/tools_lsp.go index dcd2cdda6..932d318b1 100644 --- a/internal/mcp/tools_lsp.go +++ b/internal/mcp/tools_lsp.go @@ -4,13 +4,15 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" - "strings" + "sort" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/pathkey" "github.com/zzet/gortex/internal/semantic" "github.com/zzet/gortex/internal/semantic/lsp" ) @@ -46,7 +48,11 @@ func (s *Server) enrichNodeOnDemand(node *graph.Node) { if err != nil || provider == nil { return } - _, _ = provider.EnrichNode(s.graph, s.workspaceRootFor(absPath), node) + root, err := s.workspaceRootFor(absPath) + if err != nil { + return + } + _, _ = provider.EnrichNode(s.graph, root, node) } // confirmSymbolRefsOnDemand faults in a callable symbol's INCOMING references @@ -75,7 +81,11 @@ func (s *Server) confirmSymbolRefsOnDemand(node *graph.Node) { if err != nil || provider == nil { return } - _, _ = provider.ConfirmSymbolRefs(s.graph, s.workspaceRootFor(absPath), node) + root, err := s.workspaceRootFor(absPath) + if err != nil { + return + } + _, _ = provider.ConfirmSymbolRefs(s.graph, root, node) // Record after the attempt (even on 0/err) so a query doesn't re-spawn the // server every call; a file re-index clears staleness through the normal // restub path. Durable-ledger + retry semantics are the fuller version. @@ -378,7 +388,10 @@ func (s *Server) lspProviderForPath(path string) (*lsp.Provider, string, error) // in a multi-repo daemon, each rooted correctly. A // shared (spec, defaultWorkspace) reuse would corrupt // rootURI for files outside the first-spawned repo. - root := s.workspaceRootFor(abs) + root, err := s.workspaceRootFor(abs) + if err != nil { + return nil, abs, err + } lp, err := r.ForSpecWorkspace(spec, root) if err != nil { return nil, abs, fmt.Errorf("router spawn %s: %w", spec.Name, err) @@ -402,7 +415,10 @@ func (s *Server) lspProviderForPath(path string) (*lsp.Provider, string, error) continue } // Lazy spawn — Provider.EnsureClient is idempotent. - root := s.workspaceRootFor(abs) + root, err := s.workspaceRootFor(abs) + if err != nil { + return nil, abs, err + } if err := lp.EnsureClient(root); err != nil { return nil, abs, fmt.Errorf("ensure client %s: %w", spec.Name, err) } @@ -455,30 +471,76 @@ func (s *Server) absolutePath(path string) (string, error) { return filepath.Join(root, path), nil } } + // A repo-relative fragment the prefix join could not claim (an + // unprefixed graph path in a multi-repo daemon) still belongs to one of + // the tracked repos. Anchor it there before considering filepath.Abs, + // which would join it onto the daemon's own launch directory and mint a + // path that exists nowhere. + if abs := s.anchorOnTrackedRepo(path); abs != "" { + return abs, nil + } if abs, err := filepath.Abs(path); err == nil { return abs, nil } return path, nil } -// workspaceRootFor returns the workspace root the LSP server should -// be initialised with for the given file. Falls back to the file's -// directory when no indexer root is available. -func (s *Server) workspaceRootFor(absPath string) string { +// anchorOnTrackedRepo joins a repo-relative path onto each tracked repo +// root and returns the first candidate that exists on disk. Roots are +// walked in sorted-prefix order so the answer stays deterministic when the +// same relative path resolves under more than one tracked repo. +func (s *Server) anchorOnTrackedRepo(rel string) string { + roots := s.collectRepoRoots("") + if len(roots) == 0 { + return "" + } + prefixes := make([]string, 0, len(roots)) + for prefix := range roots { + prefixes = append(prefixes, prefix) + } + sort.Strings(prefixes) + for _, prefix := range prefixes { + candidate := filepath.Join(roots[prefix], rel) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} + +// workspaceRootFor returns the workspace root the LSP server should be +// initialised with — and chdir'd into — for the given file: the tracked +// repo that contains it. +// +// It never derives a root from the daemon process's own working +// directory. A daemon tracking several repos is normally launched from +// none of them, and handing a language server a directory synthesised +// from that cwd fails the spawn with an opaque chdir error, silently +// dropping that language's enrichment for the rest of the session. +func (s *Server) workspaceRootFor(absPath string) (string, error) { if s.indexer != nil { - root := s.indexer.RootPath() - if root != "" && strings.HasPrefix(absPath, root) { - return root + // Component-boundary containment: a plain string prefix would let + // the root /src/repo claim a file under /src/repo-old. + if root := s.indexer.RootPath(); root != "" && pathkey.HasPathPrefix(absPath, root) { + return root, nil } } if s.multiIndexer != nil { if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" { - if meta := s.multiIndexer.GetMetadata(prefix); meta != nil { - return meta.RootPath + if meta := s.multiIndexer.GetMetadata(prefix); meta != nil && meta.RootPath != "" { + return meta.RootPath, nil } } } - return filepath.Dir(absPath) + // Last resort — the file's own directory, and only when it really + // exists. An unindexed-but-real file (a scratch file an editor opened) + // is a legitimate workspace; a directory that exists nowhere is the + // mis-anchored-path bug, and must not reach a spawn. + dir := filepath.Dir(absPath) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir, nil + } + return "", fmt.Errorf("no tracked repo contains %s: refusing to derive an LSP workspace from the daemon's working directory", absPath) } // lspNoProviderResult returns a structured error for the "no LSP diff --git a/internal/mcp/tools_lsp_workspace_test.go b/internal/mcp/tools_lsp_workspace_test.go new file mode 100644 index 000000000..ed5f6e08a --- /dev/null +++ b/internal/mcp/tools_lsp_workspace_test.go @@ -0,0 +1,107 @@ +package mcp + +// Regression cover for issue #297: in a daemon tracking several repos, the +// LSP workspace (which becomes the language server's working directory) was +// derived from the daemon process's own launch cwd rather than from the +// tracked repo's path, producing a directory that exists nowhere and an +// opaque `chdir ...` spawn failure. + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestWorkspaceRootFor_UsesTrackedRepoRoot — a file inside a tracked repo +// must resolve to that repo's recorded root, whatever directory the process +// is running from. +func TestWorkspaceRootFor_UsesTrackedRepoRoot(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + + for _, repo := range []string{fx.repoA, fx.repoB} { + root, err := fx.srv.workspaceRootFor(filepath.Join(repo, "main.go")) + require.NoError(t, err) + require.Equal(t, repo, root, "workspace must be the tracked repo root") + } +} + +// TestWorkspaceRootFor_RejectsPathUnderNoTrackedRepo — the reported failure +// shape. A path synthesised by joining a repo-relative fragment onto the +// daemon's launch cwd belongs to no tracked repo and names no real +// directory; it must be refused rather than handed to a spawn. +func TestWorkspaceRootFor_RejectsPathUnderNoTrackedRepo(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + + phantom := filepath.Join(t.TempDir(), "daemon-launch-cwd", "SomeProject", "SomeFolder", "Thing.cs") + _, err := fx.srv.workspaceRootFor(phantom) + require.Error(t, err, "a workspace under no tracked repo must not be invented") + require.Contains(t, err.Error(), phantom) +} + +// TestWorkspaceRootFor_AllowsRealUntrackedDirectory — an existing directory +// outside every tracked repo (a scratch file an editor opened) is still a +// legitimate workspace. Only phantom directories are refused. +func TestWorkspaceRootFor_AllowsRealUntrackedDirectory(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "scratch.go"), []byte("package x\n"), 0o644)) + + root, err := fx.srv.workspaceRootFor(filepath.Join(dir, "scratch.go")) + require.NoError(t, err) + require.Equal(t, dir, root) +} + +// TestWorkspaceRootFor_RespectsPathComponentBoundaries — the root /src/repo +// must not claim a file under a sibling /src/repo-old. A plain string-prefix +// containment check used to. +func TestWorkspaceRootFor_RespectsPathComponentBoundaries(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + + sibling := fx.repoB + "-old" + require.NoError(t, os.MkdirAll(sibling, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sibling, "main.go"), []byte("package main\n"), 0o644)) + + root, err := fx.srv.workspaceRootFor(filepath.Join(sibling, "main.go")) + require.NoError(t, err) + require.Equal(t, sibling, root, "a name-prefix sibling must not be folded into the tracked repo") +} + +// TestAbsolutePath_AnchorsUnprefixedPathOnTrackedRepo — a repo-relative +// fragment that carries no repo prefix must be anchored on a tracked repo, +// not joined onto the daemon's own working directory. +func TestAbsolutePath_AnchorsUnprefixedPathOnTrackedRepo(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + + // A file that exists in exactly one tracked repo, under a nested path — + // the shape the bug report redacted as "SomeProject\SomeFolder". + nested := filepath.Join(fx.repoB, "SomeProject", "SomeFolder") + require.NoError(t, os.MkdirAll(nested, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(nested, "Thing.cs"), []byte("class Thing {}\n"), 0o644)) + + got, err := fx.srv.absolutePath(filepath.Join("SomeProject", "SomeFolder", "Thing.cs")) + require.NoError(t, err) + require.Equal(t, filepath.Join(nested, "Thing.cs"), got) + + // And the workspace derived from it is repoB's root — the whole point. + root, err := fx.srv.workspaceRootFor(got) + require.NoError(t, err) + require.Equal(t, fx.repoB, root) +} + +// TestAbsolutePath_UnresolvableStaysOutOfTrackedRepos — a fragment that +// matches nothing on disk still yields something absolute for error +// reporting, but it must not be presented as belonging to a tracked repo. +func TestAbsolutePath_UnresolvableStaysOutOfTrackedRepos(t *testing.T) { + fx := newSharedWorkspaceServer(t, false) + + got, err := fx.srv.absolutePath(filepath.Join("NoSuchProject", "NoSuchFolder", "Nope.cs")) + require.NoError(t, err) + require.True(t, filepath.IsAbs(got)) + + // The downstream workspace derivation is what must refuse it. + _, werr := fx.srv.workspaceRootFor(got) + require.Error(t, werr) +} diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index daef44595..2dab004ca 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -2726,6 +2726,13 @@ func (p *Provider) Client() *Client { return p.client } // LSP server (idempotent) so callers that want diagnostics or code // actions outside an Enrich pass can prime the connection on demand. func (p *Provider) EnsureClient(workspaceRoot string) error { + // filepath.Abs("") is the process's working directory. For a daemon + // tracking many repos that is a directory belonging to none of them, so + // an omitted root must be an error rather than a server rooted wherever + // the daemon was launched. + if workspaceRoot == "" { + return fmt.Errorf("lsp %s: no workspace root supplied; pass the tracked repo's own path", p.command) + } abs, err := filepath.Abs(workspaceRoot) if err != nil { return err diff --git a/internal/semantic/lsp/router.go b/internal/semantic/lsp/router.go index 90b4c5e14..64e8cf7fe 100644 --- a/internal/semantic/lsp/router.go +++ b/internal/semantic/lsp/router.go @@ -32,6 +32,50 @@ func fileExists(path string) bool { return err == nil && !info.IsDir() } +// absWorkspace absolutises a workspace root, preserving the empty string. +// filepath.Abs("") returns the process's own working directory — for a +// daemon tracking many repos that is a directory belonging to none of +// them, and it must never silently become a language server's cwd. +func absWorkspace(workspace string) string { + if workspace == "" { + return "" + } + if abs, err := filepath.Abs(workspace); err == nil { + return abs + } + return workspace +} + +// validateWorkspaceRoot rejects a workspace that cannot serve as an LSP +// subprocess's working directory. The router hands this value to +// exec.Cmd.Dir, so an unusable one surfaces as an opaque +// `chdir : no such file or directory` from the OS long after the +// mistake was made. Checking here names the real problem — and lets the +// caller fail without tripping markSpawnFailed, which would disable the +// spec for every other repo the daemon tracks. +// +// requested is what the caller asked for (before absolutisation); +// resolved is what would actually be handed to exec. Both matter: a +// requested root that is merely relative is already wrong even when it +// happens to resolve, because it resolved against the daemon's launch +// directory rather than against the tracked repo. +func validateWorkspaceRoot(specName, requested, resolved string) error { + if requested == "" { + return fmt.Errorf("LSP server %q: no workspace root supplied; the workspace must be the tracked repo's own path", specName) + } + if !filepath.IsAbs(requested) { + return fmt.Errorf("LSP server %q: workspace %q is not absolute; a relative root resolves against the daemon's launch directory, not the tracked repo", specName, requested) + } + info, err := os.Stat(resolved) + if err != nil { + return fmt.Errorf("LSP server %q: workspace %s is not usable as a working directory: %w", specName, resolved, err) + } + if !info.IsDir() { + return fmt.Errorf("LSP server %q: workspace %s is not a directory", specName, resolved) + } + return nil +} + // 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 @@ -133,13 +177,17 @@ type providerKey struct { // 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. +// +// Pass "" when there is no single meaningful default (the multi-repo +// daemon case): an empty default stays empty, so a caller that omits the +// per-repo workspace gets a clear error instead of a server spawned in +// whatever directory the daemon happened to be launched from. func NewRouter(defaultWorkspace string, logger *zap.Logger) *Router { if logger == nil { logger = zap.NewNop() } - abs, _ := filepath.Abs(defaultWorkspace) return &Router{ - defaultWorkspace: abs, + defaultWorkspace: absWorkspace(defaultWorkspace), logger: logger, providers: make(map[providerKey]*routedProvider), enabled: make(map[string]*ServerSpec), @@ -399,10 +447,7 @@ 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} + return providerKey{specName: specName, workspace: absWorkspace(workspace)} } // ReleaseSpecWorkspace marks a provider previously obtained via @@ -477,9 +522,11 @@ func (r *Router) forSpecWorkspace(spec *ServerSpec, workspace string, pin bool) if workspace == "" { workspace = r.defaultWorkspace } - if abs, err := filepath.Abs(workspace); err == nil { - workspace = abs - } + // Keep what was asked for: validateWorkspaceRoot needs it to tell a + // genuine absolute repo root from a relative fragment that merely + // resolved against the daemon's launch directory. + requested := workspace + workspace = absWorkspace(workspace) key := providerKey{specName: spec.Name, workspace: workspace} r.mu.Lock() @@ -494,6 +541,16 @@ func (r *Router) forSpecWorkspace(spec *ServerSpec, workspace string, pin bool) } r.mu.Unlock() + // The workspace becomes the subprocess's working directory, so validate + // it before spawning. A caller that handed us a path anchored on the + // daemon's launch cwd instead of on the tracked repo's own root would + // otherwise fail deep inside exec with a bare chdir error — and would + // take the spec down for every repo via markSpawnFailed. Failing here + // keeps the fault local to the one bad workspace. + if err := validateWorkspaceRoot(spec.Name, requested, workspace); err != nil { + return nil, err + } + // Spawn outside the lock — initialize() blocks on stdio I/O. p := NewProviderFromSpec(spec, r.logger) p.workspaceFolders = r.additionalWorkspaceFolders diff --git a/internal/semantic/lsp/router_workspace_test.go b/internal/semantic/lsp/router_workspace_test.go new file mode 100644 index 000000000..c5a991963 --- /dev/null +++ b/internal/semantic/lsp/router_workspace_test.go @@ -0,0 +1,137 @@ +package lsp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" +) + +// markAvailable force-marks a spec live in the router's PATH-lookup cache so +// the spawn path can be exercised on a CI box with no language servers +// installed. +func markAvailable(r *Router, name string) { + r.availMu.Lock() + r.avail[name] = true + r.availMu.Unlock() +} + +// TestNewRouter_EmptyDefaultStaysEmpty — filepath.Abs("") returns the +// process's working directory. A daemon tracking many repos is normally +// launched from none of them, so an empty default must stay empty rather +// than silently becoming the launch cwd. +func TestNewRouter_EmptyDefaultStaysEmpty(t *testing.T) { + r := NewRouter("", zap.NewNop()) + defer r.Close() + if got := r.DefaultWorkspace(); got != "" { + cwd, _ := os.Getwd() + t.Fatalf("empty default workspace resolved to %q (cwd is %q); it must stay empty", got, cwd) + } +} + +// TestForSpecWorkspace_RejectsMissingWorkspace — the workspace becomes the +// subprocess's working directory, so a root that does not exist must fail +// with a diagnosable error before the spawn, not with a bare chdir error +// from exec. +func TestForSpecWorkspace_RejectsMissingWorkspace(t *testing.T) { + r := NewRouter("", zap.NewNop()) + defer r.Close() + + spec := &ServerSpec{Name: "fake-spec", Languages: []string{"csharp"}} + markAvailable(r, spec.Name) + + phantom := filepath.Join(t.TempDir(), "not-a-real-repo", "SomeProject") + _, err := r.ForSpecWorkspace(spec, phantom) + if err == nil { + t.Fatal("expected an error for a workspace that does not exist") + } + if !strings.Contains(err.Error(), phantom) { + t.Fatalf("error should name the offending workspace, got: %v", err) + } + if got := r.Names(); len(got) != 0 { + t.Fatalf("a rejected workspace must not land in the provider cache, got %v", got) + } +} + +// TestForSpecWorkspace_RejectedWorkspaceDoesNotPoisonSpec — a bad workspace +// is one repo's problem. It must not run through markSpawnFailed, which +// disables the spec for every other repo the daemon tracks for the rest of +// the session (issue #297: one mis-rooted query silently dropped C# +// enrichment daemon-wide). +func TestForSpecWorkspace_RejectedWorkspaceDoesNotPoisonSpec(t *testing.T) { + r := NewRouter("", zap.NewNop()) + defer r.Close() + + spec := &ServerSpec{Name: "fake-spec", Languages: []string{"csharp"}} + r.RegisterSpec(spec) + markAvailable(r, spec.Name) + + if _, err := r.ForSpecWorkspace(spec, filepath.Join(t.TempDir(), "gone")); err == nil { + t.Fatal("expected an error for a missing workspace") + } + if !r.SpecAvailable(spec.Name) { + t.Fatal("a per-workspace failure must leave the spec available for other repos") + } +} + +// TestForSpecWorkspace_RejectsRelativeWorkspace — a relative root would be +// resolved against the daemon's own launch directory, producing a path under +// a repo the daemon does not track. +func TestForSpecWorkspace_RejectsRelativeWorkspace(t *testing.T) { + r := NewRouter("", zap.NewNop()) + defer r.Close() + + spec := &ServerSpec{Name: "fake-spec", Languages: []string{"csharp"}} + markAvailable(r, spec.Name) + + // "." is the dangerous shape: it exists, so absolutising it succeeds — + // straight onto the daemon's launch directory. Only the relativeness + // check can catch it. + _, err := r.ForSpecWorkspace(spec, ".") + if err == nil { + t.Fatal("expected an error for a relative workspace that resolves against the process cwd") + } + if !strings.Contains(err.Error(), "not absolute") { + t.Fatalf("error should call out the relative root, got: %v", err) + } + if got := r.Names(); len(got) != 0 { + t.Fatalf("a rejected workspace must not land in the provider cache, got %v", got) + } +} + +// TestForSpecWorkspace_RejectsEmptyWorkspaceWithNoDefault — with no default +// workspace configured (the multi-repo daemon), omitting the per-repo root +// must fail loudly instead of falling back to the launch cwd. +func TestForSpecWorkspace_RejectsEmptyWorkspaceWithNoDefault(t *testing.T) { + r := NewRouter("", zap.NewNop()) + defer r.Close() + + spec := &ServerSpec{Name: "fake-spec", Languages: []string{"csharp"}} + markAvailable(r, spec.Name) + + if _, err := r.ForSpecWorkspace(spec, ""); err == nil { + t.Fatal("expected an error when neither an explicit nor a default workspace is set") + } +} + +// TestEnsureClient_RejectsEmptyWorkspace — Provider.EnsureClient absolutises +// its argument, and filepath.Abs("") is the process cwd. Reject instead. +func TestEnsureClient_RejectsEmptyWorkspace(t *testing.T) { + p := NewProvider("noop", nil, []string{"go"}, false, 1, zap.NewNop()) + if err := p.EnsureClient(""); err == nil { + t.Fatal("expected an error for an empty workspace root") + } +} + +// TestWorkspaceKey_EmptyStaysEmpty — the cache key helper must not turn an +// unset workspace into the process cwd either, or Release would miss the +// entry the spawn path refused to create. +func TestWorkspaceKey_EmptyStaysEmpty(t *testing.T) { + r := NewRouter("", zap.NewNop()) + defer r.Close() + if key := r.workspaceKey("fake-spec", ""); key.workspace != "" { + t.Fatalf("workspaceKey resolved an empty workspace to %q", key.workspace) + } +} diff --git a/internal/serverstack/shared_server.go b/internal/serverstack/shared_server.go index 94eaa87f6..9603e6b2e 100644 --- a/internal/serverstack/shared_server.go +++ b/internal/serverstack/shared_server.go @@ -356,11 +356,13 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { semMgr.RegisterProvider(tp) } - lspWorkspace := cfg.Index - if lspWorkspace == "" { - lspWorkspace, _ = os.Getwd() - } - lspRouter := lsp.NewRouter(lspWorkspace, logger). + // Only the embedded single-repo path (cfg.Index set) has one + // meaningful default workspace. The daemon tracks many repos and is + // routinely launched from none of them, so its default stays empty: + // every caller passes the tracked repo's own root per request, and + // one that forgets now fails loudly instead of spawning a language + // server in the daemon's launch directory. + lspRouter := lsp.NewRouter(cfg.Index, logger). WithIdleTimeout(10 * time.Minute). WithReaperInterval(time.Minute). WithMaxAlive(6).