on main help sign in
fix(lsp): replace sticky unavailable cache with retry backoff (#2498)

Scope unavailable tracking to each Manager instance and retry missing server commands after a short backoff so temporary environment changes do not permanently block LSP startup.

commitf669bd3634215a5e2b3feb8ef7872df0f56d28b8 authoriceymoss <114280774+iceymoss@users.noreply.github.com> dateFri 24 Apr 2026, 20:38 (6 months ago) parentda31815
2 files changed+73−14
internal/lsp/manager.go+38−14 internal/lsp/manager_test.go+35
internal/lsp/manager.go+38−14
@@ -21,14 +21,16 @@ import (
2121 "github.com/sourcegraph/jsonrpc2"
2222)
2323
24−var unavailable = csync.NewMap[string, struct{}]()
24+const unavailableRetryDelay = 30 * time.Second
2525
2626// Manager handles lazy initialization of LSP clients based on file types.
2727type Manager struct {
28− clients *csync.Map[string, *Client]
29− cfg *config.ConfigStore
30− manager *powernapconfig.Manager
31− callback func(name string, client *Client)
28+ clients *csync.Map[string, *Client]
29+ unavailable *csync.Map[string, time.Time]
30+ cfg *config.ConfigStore
31+ manager *powernapconfig.Manager
32+ callback func(name string, client *Client)
33+ now func() time.Time
3234}
3335
3436// NewManager creates a new LSP manager service.
@@ -59,10 +61,12 @@ func NewManager(cfg *config.ConfigStore) *Manager {
5961 }
6062
6163 return &Manager{
62− clients: csync.NewMap[string, *Client](),
63− cfg: cfg,
64− manager: manager,
65− callback: func(string, *Client) {}, // default no-op callback
64+ clients: csync.NewMap[string, *Client](),
65+ unavailable: csync.NewMap[string, time.Time](),
66+ cfg: cfg,
67+ manager: manager,
68+ callback: func(string, *Client) {}, // default no-op callback
69+ now: time.Now,
6670 }
6771}
6872
@@ -155,10 +159,6 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
155159 return
156160 }
157161
158− if _, exists := unavailable.Get(name); exists {
159− return
160− }
161−
162162 if client, ok := s.clients.Get(name); ok {
163163 switch client.GetServerState() {
164164 case StateReady, StateStarting, StateDisabled:
@@ -169,11 +169,15 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
169169 }
170170
171171 if !isUserConfigured {
172+ if s.recentlyUnavailable(name) {
173+ return
174+ }
172175 if _, err := exec.LookPath(server.Command); err != nil {
173176 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
174− unavailable.Set(name, struct{}{})
177+ s.markUnavailable(name)
175178 return
176179 }
180+ s.clearUnavailable(name)
177181 if skipAutoStartCommands[server.Command] {
178182 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
179183 return
@@ -255,6 +259,26 @@ func (s *Manager) isUserConfigured(name string) bool {
255259 return ok && !cfg.Disabled
256260}
257261
262+func (s *Manager) recentlyUnavailable(name string) bool {
263+ lastUnavailableAt, exists := s.unavailable.Get(name)
264+ if !exists {
265+ return false
266+ }
267+ if s.now().Sub(lastUnavailableAt) < unavailableRetryDelay {
268+ return true
269+ }
270+ s.unavailable.Del(name)
271+ return false
272+}
273+
274+func (s *Manager) markUnavailable(name string) {
275+ s.unavailable.Set(name, s.now())
276+}
277+
278+func (s *Manager) clearUnavailable(name string) {
279+ s.unavailable.Del(name)
280+}
281+
258282func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
259283 cfg := config.LSPConfig{
260284 Command: server.Command,
internal/lsp/manager_test.gonew file+35
@@ -0,0 +1,35 @@
1+package lsp
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/charmbracelet/crush/internal/csync"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestUnavailableBackoff(t *testing.T) {
12+ t.Parallel()
13+
14+ base := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC)
15+ now := base
16+
17+ manager := &Manager{
18+ unavailable: csync.NewMap[string, time.Time](),
19+ now: func() time.Time { return now },
20+ }
21+
22+ require.False(t, manager.recentlyUnavailable("gopls"))
23+
24+ manager.markUnavailable("gopls")
25+ require.True(t, manager.recentlyUnavailable("gopls"))
26+
27+ now = now.Add(unavailableRetryDelay + time.Second)
28+ require.False(t, manager.recentlyUnavailable("gopls"))
29+ _, exists := manager.unavailable.Get("gopls")
30+ require.False(t, exists)
31+
32+ manager.markUnavailable("gopls")
33+ manager.clearUnavailable("gopls")
34+ require.False(t, manager.recentlyUnavailable("gopls"))
35+}