Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/config/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 34 additions & 2 deletions internal/lsp/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"io"
"log/slog"
"math"
"os/exec"
"path/filepath"
"strings"
Expand All @@ -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)
Comment on lines +25 to +28

// Manager handles lazy initialization of LSP clients based on file types.
type Manager struct {
Expand All @@ -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.
Expand Down Expand Up @@ -60,13 +68,21 @@ 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)
}
}
Comment on lines +71 to +76
Comment on lines +71 to +76
Comment on lines +71 to +76
Comment on lines +71 to +76

return &Manager{
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,
unavailableRetry: retryDelay,
}
}

Expand All @@ -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
}
Comment on lines +103 to +106
return time.Duration(v) * time.Second
}
Comment on lines +94 to +108

// 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)) {
Expand Down Expand Up @@ -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)
Expand Down
100 changes: 96 additions & 4 deletions internal/lsp/manager_test.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,127 @@
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)
Comment on lines +38 to +39

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()

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 },
unavailable: csync.NewMap[string, time.Time](),
now: func() time.Time { return now },
unavailableRetry: defaultUnavailableRetryDelay,
}
Comment on lines 68 to 72

require.False(t, manager.recentlyUnavailable("gopls"))

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"))
Comment on lines +84 to 86
Comment on lines +84 to 86
_, 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"))
}
Loading