Merge pull request #604 from pbednarcik/feat/csharp-ls-solution

This commit is contained in:
Andrew Kumanyaev
2026-08-18 09:45:07 +02:00
committed by GitHub
10 changed files with 537 additions and 12 deletions
+11 -6
View File
@@ -168,9 +168,12 @@ The router applies these defaults in `cmd/gortex/server.go` and
| `WithMaxAlive` | 6 servers | LRU eviction kicks in when a seventh distinct server would spawn — the least-recently-used one closes. |
These defaults suit a polyglot workspace where most languages are
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.
touched only intermittently. The idle timeout is overridable at runtime:
`GORTEX_LSP_IDLE_TTL` accepts a Go duration (`45m`, `2h`); `0` or a
negative value disables reaping entirely. Useful when a long enrichment
window should keep a warm server around — a big Roslyn workspace is
expensive to reload. The reaper interval and max-alive bound are still
compile-time (`lsp.NewRouter(...).With...`).
### Workspace roots
@@ -400,8 +403,10 @@ Gortex applies two complementary, C#-scoped fixes, **both on by default**:
| Env var | Default | Effect |
| --- | --- | --- |
| `GORTEX_LSP_CSHARP_RESTORE` | on | Before spawning the C# server, run `dotnet restore -p:NuGetAudit=false` in the workspace so the MSBuild workspace loads every project (root-cause fix). Best-effort: a failure logs and falls through to a normal spawn; skipped on passive IDE attach and when `dotnet` is not on `PATH`. |
| `GORTEX_LSP_CSHARP_RESTORE` | on | Before spawning the C# server, run `dotnet restore <resolved solution> -p:NuGetAudit=false` in the workspace (falling back to a bare directory restore when no solution resolves) so the MSBuild workspace loads every project (root-cause fix). Best-effort: a failure logs and falls through to a normal spawn; skipped on passive IDE attach and when `dotnet` is not on `PATH`. |
| `GORTEX_LSP_CSHARP_DIAG_FILTER` | on | Strip diagnostics whose code is the `NU####` NuGet family from `publishDiagnostics` before storing / fanning out (symptom fix). Deliberately narrow — real `CS####` compiler diagnostics always pass through. |
| `GORTEX_LSP_CSHARP_SOLUTION` | unset | Solution csharp-ls loads and the pre-restore targets — a PATH-style list of `.sln`/`.slnx` paths (relative to a workspace root, or absolute inside one); each tracked root uses the first entry that resolves inside it, so one daemon-global value serves a multi-repo daemon. Unset auto-detects a lone root-level solution; a falsy value (`0` / `off` / `false` / `no` / `none`) disables injection and auto-detect, leaving csharp-ls's own recursive discovery untouched. The resolved pin and any ignored entries are logged at spawn. |
| `GORTEX_LSP_RESOLVER_CSHARP` | off | Opt-in: add csharp-ls to the resolve-time LSP helper for repos with C# intent (root-level `.sln`/`.slnx`/`.csproj`). Any set, non-falsy value enables (`1` / `true` / `on` / `yes`); off by default because a Roslyn workspace load costs real memory and startup time. |
Set either to a falsey value (`0` / `off` / `false` / `none`) to disable it —
e.g. `GORTEX_LSP_CSHARP_RESTORE=0` for offline / air-gapped indexing or to
@@ -440,8 +445,8 @@ for repositories you trust.
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.
next request re-spawned. Raise `GORTEX_LSP_IDLE_TTL` (Go duration;
`0` disables reaping) if this hurts warm-cache benchmarks.
- **High memory under polyglot load:** lower `WithMaxAlive` from 6 to
3-4. The LRU evicts the least-recent server transparently.
+165
View File
@@ -0,0 +1,165 @@
package lsp
import (
"os"
"path/filepath"
"strings"
)
// CSharpSolutionEnv names the solution file csharp-ls should load and the C#
// pre-restore should target — a PATH-style list of candidates (relative to
// the workspace root, or absolute inside it), each root using the first
// entry that resolves inside it. The variable is daemon-global while
// solutions are per-workspace, so per-root resolution is what makes one
// setting serve a multi-repo daemon: entries that do not resolve inside a
// given root are ignored for that root (and logged), and several repos with
// different solutions each pick their own entry.
//
// Empty (the default) auto-detects: a workspace root carrying exactly one
// root-level .sln/.slnx uses it; anything else keeps the server's own
// discovery. Current csharp-ls discovers on its own — recursive
// .sln/.slnx glob with a most-projects heuristic since 0.19.0, .slnx since
// 0.18.0, per-.csproj fallback — so the pin's value is determinism, a
// concrete target for the pre-spawn restore, and an operator override, not
// a capability the server lacks. An explicit falsy value
// (0/false/no/off/n/none) disables injection and auto-detect entirely,
// leaving the server's discovery untouched.
const CSharpSolutionEnv = "GORTEX_LSP_CSHARP_SOLUTION"
// isCSharpLSCommand reports whether a resolved LSP command is csharp-ls,
// whether configured bare, as a path, or with a Windows .exe suffix.
func isCSharpLSCommand(command string) bool {
return strings.TrimSuffix(filepath.Base(command), ".exe") == "csharp-ls"
}
// csharpSolutionEnvOff reports whether the env value is an explicit off
// switch — the IsFalsyEnv value vocabulary plus "none" for path-flavored
// configs.
func csharpSolutionEnvOff(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "0", "false", "no", "off", "n", "none":
return true
}
return false
}
// csharpSolutionChoice is a resolved solution pin plus the provenance
// callers log: how the solution was chosen and which env entries were
// dropped on the way. A silently ignored mis-spelled entry otherwise
// presents to the operator as an unpinned workspace with no explanation.
type csharpSolutionChoice struct {
solution string // argv spelling; "" = no pin
source string // "env" or "auto-detect" when solution != ""
ignored []string // env entries skipped for this root, with reason
}
// csharpSolutionResolution resolves the solution pin for a workspace root:
// the first CSharpSolutionEnv entry that names a file inside the root, else
// the single root-level .sln/.slnx when exactly one exists, else no pin.
// The returned spelling is what the argv carries — the env value as given,
// or the bare file name for an auto-detected root solution; both resolve
// against the server's working directory, which is the workspace root.
func csharpSolutionResolution(workspaceRoot string) csharpSolutionChoice {
raw := os.Getenv(CSharpSolutionEnv)
if csharpSolutionEnvOff(raw) {
return csharpSolutionChoice{}
}
var choice csharpSolutionChoice
for _, entry := range strings.Split(raw, string(os.PathListSeparator)) {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
abs := entry
if !filepath.IsAbs(abs) {
abs = filepath.Join(workspaceRoot, entry)
}
switch {
case !pathInsideRoot(workspaceRoot, abs):
choice.ignored = append(choice.ignored, entry+" (outside the workspace root)")
case !solutionFileExists(abs):
choice.ignored = append(choice.ignored, entry+" (no such file)")
default:
choice.solution = entry
choice.source = "env"
return choice
}
}
entries, err := os.ReadDir(workspaceRoot)
if err != nil {
return choice
}
found := ""
for _, e := range entries {
if e.IsDir() {
continue
}
switch strings.ToLower(filepath.Ext(e.Name())) {
case ".sln", ".slnx":
if found != "" {
return choice // several root solutions are ambiguous — no pick
}
found = e.Name()
}
}
if found != "" {
choice.solution = found
choice.source = "auto-detect"
}
return choice
}
// csharpSolutionFor is the pin without provenance — see
// csharpSolutionResolution.
func csharpSolutionFor(workspaceRoot string) string {
return csharpSolutionResolution(workspaceRoot).solution
}
// csharpSolutionArgs appends `--solution <file>` to a csharp-ls launch argv
// when a solution resolves for the workspace root and the caller has not
// already pinned one (bare or =-joined spelling). The explicit pin keeps
// the loaded workspace deterministic and names the same file the targeted
// pre-restore uses; csharp-ls's own discovery would otherwise re-choose
// recursively on every spawn.
func csharpSolutionArgs(baseArgs []string, workspaceRoot string) []string {
for _, a := range baseArgs {
if a == "--solution" || a == "-s" ||
strings.HasPrefix(a, "--solution=") || strings.HasPrefix(a, "-s=") {
return baseArgs
}
}
sln := csharpSolutionFor(workspaceRoot)
if sln == "" {
return baseArgs
}
out := append([]string(nil), baseArgs...)
return append(out, "--solution", sln)
}
// csharpRestoreArgs builds the pre-spawn `dotnet` argv: targeted at the
// resolved solution when one exists, else the bare directory restore. A bare
// restore fails with MSB1011 when the working directory is ambiguous about
// what to restore, so the audit-suppressed assets the workspace load needs
// are never written — targeting the same solution the server loads closes
// that hole.
func csharpRestoreArgs(workspaceRoot string) []string {
if sln := csharpSolutionFor(workspaceRoot); sln != "" {
return []string{"restore", sln, "-p:NuGetAudit=false"}
}
return []string{"restore", "-p:NuGetAudit=false"}
}
// pathInsideRoot reports whether path (absolute) lies under root.
func pathInsideRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// solutionFileExists reports whether path names an existing regular file.
func solutionFileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
@@ -0,0 +1,180 @@
package lsp
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeSolutionFile(t *testing.T, root string, rel string) string {
t.Helper()
path := filepath.Join(root, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte("solution"), 0o644))
return path
}
func TestIsCSharpLSCommand(t *testing.T) {
assert.True(t, isCSharpLSCommand("csharp-ls"))
assert.True(t, isCSharpLSCommand(filepath.Join("some", "tools", "csharp-ls")))
assert.True(t, isCSharpLSCommand("csharp-ls.exe"))
assert.False(t, isCSharpLSCommand("omnisharp"))
assert.False(t, isCSharpLSCommand("jdtls"))
}
func TestCSharpSolutionForEnvWinsWhenInsideRoot(t *testing.T) {
root := t.TempDir()
writeSolutionFile(t, root, filepath.Join("projects", "All.slnx"))
// Decoys: multiple root-level solutions must not matter when env resolves.
writeSolutionFile(t, root, "A.sln")
writeSolutionFile(t, root, "B.sln")
t.Setenv(CSharpSolutionEnv, filepath.Join("projects", "All.slnx"))
assert.Equal(t, filepath.Join("projects", "All.slnx"), csharpSolutionFor(root))
// Absolute spelling of a solution inside the root is used as-is.
abs := filepath.Join(root, "projects", "All.slnx")
t.Setenv(CSharpSolutionEnv, abs)
assert.Equal(t, abs, csharpSolutionFor(root))
}
func TestCSharpSolutionForEnvIgnoredOutsideRoot(t *testing.T) {
// A daemon-global env value must be per-workspace safe: a path that does
// not resolve inside THIS root is ignored and auto-detection takes over.
root := t.TempDir()
other := t.TempDir()
writeSolutionFile(t, root, "Probe.sln")
otherSln := writeSolutionFile(t, other, filepath.Join("projects", "All.slnx"))
t.Setenv(CSharpSolutionEnv, filepath.Join("projects", "All.slnx"))
assert.Equal(t, "Probe.sln", csharpSolutionFor(root),
"missing relative env path must fall through to auto-detect")
t.Setenv(CSharpSolutionEnv, otherSln)
assert.Equal(t, "Probe.sln", csharpSolutionFor(root),
"absolute env path outside the root must fall through to auto-detect")
}
func TestCSharpSolutionForListPinsPerRoot(t *testing.T) {
// One daemon-global value, several tracked repos: a path-list entry
// (PATH-style separator) pins each root via the first entry that
// resolves inside it.
rootA := t.TempDir()
rootB := t.TempDir()
writeSolutionFile(t, rootA, filepath.Join("projects", "All.slnx"))
writeSolutionFile(t, rootA, "Extra.sln") // multi-solution: no auto-pick
writeSolutionFile(t, rootB, filepath.Join("src", "Other.sln"))
writeSolutionFile(t, rootB, "Extra.sln")
t.Setenv(CSharpSolutionEnv,
filepath.Join("projects", "All.slnx")+string(os.PathListSeparator)+filepath.Join("src", "Other.sln"))
assert.Equal(t, filepath.Join("projects", "All.slnx"), csharpSolutionFor(rootA))
assert.Equal(t, filepath.Join("src", "Other.sln"), csharpSolutionFor(rootB))
// A root where no entry resolves still falls through to auto-detect.
rootC := t.TempDir()
writeSolutionFile(t, rootC, "Only.sln")
assert.Equal(t, "Only.sln", csharpSolutionFor(rootC))
}
func TestCSharpSolutionForAutoDetect(t *testing.T) {
t.Setenv(CSharpSolutionEnv, "")
single := t.TempDir()
writeSolutionFile(t, single, "Only.sln")
assert.Equal(t, "Only.sln", csharpSolutionFor(single))
slnx := t.TempDir()
writeSolutionFile(t, slnx, "Only.slnx")
assert.Equal(t, "Only.slnx", csharpSolutionFor(slnx))
multi := t.TempDir()
writeSolutionFile(t, multi, "A.sln")
writeSolutionFile(t, multi, "B.slnx")
assert.Equal(t, "", csharpSolutionFor(multi),
"several root solutions are ambiguous — no automatic pick")
nested := t.TempDir()
writeSolutionFile(t, nested, filepath.Join("sub", "Deep.sln"))
assert.Equal(t, "", csharpSolutionFor(nested),
"auto-detect scans the workspace root only")
assert.Equal(t, "", csharpSolutionFor(t.TempDir()))
}
func TestCSharpSolutionForOffSentinelDisablesPinning(t *testing.T) {
// One env var covers the whole feature: a path list pins, empty
// auto-detects, and an explicit falsy value switches injection AND
// auto-detect off so the server's own discovery runs untouched.
root := t.TempDir()
writeSolutionFile(t, root, "Only.sln")
for _, v := range []string{"off", "none", "0", "false", "no", "OFF"} {
t.Setenv(CSharpSolutionEnv, v)
assert.Equal(t, "", csharpSolutionFor(root), "sentinel %q must disable auto-detect", v)
}
}
func TestCSharpSolutionResolutionReportsSourceAndIgnored(t *testing.T) {
// Callers log how the solution was chosen and which env entries were
// dropped — a silently ignored mis-spelled entry otherwise presents
// as the exact symptom the pin exists to fix.
root := t.TempDir()
writeSolutionFile(t, root, "Only.sln")
t.Setenv(CSharpSolutionEnv, "")
choice := csharpSolutionResolution(root)
assert.Equal(t, "Only.sln", choice.solution)
assert.Equal(t, "auto-detect", choice.source)
assert.Empty(t, choice.ignored)
t.Setenv(CSharpSolutionEnv, "missing.sln"+string(os.PathListSeparator)+"Only.sln")
choice = csharpSolutionResolution(root)
assert.Equal(t, "Only.sln", choice.solution)
assert.Equal(t, "env", choice.source)
require.Len(t, choice.ignored, 1)
assert.Contains(t, choice.ignored[0], "missing.sln")
}
func TestCSharpSolutionArgs(t *testing.T) {
root := t.TempDir()
writeSolutionFile(t, root, "Only.sln")
t.Setenv(CSharpSolutionEnv, "")
assert.Equal(t, []string{"--solution", "Only.sln"}, csharpSolutionArgs(nil, root))
assert.Equal(t, []string{"--stdio", "--solution", "Only.sln"},
csharpSolutionArgs([]string{"--stdio"}, root))
// A caller-supplied solution wins, in every spelling — bare token
// and the =-joined form config args can carry.
explicit := []string{"--solution", "Custom.sln"}
assert.Equal(t, explicit, csharpSolutionArgs(explicit, root))
short := []string{"-s", "Custom.sln"}
assert.Equal(t, short, csharpSolutionArgs(short, root))
joined := []string{"--solution=Custom.sln"}
assert.Equal(t, joined, csharpSolutionArgs(joined, root))
shortJoined := []string{"-s=Custom.sln"}
assert.Equal(t, shortJoined, csharpSolutionArgs(shortJoined, root))
// No resolvable solution leaves the argv untouched.
empty := t.TempDir()
base := []string{"--stdio"}
assert.Equal(t, base, csharpSolutionArgs(base, empty))
}
func TestCSharpRestoreArgsTargetSolution(t *testing.T) {
root := t.TempDir()
t.Setenv(CSharpSolutionEnv, "")
assert.Equal(t, []string{"restore", "-p:NuGetAudit=false"}, csharpRestoreArgs(root),
"no solution keeps the bare directory restore")
writeSolutionFile(t, root, filepath.Join("projects", "All.slnx"))
writeSolutionFile(t, root, "A.sln") // multi-solution root: bare restore would fail MSB1011
t.Setenv(CSharpSolutionEnv, filepath.Join("projects", "All.slnx"))
assert.Equal(t,
[]string{"restore", filepath.Join("projects", "All.slnx"), "-p:NuGetAudit=false"},
csharpRestoreArgs(root),
"a resolved solution becomes the restore target")
}
@@ -0,0 +1,33 @@
package lsp
import (
"testing"
"time"
)
func TestIdleTimeoutFromEnv(t *testing.T) {
const fallback = 10 * time.Minute
t.Setenv(IdleTTLEnv, "")
if got := IdleTimeoutFromEnv(fallback); got != fallback {
t.Fatalf("unset: got %v, want fallback %v", got, fallback)
}
t.Setenv(IdleTTLEnv, "45m")
if got := IdleTimeoutFromEnv(fallback); got != 45*time.Minute {
t.Fatalf("45m: got %v", got)
}
// Zero (and negative, clamped to zero) disables the reaper — the
// router's idleTimeout <= 0 no-op.
t.Setenv(IdleTTLEnv, "0")
if got := IdleTimeoutFromEnv(fallback); got != 0 {
t.Fatalf("0: got %v, want 0", got)
}
t.Setenv(IdleTTLEnv, "-5m")
if got := IdleTimeoutFromEnv(fallback); got != 0 {
t.Fatalf("-5m: got %v, want 0", got)
}
t.Setenv(IdleTTLEnv, "not-a-duration")
if got := IdleTimeoutFromEnv(fallback); got != fallback {
t.Fatalf("unparseable: got %v, want fallback %v", got, fallback)
}
}
+29 -2
View File
@@ -1914,6 +1914,29 @@ func (p *Provider) dialOrSpawn(workspaceRoot string) (*Client, error) {
if isJdtlsCommand(p.command) {
args = jdtlsDataArgs(args, workspaceRoot)
}
// Pin the workspace's resolved solution (env or lone root .sln/.slnx)
// so the loaded workspace is deterministic and matches the targeted
// pre-restore — see csharpSolutionArgs. Log the choice and any dropped
// env entries: a silently ignored mis-spelling otherwise presents as an
// unpinned workspace with no explanation.
if isCSharpLSCommand(p.command) {
before := len(args)
args = csharpSolutionArgs(args, workspaceRoot)
if p.logger != nil {
choice := csharpSolutionResolution(workspaceRoot)
for _, entry := range choice.ignored {
p.logger.Warn("lsp: csharp solution env entry ignored",
zap.String("entry", entry),
zap.String("workspace", workspaceRoot))
}
if len(args) > before {
p.logger.Info("lsp: csharp solution pinned",
zap.String("solution", choice.solution),
zap.String("source", choice.source),
zap.String("workspace", workspaceRoot))
}
}
}
return NewClient(p.command, args, p.env, workspaceRoot, p.logger)
}
@@ -2100,7 +2123,10 @@ func (p *Provider) maybeCSharpPreRestore(workspaceRoot string) {
}
ctx, cancel := context.WithTimeout(context.Background(), csharpRestoreTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "dotnet", "restore", "-p:NuGetAudit=false")
// Target the same solution the server will load (when one resolves) — a
// bare restore fails with MSB1011 in a multi-solution root, leaving the
// audit-suppressed assets unwritten. See csharpRestoreArgs.
cmd := exec.CommandContext(ctx, "dotnet", csharpRestoreArgs(workspaceRoot)...)
platform.ConfigureBackgroundCommand(cmd)
cmd.Dir = workspaceRoot
cmd.Env = append(os.Environ(), p.env...)
@@ -2116,7 +2142,8 @@ func (p *Provider) maybeCSharpPreRestore(workspaceRoot string) {
}
if p.logger != nil {
p.logger.Info("lsp: csharp pre-restore complete (NuGetAudit suppressed)",
zap.String("workspace", workspaceRoot))
zap.String("workspace", workspaceRoot),
zap.String("solution", csharpSolutionFor(workspaceRoot)))
}
}
+8 -3
View File
@@ -461,9 +461,14 @@ var Servers = []ServerSpec{
Daemon: true,
MaxParallel: 6,
// csharp-ls is a Roslyn stdio LSP (`dotnet tool install csharp-ls`)
// that speaks plain LSP with no args and auto-discovers a .sln under
// the workspace root. It is far more commonly installed than
// OmniSharp on dev machines, so try it when omnisharp is not on PATH.
// that speaks plain LSP with no args. Current versions discover
// solutions on their own (recursive .sln/.slnx glob, most-projects
// heuristic since 0.19.0); gortex still pins the resolved solution
// at spawn via csharpSolutionArgs for determinism, to name the
// same file the targeted pre-restore uses, and as the operator
// override when the server's heuristic picks wrong. It is far more
// commonly installed than OmniSharp on dev machines, so try it when
// omnisharp is not on PATH.
AlternativeCommands: []ServerAlt{
{Command: "csharp-ls"},
},
+25
View File
@@ -362,6 +362,31 @@ func (r *Router) WithIdleTimeout(d time.Duration) *Router {
return r
}
// IdleTTLEnv overrides the router's provider idle timeout — a Go duration
// ("45m", "2h"); zero or negative disables reaping entirely. Exists because
// one timeout cannot fit every server class: a Roslyn or jdtls workspace can
// take longer to load than the default idle window, and a server reaped
// mid-load never becomes useful — every later pass pays the load again.
const IdleTTLEnv = "GORTEX_LSP_IDLE_TTL"
// IdleTimeoutFromEnv resolves the router idle timeout: the IdleTTLEnv
// duration when set and parseable (negative clamps to 0 = never reap),
// else fallback.
func IdleTimeoutFromEnv(fallback time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(IdleTTLEnv))
if raw == "" {
return fallback
}
d, err := time.ParseDuration(raw)
if err != nil {
return fallback
}
if d < 0 {
return 0
}
return d
}
// WithAdditionalWorkspaceFolders sets extra directory roots advertised
// to every LSP server's initialize request alongside the primary
// workspace root, enabling cross-package resolution. Builder-style.
+31
View File
@@ -91,6 +91,33 @@ func RepoLikelyHasPythonIntent(absRoot string) bool {
return false
}
// RepoLikelyHasCSharpIntent reports whether a repo root carries a root-level
// .NET project marker (a solution or project file), used to decide whether to
// wire the resolve-time C# helper for a tracked repo. Root-level only, like
// the TS/Python probes.
func RepoLikelyHasCSharpIntent(absRoot string) bool {
for _, pattern := range []string{"*.sln", "*.slnx", "*.csproj"} {
if matches, err := filepath.Glob(filepath.Join(absRoot, pattern)); err == nil && len(matches) > 0 {
return true
}
}
return false
}
// CSharpResolverEnv opts the resolve-time C# LSP helper in. OFF by default —
// unlike tsserver/pyright, the Roslyn workspace load behind csharp-ls costs
// minutes and hundreds of MB on a large solution, so repos only pay it when
// the operator asks for LSP-grade C# resolution ("1" / "true").
const CSharpResolverEnv = "GORTEX_LSP_RESOLVER_CSHARP"
// csharpResolverHelperEnabled reports whether the resolve-time C# helper may
// be wired for repos with C# intent.
func csharpResolverHelperEnabled() bool {
// Set-and-not-falsy, mirroring the sibling GORTEX_LSP_RESOLVER's
// value vocabulary — "on"/"yes" enable instead of failing silently.
return strings.TrimSpace(os.Getenv(CSharpResolverEnv)) != "" && !IsFalsyEnv(CSharpResolverEnv)
}
// BuildResolverLSPHelper constructs the resolve-time LSP helper for a
// workspace, choosing the router-cached lazy path (poolSize <= 1, reuses
// the router's idle reaper) or the fresh-spawn pool path (poolSize > 1,
@@ -144,6 +171,10 @@ func BuildResolverLSPHelperForRepo(router *lsp.Router, absRoot string, poolSize
add(preferredSpecName(router.Available, ".ts"), RepoLikelyHasTypeScriptIntent(absRoot))
add(preferredSpecName(router.Available, ".py"), RepoLikelyHasPythonIntent(absRoot))
// C# is opt-in (CSharpResolverEnv): the Roslyn workspace load behind the
// helper is far heavier than the TS/Python servers'.
add(preferredSpecName(router.Available, ".cs"),
csharpResolverHelperEnabled() && RepoLikelyHasCSharpIntent(absRoot))
return lsp.NewResolverHelperMux(helpers...), specs
}
+54
View File
@@ -60,3 +60,57 @@ func TestRepoLikelyHasPythonIntent_RootScript(t *testing.T) {
t.Fatalf("root-level .py file should mark a Python repo")
}
}
func TestRepoLikelyHasCSharpIntent(t *testing.T) {
dir := t.TempDir()
if RepoLikelyHasCSharpIntent(dir) {
t.Fatalf("empty temp dir should not look like a C# repo")
}
if err := os.WriteFile(filepath.Join(dir, "App.slnx"), []byte("<Solution/>\n"), 0o644); err != nil {
t.Fatal(err)
}
if !RepoLikelyHasCSharpIntent(dir) {
t.Fatalf("root-level .slnx should mark a C# repo")
}
proj := t.TempDir()
if err := os.WriteFile(filepath.Join(proj, "App.csproj"), []byte("<Project/>\n"), 0o644); err != nil {
t.Fatal(err)
}
if !RepoLikelyHasCSharpIntent(proj) {
t.Fatalf("root-level .csproj should mark a C# repo")
}
nested := t.TempDir()
if err := os.MkdirAll(filepath.Join(nested, "src"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "src", "App.sln"), []byte("sln\n"), 0o644); err != nil {
t.Fatal(err)
}
if RepoLikelyHasCSharpIntent(nested) {
t.Fatalf("intent probes are root-level only, like the TS/Python ones")
}
}
func TestCSharpResolverHelperOptIn(t *testing.T) {
t.Setenv(CSharpResolverEnv, "")
if csharpResolverHelperEnabled() {
t.Fatalf("resolve-time C# LSP must be opt-in: unset means disabled")
}
// Any set, non-falsy value enables — the same value vocabulary
// IsFalsyEnv uses, so "on"/"yes" behave like the sibling
// GORTEX_LSP_RESOLVER instead of being silently ignored.
for _, v := range []string{"1", "true", "TRUE", "on", "yes"} {
t.Setenv(CSharpResolverEnv, v)
if !csharpResolverHelperEnabled() {
t.Fatalf("%q should enable the resolve-time C# helper", v)
}
}
for _, v := range []string{"0", "false", "no", "off", "n"} {
t.Setenv(CSharpResolverEnv, v)
if csharpResolverHelperEnabled() {
t.Fatalf("%q should disable the resolve-time C# helper", v)
}
}
}
+1 -1
View File
@@ -377,7 +377,7 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) {
// 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).
WithIdleTimeout(lsp.IdleTimeoutFromEnv(10 * time.Minute)).
WithReaperInterval(time.Minute).
WithMaxAlive(6).
WithAdditionalWorkspaceFolders(conf.Semantic.AdditionalWorkspaceFolders).