on main help sign in
fix(lsp): replace sticky unavailable cache with retry backoff (#2498)
f669bd3iceymoss
internal/lsp/manager.go+38−14
@@ -21,14 +21,16 @@ import (
21 "github.com/sourcegraph/jsonrpc2"
21 "github.com/sourcegraph/jsonrpc2"
22)
22)
23
23
24−var unavailable = csync.NewMap[string, struct{}]()
24+const unavailableRetryDelay = 30 * time.Second
25
25
26// Manager handles lazy initialization of LSP clients based on file types.
26// Manager handles lazy initialization of LSP clients based on file types.
27type Manager struct {
27type Manager struct {
28− clients *csync.Map[string, *Client]
28+ clients *csync.Map[string, *Client]
29+ unavailable *csync.Map[string, time.Time]
29− cfg *config.ConfigStore
30+ cfg *config.ConfigStore
30− manager *powernapconfig.Manager
31+ manager *powernapconfig.Manager
31− callback func(name string, client *Client)
32+ callback func(name string, client *Client)
33+ now func() time.Time
32}
34}
33
35
34// NewManager creates a new LSP manager service.
36// NewManager creates a new LSP manager service.
@@ -59,10 +61,12 @@ func NewManager(cfg *config.ConfigStore) *Manager {
59 }
61 }
60
62
61 return &Manager{
63 return &Manager{
62− clients: csync.NewMap[string, *Client](),
64+ clients: csync.NewMap[string, *Client](),
65+ unavailable: csync.NewMap[string, time.Time](),
63− cfg: cfg,
66+ cfg: cfg,
64− manager: manager,
67+ manager: manager,
65− callback: func(string, *Client) {}, // default no-op callback
68+ callback: func(string, *Client) {}, // default no-op callback
69+ now: time.Now,
66 }
70 }
67}
71}
68
72
@@ -155,10 +159,6 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
155 return
159 return
156 }
160 }
157
161
158− if _, exists := unavailable.Get(name); exists {
159− return
160− }
161−
162 if client, ok := s.clients.Get(name); ok {
162 if client, ok := s.clients.Get(name); ok {
163 switch client.GetServerState() {
163 switch client.GetServerState() {
164 case StateReady, StateStarting, StateDisabled:
164 case StateReady, StateStarting, StateDisabled:
@@ -169,11 +169,15 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
169 }
169 }
170
170
171 if !isUserConfigured {
171 if !isUserConfigured {
172+ if s.recentlyUnavailable(name) {
173+ return
174+ }
172 if _, err := exec.LookPath(server.Command); err != nil {
175 if _, err := exec.LookPath(server.Command); err != nil {
173 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
176 slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command)
174− unavailable.Set(name, struct{}{})
177+ s.markUnavailable(name)
175 return
178 return
176 }
179 }
180+ s.clearUnavailable(name)
177 if skipAutoStartCommands[server.Command] {
181 if skipAutoStartCommands[server.Command] {
178 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
182 slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command)
179 return
183 return
@@ -255,6 +259,26 @@ func (s *Manager) isUserConfigured(name string) bool {
255 return ok && !cfg.Disabled
259 return ok && !cfg.Disabled
256}
260}
257
261
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+
258func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
282func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig {
259 cfg := config.LSPConfig{
283 cfg := config.LSPConfig{
260 Command: server.Command,
284 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+}