diff --git a/internal/config/config.go b/internal/config/config.go index fc3bab3302..76a2502013 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -277,6 +277,12 @@ type Options struct { DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"` + // LSPUnavailableRetryDelay is the number of seconds to wait before + // retrying an LSP server that was marked unavailable (e.g. binary not + // found). -1 or unset means effectively infinite backoff (~292 years). + // 0 means no backoff — the server is retried immediately on every file + // access. Positive values are interpreted as seconds. + LSPUnavailableRetryDelay *int `json:"lsp_unavailable_retry_delay,omitempty" jsonschema:"description=Seconds to wait before retrying an unavailable LSP server. -1 or unset means effectively infinite backoff (default). 0 disables backoff and retries immediately. Positive values are seconds,default=-1,minimum=-1"` Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"` DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Deprecated: Use notification_style instead. Disable desktop notifications,default=false"` NotificationStyle string `json:"notification_style,omitempty" jsonschema:"description=Notification style to use. Options: auto (default), native, osc, bell, disabled. Auto selects based on environment: native for local sessions, osc for SSH (with automatic OSC 99/777 detection).,enum=auto,enum=native,enum=osc,enum=bell,enum=disabled,default=auto"` diff --git a/internal/config/load.go b/internal/config/load.go index 2f0946e7bc..b114abeb25 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -488,6 +488,8 @@ func (c *Config) setDefaults(workingDir, dataDir string) { } c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs) + + assignIfNil(&c.Options.LSPUnavailableRetryDelay, -1) } // applyLSPDefaults applies default values from powernap to LSP configurations diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 9d67acaeec..c14648d881 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -297,6 +297,14 @@ func TestConfig_setDefaults(t *testing.T) { require.NoError(t, err) require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval) }) + + t.Run("sets default LSP unavailable retry delay to -1", func(t *testing.T) { + cfg := &Config{} + cfg.setDefaults(t.TempDir(), "") + + require.NotNil(t, cfg.Options.LSPUnavailableRetryDelay) + require.Equal(t, -1, *cfg.Options.LSPUnavailableRetryDelay) + }) } func TestConfig_configureProviders(t *testing.T) { diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index ebc914e27f..c6290d668c 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -7,6 +7,7 @@ import ( "errors" "io" "log/slog" + "math" "os/exec" "path/filepath" "strings" @@ -21,7 +22,10 @@ import ( "github.com/sourcegraph/jsonrpc2" ) -const unavailableRetryDelay = 30 * time.Second +// defaultUnavailableRetryDelay is the effective retry delay when the configured +// value is negative (including the -1 default set by setDefaults). It is +// math.MaxInt64 nanoseconds (~292 years), which is effectively infinite. +const defaultUnavailableRetryDelay = time.Duration(math.MaxInt64) // Manager handles lazy initialization of LSP clients based on file types. type Manager struct { @@ -31,6 +35,10 @@ type Manager struct { manager *powernapconfig.Manager callback func(name string, client *Client) now func() time.Time + // unavailableRetry controls how long before a previously-unavailable LSP + // server can be retried. 0 means no backoff (retry immediately), and the + // default (math.MaxInt64) means effectively infinite backoff. + unavailableRetry time.Duration } // NewManager creates a new LSP manager service. @@ -60,6 +68,13 @@ func NewManager(cfg *config.ConfigStore) *Manager { }) } + retryDelay := defaultUnavailableRetryDelay + if cfg != nil { + if conf := cfg.Config(); conf != nil && conf.Options != nil { + retryDelay = parseUnavailableRetryDelay(conf.Options.LSPUnavailableRetryDelay) + } + } + return &Manager{ clients: csync.NewMap[string, *Client](), unavailable: csync.NewMap[string, time.Time](), @@ -67,6 +82,7 @@ func NewManager(cfg *config.ConfigStore) *Manager { manager: manager, callback: func(string, *Client) {}, // default no-op callback now: time.Now, + unavailableRetry: retryDelay, } } @@ -75,6 +91,22 @@ func (s *Manager) Clients() *csync.Map[string, *Client] { return s.clients } +// parseUnavailableRetryDelay converts a config value (in seconds) to a +// time.Duration for the LSP unavailable backoff. nil or negative values mean +// infinite backoff (defaultUnavailableRetryDelay). Non-negative values are +// clamped to math.MaxInt64 to avoid overflow. +func parseUnavailableRetryDelay(val *int) time.Duration { + if val == nil || *val < 0 { + return defaultUnavailableRetryDelay + } + v := *val + const maxSeconds = math.MaxInt64 / int64(time.Second) + if int64(v) > maxSeconds { + return defaultUnavailableRetryDelay + } + return time.Duration(v) * time.Second +} + // SetCallback sets a callback that is invoked when a new LSP // client is successfully started. This allows the coordinator to add LSP tools. func (s *Manager) SetCallback(cb func(name string, client *Client)) { @@ -264,7 +296,7 @@ func (s *Manager) recentlyUnavailable(name string) bool { if !exists { return false } - if s.now().Sub(lastUnavailableAt) < unavailableRetryDelay { + if s.now().Sub(lastUnavailableAt) < s.unavailableRetry { return true } s.unavailable.Del(name) diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go index d5e65f4aa7..cdcb311548 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -1,13 +1,64 @@ package lsp import ( + "math" + "os" + "path/filepath" + "strconv" "testing" "time" + "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/stretchr/testify/require" ) +func TestParseUnavailableRetryDelay(t *testing.T) { + t.Parallel() + + negOne := -1 + zero := 0 + five := 5 + + require.Equal(t, defaultUnavailableRetryDelay, parseUnavailableRetryDelay(nil)) + require.Equal(t, defaultUnavailableRetryDelay, parseUnavailableRetryDelay(&negOne)) + require.Equal(t, time.Duration(0), parseUnavailableRetryDelay(&zero)) + require.Equal(t, 5*time.Second, parseUnavailableRetryDelay(&five)) + + if strconv.IntSize == 64 { + huge := int(math.MaxInt64) + require.Equal(t, defaultUnavailableRetryDelay, parseUnavailableRetryDelay(&huge)) + } +} + +func TestNewManager_DefaultRetryDelay(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + store, err := config.Load(dir, "", false) + require.NoError(t, err) + + m := NewManager(store) + require.Equal(t, defaultUnavailableRetryDelay, m.unavailableRetry) +} + +func TestNewManager_CustomRetryDelay(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "crush.json"), + []byte(`{"options":{"lsp_unavailable_retry_delay":5}}`), + 0o644, + )) + + store, err := config.Load(dir, "", false) + require.NoError(t, err) + + m := NewManager(store) + require.Equal(t, 5*time.Second, m.unavailableRetry) +} + func TestUnavailableBackoff(t *testing.T) { t.Parallel() @@ -15,8 +66,9 @@ func TestUnavailableBackoff(t *testing.T) { now := base manager := &Manager{ - unavailable: csync.NewMap[string, time.Time](), - now: func() time.Time { return now }, + unavailable: csync.NewMap[string, time.Time](), + now: func() time.Time { return now }, + unavailableRetry: defaultUnavailableRetryDelay, } require.False(t, manager.recentlyUnavailable("gopls")) @@ -24,12 +76,52 @@ func TestUnavailableBackoff(t *testing.T) { manager.markUnavailable("gopls") require.True(t, manager.recentlyUnavailable("gopls")) - now = now.Add(unavailableRetryDelay + time.Second) + // With the default infinite backoff, it should still be unavailable + // even after a very long time. + now = now.Add(time.Hour * 24 * 365 * 100) + require.True(t, manager.recentlyUnavailable("gopls")) + + // Clearing should make it available immediately. + manager.clearUnavailable("gopls") require.False(t, manager.recentlyUnavailable("gopls")) _, exists := manager.unavailable.Get("gopls") require.False(t, exists) +} + +func TestUnavailableBackoffCustomDelay(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 }, + unavailableRetry: 5 * time.Second, + } + + manager.markUnavailable("gopls") + require.True(t, manager.recentlyUnavailable("gopls")) + + now = now.Add(4 * time.Second) + require.True(t, manager.recentlyUnavailable("gopls")) + + now = now.Add(2 * time.Second) + require.False(t, manager.recentlyUnavailable("gopls")) +} + +func TestUnavailableBackoffZeroMeansNoBackoff(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 }, + unavailableRetry: 0, + } manager.markUnavailable("gopls") - manager.clearUnavailable("gopls") require.False(t, manager.recentlyUnavailable("gopls")) }