From dcae323a11347da4aefec350b94613f5634c9943 Mon Sep 17 00:00:00 2001 From: gmit3 Date: Mon, 8 Jun 2026 17:55:05 +0200 Subject: [PATCH 1/7] Added LSPUnavailableRetryDelay option to configure period when LSPs will be checked again --- internal/config/config.go | 1 + internal/config/load.go | 3 +++ internal/lsp/manager.go | 12 ++++++++++-- internal/lsp/manager_test.go | 3 ++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fc3bab3302..0eae06604f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -277,6 +277,7 @@ 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 int `json:"lsp_unavailable_retry_delay,omitempty" jsonschema:"description=Seconds to wait before retrying an unavailable LSP server (0 to disable retry),default=999999,minimum=0"` 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..b356f858be 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -488,6 +488,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) { } c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs) + if c.Options.LSPUnavailableRetryDelay <= 0 { + c.Options.LSPUnavailableRetryDelay = 999999 + } } // applyLSPDefaults applies default values from powernap to LSP configurations diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index ebc914e27f..58ca160701 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -21,7 +21,8 @@ import ( "github.com/sourcegraph/jsonrpc2" ) -const unavailableRetryDelay = 30 * time.Second +// defaultUnavailableRetryDelay is the fallback when config doesn't specify one. +const defaultUnavailableRetryDelay = 999999 * time.Second // Manager handles lazy initialization of LSP clients based on file types. type Manager struct { @@ -31,6 +32,7 @@ type Manager struct { manager *powernapconfig.Manager callback func(name string, client *Client) now func() time.Time + unavailableRetry time.Duration } // NewManager creates a new LSP manager service. @@ -60,6 +62,11 @@ func NewManager(cfg *config.ConfigStore) *Manager { }) } + retryDelay := defaultUnavailableRetryDelay + if cfg != nil && cfg.Config() != nil && cfg.Config().Options != nil && cfg.Config().Options.LSPUnavailableRetryDelay > 0 { + retryDelay = time.Duration(cfg.Config().Options.LSPUnavailableRetryDelay) * time.Second + } + return &Manager{ clients: csync.NewMap[string, *Client](), unavailable: csync.NewMap[string, time.Time](), @@ -67,6 +74,7 @@ func NewManager(cfg *config.ConfigStore) *Manager { manager: manager, callback: func(string, *Client) {}, // default no-op callback now: time.Now, + unavailableRetry: retryDelay, } } @@ -264,7 +272,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..ec5a6bad31 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -17,6 +17,7 @@ func TestUnavailableBackoff(t *testing.T) { manager := &Manager{ unavailable: csync.NewMap[string, time.Time](), now: func() time.Time { return now }, + unavailableRetry: defaultUnavailableRetryDelay, } require.False(t, manager.recentlyUnavailable("gopls")) @@ -24,7 +25,7 @@ func TestUnavailableBackoff(t *testing.T) { manager.markUnavailable("gopls") require.True(t, manager.recentlyUnavailable("gopls")) - now = now.Add(unavailableRetryDelay + time.Second) + now = now.Add(defaultUnavailableRetryDelay + time.Second) require.False(t, manager.recentlyUnavailable("gopls")) _, exists := manager.unavailable.Get("gopls") require.False(t, exists) From 3af628f11d70e15ea0bba978c9542e4fb37a3df8 Mon Sep 17 00:00:00 2001 From: gmit3 Date: Mon, 8 Jun 2026 17:55:05 +0200 Subject: [PATCH 2/7] Added LSPUnavailableRetryDelay option to configure period when LSPs will be checked again --- internal/config/config.go | 1 + internal/config/load.go | 3 +++ internal/lsp/manager.go | 12 ++++++++++-- internal/lsp/manager_test.go | 3 ++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fc3bab3302..0eae06604f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -277,6 +277,7 @@ 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 int `json:"lsp_unavailable_retry_delay,omitempty" jsonschema:"description=Seconds to wait before retrying an unavailable LSP server (0 to disable retry),default=999999,minimum=0"` 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..b356f858be 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -488,6 +488,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) { } c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs) + if c.Options.LSPUnavailableRetryDelay <= 0 { + c.Options.LSPUnavailableRetryDelay = 999999 + } } // applyLSPDefaults applies default values from powernap to LSP configurations diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index ebc914e27f..58ca160701 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -21,7 +21,8 @@ import ( "github.com/sourcegraph/jsonrpc2" ) -const unavailableRetryDelay = 30 * time.Second +// defaultUnavailableRetryDelay is the fallback when config doesn't specify one. +const defaultUnavailableRetryDelay = 999999 * time.Second // Manager handles lazy initialization of LSP clients based on file types. type Manager struct { @@ -31,6 +32,7 @@ type Manager struct { manager *powernapconfig.Manager callback func(name string, client *Client) now func() time.Time + unavailableRetry time.Duration } // NewManager creates a new LSP manager service. @@ -60,6 +62,11 @@ func NewManager(cfg *config.ConfigStore) *Manager { }) } + retryDelay := defaultUnavailableRetryDelay + if cfg != nil && cfg.Config() != nil && cfg.Config().Options != nil && cfg.Config().Options.LSPUnavailableRetryDelay > 0 { + retryDelay = time.Duration(cfg.Config().Options.LSPUnavailableRetryDelay) * time.Second + } + return &Manager{ clients: csync.NewMap[string, *Client](), unavailable: csync.NewMap[string, time.Time](), @@ -67,6 +74,7 @@ func NewManager(cfg *config.ConfigStore) *Manager { manager: manager, callback: func(string, *Client) {}, // default no-op callback now: time.Now, + unavailableRetry: retryDelay, } } @@ -264,7 +272,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..ec5a6bad31 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -17,6 +17,7 @@ func TestUnavailableBackoff(t *testing.T) { manager := &Manager{ unavailable: csync.NewMap[string, time.Time](), now: func() time.Time { return now }, + unavailableRetry: defaultUnavailableRetryDelay, } require.False(t, manager.recentlyUnavailable("gopls")) @@ -24,7 +25,7 @@ func TestUnavailableBackoff(t *testing.T) { manager.markUnavailable("gopls") require.True(t, manager.recentlyUnavailable("gopls")) - now = now.Add(unavailableRetryDelay + time.Second) + now = now.Add(defaultUnavailableRetryDelay + time.Second) require.False(t, manager.recentlyUnavailable("gopls")) _, exists := manager.unavailable.Get("gopls") require.False(t, exists) From 0fff72cb08284e9490760dc381779f4704a445ac Mon Sep 17 00:00:00 2001 From: gmit3 Date: Tue, 9 Jun 2026 11:47:27 +0200 Subject: [PATCH 3/7] fixes asked by Copilot review --- internal/config/config.go | 7 ++++++- internal/config/load.go | 3 --- internal/lsp/manager.go | 4 ++-- internal/lsp/manager_test.go | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 0eae06604f..3da0abf041 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -277,7 +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 int `json:"lsp_unavailable_retry_delay,omitempty" jsonschema:"description=Seconds to wait before retrying an unavailable LSP server (0 to disable retry),default=999999,minimum=0"` + // LSPUnavailableRetryDelay is the number of seconds to wait before + // retrying an LSP server that was marked unavailable (e.g. binary not + // found). 0 means no backoff — the server is retried immediately on + // every file access. When unset (nil), the default is very large + // (effectively infinite). + LSPUnavailableRetryDelay *int `json:"lsp_unavailable_retry_delay,omitempty" jsonschema:"description=Seconds to wait before retrying an unavailable LSP server. 0 disables backoff and retries immediately. When unset, defaults to 999999,default=999999,minimum=0"` 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 b356f858be..2f0946e7bc 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -488,9 +488,6 @@ func (c *Config) setDefaults(workingDir, dataDir string) { } c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs) - if c.Options.LSPUnavailableRetryDelay <= 0 { - c.Options.LSPUnavailableRetryDelay = 999999 - } } // applyLSPDefaults applies default values from powernap to LSP configurations diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 58ca160701..1962251e86 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -63,8 +63,8 @@ func NewManager(cfg *config.ConfigStore) *Manager { } retryDelay := defaultUnavailableRetryDelay - if cfg != nil && cfg.Config() != nil && cfg.Config().Options != nil && cfg.Config().Options.LSPUnavailableRetryDelay > 0 { - retryDelay = time.Duration(cfg.Config().Options.LSPUnavailableRetryDelay) * time.Second + if cfg != nil && cfg.Config() != nil && cfg.Config().Options != nil && cfg.Config().Options.LSPUnavailableRetryDelay != nil { + retryDelay = time.Duration(*cfg.Config().Options.LSPUnavailableRetryDelay) * time.Second } return &Manager{ diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go index ec5a6bad31..c9c65c2731 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -34,3 +34,41 @@ func TestUnavailableBackoff(t *testing.T) { manager.clearUnavailable("gopls") require.False(t, manager.recentlyUnavailable("gopls")) } + +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") + require.False(t, manager.recentlyUnavailable("gopls")) +} From ad90971689eb107544daeb061186b1be7e21a067 Mon Sep 17 00:00:00 2001 From: gmit3 Date: Tue, 9 Jun 2026 14:42:10 +0200 Subject: [PATCH 4/7] -1 is infinite --- internal/config/config.go | 8 ++++---- internal/config/load.go | 2 ++ internal/lsp/manager.go | 13 ++++++++++--- internal/lsp/manager_test.go | 10 +++++----- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 3da0abf041..5eff1ec012 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -279,10 +279,10 @@ type Options struct { 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). 0 means no backoff — the server is retried immediately on - // every file access. When unset (nil), the default is very large - // (effectively infinite). - LSPUnavailableRetryDelay *int `json:"lsp_unavailable_retry_delay,omitempty" jsonschema:"description=Seconds to wait before retrying an unavailable LSP server. 0 disables backoff and retries immediately. When unset, defaults to 999999,default=999999,minimum=0"` + // found). -1 or unset means infinite backoff (never retry). 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 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/lsp/manager.go b/internal/lsp/manager.go index 1962251e86..f0fbf7990c 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,8 +22,10 @@ import ( "github.com/sourcegraph/jsonrpc2" ) -// defaultUnavailableRetryDelay is the fallback when config doesn't specify one. -const defaultUnavailableRetryDelay = 999999 * time.Second +// defaultUnavailableRetryDelay is used when the config leaves the field unset +// or explicitly sets it to a negative value. 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 { @@ -64,7 +67,11 @@ func NewManager(cfg *config.ConfigStore) *Manager { retryDelay := defaultUnavailableRetryDelay if cfg != nil && cfg.Config() != nil && cfg.Config().Options != nil && cfg.Config().Options.LSPUnavailableRetryDelay != nil { - retryDelay = time.Duration(*cfg.Config().Options.LSPUnavailableRetryDelay) * time.Second + val := *cfg.Config().Options.LSPUnavailableRetryDelay + if val >= 0 { + retryDelay = time.Duration(val) * time.Second + } + // val < 0 (including -1) means infinite backoff. } return &Manager{ diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go index c9c65c2731..4d457a4458 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -25,12 +25,12 @@ func TestUnavailableBackoff(t *testing.T) { manager.markUnavailable("gopls") require.True(t, manager.recentlyUnavailable("gopls")) - now = now.Add(defaultUnavailableRetryDelay + time.Second) - require.False(t, manager.recentlyUnavailable("gopls")) - _, exists := manager.unavailable.Get("gopls") - require.False(t, exists) + // 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")) - manager.markUnavailable("gopls") + // Clearing should make it available immediately. manager.clearUnavailable("gopls") require.False(t, manager.recentlyUnavailable("gopls")) } From 2d9e7ba5db97232b7dd884de4708029c14e1a018 Mon Sep 17 00:00:00 2001 From: gmit3 Date: Tue, 9 Jun 2026 14:55:51 +0200 Subject: [PATCH 5/7] fixes asked by Copilot review --- internal/lsp/manager.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index f0fbf7990c..3f7eb70e78 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -22,9 +22,9 @@ import ( "github.com/sourcegraph/jsonrpc2" ) -// defaultUnavailableRetryDelay is used when the config leaves the field unset -// or explicitly sets it to a negative value. It is math.MaxInt64 nanoseconds -// (~292 years), which is effectively infinite. +// 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. @@ -66,12 +66,18 @@ func NewManager(cfg *config.ConfigStore) *Manager { } retryDelay := defaultUnavailableRetryDelay - if cfg != nil && cfg.Config() != nil && cfg.Config().Options != nil && cfg.Config().Options.LSPUnavailableRetryDelay != nil { - val := *cfg.Config().Options.LSPUnavailableRetryDelay - if val >= 0 { - retryDelay = time.Duration(val) * time.Second + if cfg != nil { + if conf := cfg.Config(); conf != nil && conf.Options != nil && conf.Options.LSPUnavailableRetryDelay != nil { + val := *conf.Options.LSPUnavailableRetryDelay + if val >= 0 { + const maxSeconds = int(math.MaxInt64 / int64(time.Second)) + if val > maxSeconds { + val = maxSeconds + } + retryDelay = time.Duration(val) * time.Second + } + // val < 0 (including -1) means infinite backoff. } - // val < 0 (including -1) means infinite backoff. } return &Manager{ From 4ea11f8ebda86c28eb3b970230a86100e955ed53 Mon Sep 17 00:00:00 2001 From: gmit3 Date: Tue, 9 Jun 2026 15:17:30 +0200 Subject: [PATCH 6/7] fixes wanted by Copilot review --- internal/config/load_test.go | 8 ++++++ internal/lsp/manager.go | 31 ++++++++++++++++-------- internal/lsp/manager_test.go | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 10 deletions(-) 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 3f7eb70e78..8775a3c87a 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -35,6 +35,9 @@ 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 } @@ -67,16 +70,8 @@ func NewManager(cfg *config.ConfigStore) *Manager { retryDelay := defaultUnavailableRetryDelay if cfg != nil { - if conf := cfg.Config(); conf != nil && conf.Options != nil && conf.Options.LSPUnavailableRetryDelay != nil { - val := *conf.Options.LSPUnavailableRetryDelay - if val >= 0 { - const maxSeconds = int(math.MaxInt64 / int64(time.Second)) - if val > maxSeconds { - val = maxSeconds - } - retryDelay = time.Duration(val) * time.Second - } - // val < 0 (including -1) means infinite backoff. + if conf := cfg.Config(); conf != nil && conf.Options != nil { + retryDelay = parseUnavailableRetryDelay(conf.Options.LSPUnavailableRetryDelay) } } @@ -96,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 time.Duration(math.MaxInt64) + } + 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)) { diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go index 4d457a4458..2001e80ebc 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -1,13 +1,60 @@ package lsp import ( + "math" + "os" + "path/filepath" "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 + huge := int(math.MaxInt64) + + 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)) + require.Equal(t, time.Duration(math.MaxInt64), 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() From 1976ca2d14bd4aab2478056e535746337c9f10a4 Mon Sep 17 00:00:00 2001 From: gmit3 Date: Tue, 9 Jun 2026 15:37:40 +0200 Subject: [PATCH 7/7] fixed wanted by Copilot review --- internal/config/config.go | 8 ++++---- internal/lsp/manager.go | 2 +- internal/lsp/manager_test.go | 14 ++++++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5eff1ec012..76a2502013 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -279,10 +279,10 @@ type Options struct { 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 infinite backoff (never retry). 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 infinite backoff (default). 0 disables backoff and retries immediately. Positive values are seconds,default=-1,minimum=-1"` + // 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/lsp/manager.go b/internal/lsp/manager.go index 8775a3c87a..c6290d668c 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -102,7 +102,7 @@ func parseUnavailableRetryDelay(val *int) time.Duration { v := *val const maxSeconds = math.MaxInt64 / int64(time.Second) if int64(v) > maxSeconds { - return time.Duration(math.MaxInt64) + return defaultUnavailableRetryDelay } return time.Duration(v) * time.Second } diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go index 2001e80ebc..cdcb311548 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -4,6 +4,7 @@ import ( "math" "os" "path/filepath" + "strconv" "testing" "time" @@ -18,13 +19,16 @@ func TestParseUnavailableRetryDelay(t *testing.T) { negOne := -1 zero := 0 five := 5 - huge := int(math.MaxInt64) 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)) - require.Equal(t, time.Duration(math.MaxInt64), parseUnavailableRetryDelay(&huge)) + + if strconv.IntSize == 64 { + huge := int(math.MaxInt64) + require.Equal(t, defaultUnavailableRetryDelay, parseUnavailableRetryDelay(&huge)) + } } func TestNewManager_DefaultRetryDelay(t *testing.T) { @@ -62,8 +66,8 @@ 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, } @@ -80,6 +84,8 @@ func TestUnavailableBackoff(t *testing.T) { // 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) {