Skip to content

fix(lsp): root language servers at the tracked repo, not the daemon cwd - #382

Merged
zzet merged 1 commit into
mainfrom
fix/lsp-workspace-from-tracked-repo-root
Jul 27, 2026
Merged

fix(lsp): root language servers at the tracked repo, not the daemon cwd#382
zzet merged 1 commit into
mainfrom
fix/lsp-workspace-from-tracked-repo-root

Conversation

@zzet

@zzet zzet commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Fixes #297.

Validation

Reproduced and confirmed. With the fix reverted, the new test resolves a repo-relative fragment to <process cwd>/SomeProject/SomeFolder/Thing.cs — exactly the shape reported (C:\Users\someuser\SomeProject\SomeFolder). The reporter's inference was right: a repo-relative path fragment gets joined onto the daemon's launch directory instead of the tracked repo's root.

Root cause

Three paths let the daemon's launch cwd leak into an LSP workspace, and that workspace becomes the language server subprocess's working directory (SpawnTransport.Start sets cmd.Dir).

  1. Server.absolutePath — for a repo-relative graph path the multi-indexer's prefix join couldn't claim, it fell through to filepath.Abs, which joins onto the process cwd.
  2. Server.workspaceRootFor — returned filepath.Dir of that phantom path unconditionally. It also matched the indexer root with a plain strings.HasPrefix, so /src/repo could claim a file under /src/repo-old.
  3. serverstack.NewSharedServer — seeded the router's default workspace from os.Getwd() whenever cfg.Index was empty, which is always in daemon mode. (filepath.Abs("") returns the cwd too, so NewRouter("") had the same problem.)

The spawn then failed with a bare chdir <phantom>: no such file or directory. markSpawnFailed treats that as "this binary can't launch" and disables the spec for every tracked repo for the rest of the session — which is why one mis-anchored query silently dropped C# enrichment daemon-wide, and why restarting the daemon inside the repo appeared to fix it.

Fix

  • absolutePath anchors an unclaimed relative path on a tracked repo root (stat-checked, sorted-prefix order for determinism) before considering filepath.Abs.
  • workspaceRootFor returns (string, error): it resolves the containing tracked repo on path-component boundaries (pathkey.HasPathPrefix), and refuses to invent a workspace from a directory that doesn't exist. An existing untracked directory (a scratch file an editor opened) is still accepted.
  • The daemon's router default workspace stays empty rather than becoming the launch cwd, so a caller that omits the per-repo root fails loudly instead of silently spawning in the wrong place. absWorkspace preserves the empty string through every absolutisation site.
  • Router.forSpecWorkspace validates the workspace before spawning — non-empty, absolute, an existing directory — so the failure is diagnosable and, critically, never reaches markSpawnFailed. A bad workspace stays one repo's problem.
  • Provider.EnsureClient rejects an empty workspace root for the same reason.

A daemon can now be started from anywhere; no repo needs to be its cwd.

Tests

internal/semantic/lsp/router_workspace_test.go and internal/mcp/tools_lsp_workspace_test.go — 13 new cases covering the tracked-root resolution, the phantom-path refusal, component-boundary containment, the empty/relative workspace guards, and the spec-not-poisoned invariant. The behavioural ones were confirmed to fail against the pre-fix code.

Verification

go build ./..., go vet ./..., golangci-lint run on the touched packages, and go test -race on internal/mcp, internal/semantic/..., internal/serverstack, internal/indexer — all clean.

Not addressed

markSpawnFailed still disables a spec daemon-wide for a genuine launch failure (missing runtime dependency, broken toolchain shim). That is documented as intentional, and the validation gate now keeps workspace-shaped failures out of it. Narrowing it further is a separate call.

A daemon tracking several repos is normally launched from none of them.
Three paths let that launch directory leak into an LSP workspace, which
becomes the language server subprocess's working directory:

- Server.absolutePath fell back to filepath.Abs for a repo-relative
  graph path the prefix join could not claim, joining the fragment onto
  the daemon's cwd and minting a path that exists nowhere.
- Server.workspaceRootFor then returned filepath.Dir of that phantom
  path, and matched the indexer root with a plain string prefix, so
  /src/repo could claim a file under /src/repo-old.
- The router's default workspace was seeded from os.Getwd() whenever
  cfg.Index was empty — always, in daemon mode.

The spawn failed with a bare `chdir <phantom>: no such file or
directory`, and markSpawnFailed then disabled that spec for every other
tracked repo for the rest of the session, so one mis-anchored query
silently dropped a whole language's enrichment daemon-wide.

absolutePath now anchors an unclaimed relative path on a tracked repo
root, workspaceRootFor resolves the containing tracked repo on
component boundaries and refuses to invent a workspace from a
nonexistent directory, and the daemon's router default stays empty so a
caller that omits the per-repo root fails loudly. The router validates
the workspace before spawning, keeping the failure local to the one bad
workspace instead of poisoning the spec.

Fixes #297
@zzet
zzet merged commit 34d1ab2 into main Jul 27, 2026
11 checks passed
@zzet
zzet deleted the fix/lsp-workspace-from-tracked-repo-root branch July 27, 2026 23:35
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.

bug: LSP router derives subprocess working directory from daemon launch cwd, not the tracked repo path (multi-repo daemon)

1 participant