From f669bd3634215a5e2b3feb8ef7872df0f56d28b8 Mon Sep 17 00:00:00 2001 From: iceymoss <114280774+iceymoss@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:38:01 +0800 Subject: [PATCH] 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. --- internal/lsp/manager.go | 52 ++++++++++++++++++++++++++---------- internal/lsp/manager_test.go | 35 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 internal/lsp/manager_test.go diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index a7e497331899a1377c002f892be005c71e2e86d5..ebc914e27f80bccceabbf08c4b86d171da71ad77 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -21,14 +21,16 @@ import ( "github.com/sourcegraph/jsonrpc2" ) -var unavailable = csync.NewMap[string, struct{}]() +const unavailableRetryDelay = 30 * time.Second // Manager handles lazy initialization of LSP clients based on file types. type Manager struct { - clients *csync.Map[string, *Client] - cfg *config.ConfigStore - manager *powernapconfig.Manager - callback func(name string, client *Client) + clients *csync.Map[string, *Client] + unavailable *csync.Map[string, time.Time] + cfg *config.ConfigStore + manager *powernapconfig.Manager + callback func(name string, client *Client) + now func() time.Time } // NewManager creates a new LSP manager service. @@ -59,10 +61,12 @@ func NewManager(cfg *config.ConfigStore) *Manager { } return &Manager{ - clients: csync.NewMap[string, *Client](), - cfg: cfg, - manager: manager, - callback: func(string, *Client) {}, // default no-op callback + clients: csync.NewMap[string, *Client](), + unavailable: csync.NewMap[string, time.Time](), + cfg: cfg, + manager: manager, + callback: func(string, *Client) {}, // default no-op callback + now: time.Now, } } @@ -155,10 +159,6 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server return } - if _, exists := unavailable.Get(name); exists { - return - } - if client, ok := s.clients.Get(name); ok { switch client.GetServerState() { case StateReady, StateStarting, StateDisabled: @@ -169,11 +169,15 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server } if !isUserConfigured { + if s.recentlyUnavailable(name) { + return + } if _, err := exec.LookPath(server.Command); err != nil { slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command) - unavailable.Set(name, struct{}{}) + s.markUnavailable(name) return } + s.clearUnavailable(name) if skipAutoStartCommands[server.Command] { slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command) return @@ -255,6 +259,26 @@ func (s *Manager) isUserConfigured(name string) bool { return ok && !cfg.Disabled } +func (s *Manager) recentlyUnavailable(name string) bool { + lastUnavailableAt, exists := s.unavailable.Get(name) + if !exists { + return false + } + if s.now().Sub(lastUnavailableAt) < unavailableRetryDelay { + return true + } + s.unavailable.Del(name) + return false +} + +func (s *Manager) markUnavailable(name string) { + s.unavailable.Set(name, s.now()) +} + +func (s *Manager) clearUnavailable(name string) { + s.unavailable.Del(name) +} + func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig { cfg := config.LSPConfig{ Command: server.Command, diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d5e65f4aa7d6cbc0628c49118baf96fb382a8d61 --- /dev/null +++ b/internal/lsp/manager_test.go @@ -0,0 +1,35 @@ +package lsp + +import ( + "testing" + "time" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/stretchr/testify/require" +) + +func TestUnavailableBackoff(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 3, 26, 0, 0, 0, 0, time.UTC) + now := base + + manager := &Manager{ + unavailable: csync.NewMap[string, time.Time](), + now: func() time.Time { return now }, + } + + require.False(t, manager.recentlyUnavailable("gopls")) + + manager.markUnavailable("gopls") + require.True(t, manager.recentlyUnavailable("gopls")) + + now = now.Add(unavailableRetryDelay + time.Second) + require.False(t, manager.recentlyUnavailable("gopls")) + _, exists := manager.unavailable.Get("gopls") + require.False(t, exists) + + manager.markUnavailable("gopls") + manager.clearUnavailable("gopls") + require.False(t, manager.recentlyUnavailable("gopls")) +}