diff --git a/README.md b/README.md index 0a79d0a0..7ef38574 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,37 @@ Rebuild after making changes. Setting the `LOG_LEVEL` environment variable to DEBUG enables verbose logging to stderr for all components including messages to and from the language server and the language server's logs. +### Runtime Controls + +The server keeps existing behavior by default. You can opt in to additional runtime controls with CLI flags or env vars: + +- `--watcher-preopen-on-register` (default: `true`) + - Env: `WATCHER_PREOPEN_ON_REGISTER` or `MCP_WATCHER_PREOPEN_ON_REGISTER` +- `--watcher-preopen-max-files` (default: `0`, unlimited) + - Env: `WATCHER_PREOPEN_MAX_FILES` or `MCP_WATCHER_PREOPEN_MAX_FILES` +- `--idle-timeout` (default: `0s`, disabled) + - Env: `IDLE_TIMEOUT` or `MCP_IDLE_TIMEOUT` + +Codex-specific example (disable registration preopen and auto-clean idle servers): + +```json +{ + "mcpServers": { + "swift-lsp": { + "command": "/Users/you/.local/bin/mcp-language-server-codex", + "args": [ + "--workspace", + "/path/to/workspace", + "--lsp", + "sourcekit-lsp", + "--watcher-preopen-on-register=false", + "--idle-timeout=15m" + ] + } + } +} +``` + ### LSP interaction - `internal/lsp/methods.go` contains generated code to make calls to the connected language server. diff --git a/internal/lsp/client.go b/internal/lsp/client.go index fc07059d..f38ff12f 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -44,6 +44,12 @@ type Client struct { // Files are currently opened by the LSP openFiles map[string]*OpenFileInfo openFilesMu sync.RWMutex + + // Idempotent shutdown state + closeOnce sync.Once + closeErr error + waitOnce sync.Once + waitErr error } func NewClient(command string, args ...string) (*Client, error) { @@ -228,6 +234,14 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) ( } func (c *Client) Close() error { + c.closeOnce.Do(func() { + c.closeErr = c.closeInternal() + }) + + return c.closeErr +} + +func (c *Client) closeInternal() error { // Try to close all open files first ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -236,35 +250,42 @@ func (c *Client) Close() error { c.CloseAllFiles(ctx) // Force kill the LSP process if it doesn't exit within timeout - forcedKill := make(chan struct{}) + forcedKillDone := make(chan struct{}) go func() { select { case <-time.After(2 * time.Second): lspLogger.Warn("LSP process did not exit within timeout, forcing kill") - if c.Cmd.Process != nil { + if c.Cmd != nil && c.Cmd.Process != nil { if err := c.Cmd.Process.Kill(); err != nil { lspLogger.Error("Failed to kill process: %v", err) } else { lspLogger.Info("Process killed successfully") } } - close(forcedKill) - case <-forcedKill: + case <-forcedKillDone: // Channel closed from completion path return } }() // Close stdin to signal the server - if err := c.stdin.Close(); err != nil { - lspLogger.Error("Failed to close stdin: %v", err) + if c.stdin != nil { + if err := c.stdin.Close(); err != nil { + lspLogger.Error("Failed to close stdin: %v", err) + } } - // Wait for process to exit - err := c.Cmd.Wait() - close(forcedKill) // Stop the force kill goroutine + // Wait for process to exit once. + c.waitOnce.Do(func() { + if c.Cmd == nil { + return + } + c.waitErr = c.Cmd.Wait() + }) + + close(forcedKillDone) // Stop the force kill goroutine - return err + return c.waitErr } type ServerState int diff --git a/internal/lsp/client_close_test.go b/internal/lsp/client_close_test.go new file mode 100644 index 00000000..86f89557 --- /dev/null +++ b/internal/lsp/client_close_test.go @@ -0,0 +1,15 @@ +package lsp + +import "testing" + +func TestClientCloseIsIdempotent(t *testing.T) { + client := &Client{} + + if err := client.Close(); err != nil { + t.Fatalf("first close returned error: %v", err) + } + + if err := client.Close(); err != nil { + t.Fatalf("second close returned error: %v", err) + } +} diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index db05631a..d2e48fae 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -27,6 +27,12 @@ type WatcherConfig struct { // DebounceTime is the duration to wait before sending file change events DebounceTime time.Duration + // PreopenOnRegistration controls whether AddRegistrations should pre-open matching files. + PreopenOnRegistration bool + + // PreopenMaxFiles limits file pre-opening per registration (0 = unlimited). + PreopenMaxFiles int + // ExcludedDirs are directory names that should be excluded from watching ExcludedDirs map[string]bool @@ -43,7 +49,9 @@ type WatcherConfig struct { // DefaultWatcherConfig returns a configuration with sensible defaults func DefaultWatcherConfig() *WatcherConfig { return &WatcherConfig{ - DebounceTime: 300 * time.Millisecond, + DebounceTime: 300 * time.Millisecond, + PreopenOnRegistration: true, + PreopenMaxFiles: 0, ExcludedDirs: map[string]bool{ ".git": true, "node_modules": true, diff --git a/internal/watcher/testing/mock_client.go b/internal/watcher/testing/mock_client.go index abfd003d..38048d3f 100644 --- a/internal/watcher/testing/mock_client.go +++ b/internal/watcher/testing/mock_client.go @@ -19,10 +19,12 @@ type MockLSPClient struct { mu sync.Mutex events []FileEvent openedFiles map[string]bool + openCalls int openErrors map[string]error notifyErrors map[string]error changeErrors map[string]error eventsReceived chan struct{} + opensReceived chan struct{} } // NewMockLSPClient creates a new mock LSP client for testing @@ -34,6 +36,7 @@ func NewMockLSPClient() *MockLSPClient { notifyErrors: make(map[string]error), changeErrors: make(map[string]error), eventsReceived: make(chan struct{}, 100), // Buffer to avoid blocking + opensReceived: make(chan struct{}, 100), // Buffer to avoid blocking } } @@ -53,7 +56,16 @@ func (m *MockLSPClient) OpenFile(ctx context.Context, path string) error { return err } + m.openCalls++ m.openedFiles[path] = true + + // Signal that an open happened + select { + case m.opensReceived <- struct{}{}: + default: + // Channel is full, but we don't want to block + } + return nil } @@ -153,5 +165,31 @@ func (m *MockLSPClient) WaitForEvent(ctx context.Context) bool { } } +// OpenCallCount returns how many times OpenFile has been called. +func (m *MockLSPClient) OpenCallCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.openCalls +} + +// WaitForOpenCalls waits until at least min OpenFile calls have been observed. +func (m *MockLSPClient) WaitForOpenCalls(ctx context.Context, min int) bool { + for { + m.mu.Lock() + current := m.openCalls + m.mu.Unlock() + + if current >= min { + return true + } + + select { + case <-m.opensReceived: + case <-ctx.Done(): + return false + } + } +} + // Verify the MockLSPClient implements the watcher.LSPClient interface var _ watcher.LSPClient = (*MockLSPClient)(nil) diff --git a/internal/watcher/testing/watcher_test.go b/internal/watcher/testing/watcher_test.go index 269d8135..03d38600 100644 --- a/internal/watcher/testing/watcher_test.go +++ b/internal/watcher/testing/watcher_test.go @@ -433,3 +433,122 @@ func TestRapidChangesDebouncing(t *testing.T) { } }) } + +func TestRegistrationPreopenControls(t *testing.T) { + if os.Getenv("GITHUB_ACTIONS") == "true" { + t.Skip("Skipping filesystem watcher tests in GitHub Actions environment") + } + + t.Run("PreopenDisabledSkipsWorkspaceScan", func(t *testing.T) { + openCalls := runPreopenRegistrationScenario(t, func(config *watcher.WatcherConfig) { + config.PreopenOnRegistration = false + config.PreopenMaxFiles = 0 + }) + + if openCalls != 0 { + t.Fatalf("expected 0 opened files with preopen disabled, got %d", openCalls) + } + }) + + t.Run("PreopenEnabledOpensMatchingFiles", func(t *testing.T) { + openCalls := runPreopenRegistrationScenario(t, func(config *watcher.WatcherConfig) { + config.PreopenOnRegistration = true + config.PreopenMaxFiles = 0 + }) + + // Three .swift files should match the watcher pattern. + if openCalls != 3 { + t.Fatalf("expected 3 opened files with preopen enabled, got %d", openCalls) + } + }) + + t.Run("PreopenMaxFilesCapsOpenedFiles", func(t *testing.T) { + openCalls := runPreopenRegistrationScenario(t, func(config *watcher.WatcherConfig) { + config.PreopenOnRegistration = true + config.PreopenMaxFiles = 2 + }) + + if openCalls != 2 { + t.Fatalf("expected opened files to be capped at 2, got %d", openCalls) + } + }) +} + +func runPreopenRegistrationScenario(t *testing.T, configure func(*watcher.WatcherConfig)) int { + t.Helper() + + testDir, err := os.MkdirTemp("", "watcher-preopen-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { + if err := os.RemoveAll(testDir); err != nil { + t.Logf("failed to remove test directory: %v", err) + } + }() + + srcDir := filepath.Join(testDir, "src") + if err := os.MkdirAll(srcDir, 0755); err != nil { + t.Fatalf("failed to create src directory: %v", err) + } + + matchingFiles := []string{ + filepath.Join(srcDir, "Alpha.swift"), + filepath.Join(srcDir, "Beta.swift"), + filepath.Join(srcDir, "Gamma.swift"), + } + nonMatchingFiles := []string{ + filepath.Join(srcDir, "Notes.txt"), + } + + for _, path := range append(matchingFiles, nonMatchingFiles...) { + if err := os.WriteFile(path, []byte("test content"), 0644); err != nil { + t.Fatalf("failed to write test file %s: %v", path, err) + } + } + + mockClient := NewMockLSPClient() + config := watcher.DefaultWatcherConfig() + config.DebounceTime = 50 * time.Millisecond + configure(config) + + testWatcher := watcher.NewWorkspaceWatcherWithConfig(mockClient, config) + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + go testWatcher.WatchWorkspace(ctx, testDir) + time.Sleep(400 * time.Millisecond) + + watchers := []protocol.FileSystemWatcher{ + { + GlobPattern: protocol.GlobPattern{Value: "**/*.swift"}, + Kind: func() *protocol.WatchKind { + kind := protocol.WatchKind(protocol.WatchCreate | protocol.WatchChange | protocol.WatchDelete) + return &kind + }(), + }, + } + + testWatcher.AddRegistrations(ctx, "preopen-test", watchers) + + expectedOpenCalls := 0 + if config.PreopenOnRegistration { + expectedOpenCalls = len(matchingFiles) + if config.PreopenMaxFiles > 0 && config.PreopenMaxFiles < expectedOpenCalls { + expectedOpenCalls = config.PreopenMaxFiles + } + } + + if expectedOpenCalls > 0 { + waitCtx, waitCancel := context.WithTimeout(ctx, 3*time.Second) + defer waitCancel() + + if !mockClient.WaitForOpenCalls(waitCtx, expectedOpenCalls) { + t.Fatalf("timed out waiting for %d opened files, got %d", expectedOpenCalls, mockClient.OpenCallCount()) + } + } + + time.Sleep(200 * time.Millisecond) + return mockClient.OpenCallCount() +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 6e7a0b8a..46dc7455 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -2,6 +2,7 @@ package watcher import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -18,6 +19,8 @@ import ( // Create a logger for the watcher component var watcherLogger = logging.NewLogger(logging.Watcher) +var errPreopenLimitReached = errors.New("preopen limit reached") + // WorkspaceWatcher manages LSP file watching type WorkspaceWatcher struct { client LSPClient @@ -112,9 +115,14 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc } // Find and open all existing files that match the newly registered patterns - // TODO: not all language servers require this, but typescript does. Make this configurable + if !w.config.PreopenOnRegistration { + watcherLogger.Info("Pre-open on registration disabled; skipping workspace scan") + return + } + go func() { startTime := time.Now() + filesScanned := 0 filesOpened := 0 err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error { @@ -131,11 +139,17 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc } } else { // Process files - w.openMatchingFile(ctx, path) - filesOpened++ + filesScanned++ + if w.openMatchingFile(ctx, path) { + filesOpened++ + } + + if w.config.PreopenMaxFiles > 0 && filesOpened >= w.config.PreopenMaxFiles { + return errPreopenLimitReached + } // Add a small delay after every 100 files to prevent overwhelming the server - if filesOpened%100 == 0 { + if filesScanned%100 == 0 { time.Sleep(10 * time.Millisecond) } } @@ -143,9 +157,14 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc return nil }) + if errors.Is(err, errPreopenLimitReached) { + watcherLogger.Info("Workspace pre-open cap reached at %d files", filesOpened) + err = nil + } + elapsedTime := time.Since(startTime) - watcherLogger.Info("Workspace scan complete: processed %d files in %.2f seconds", - filesOpened, elapsedTime.Seconds()) + watcherLogger.Info("Workspace scan complete: scanned %d files, opened %d files in %.2f seconds", + filesScanned, filesOpened, elapsedTime.Seconds()) if err != nil { watcherLogger.Error("Error scanning workspace for files to open: %v", err) @@ -628,24 +647,35 @@ func (w *WorkspaceWatcher) shouldExcludeFile(filePath string) bool { return false } -// openMatchingFile opens a file if it matches any of the registered patterns -func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) { +// openMatchingFile opens a file if it matches any of the registered patterns. +// Returns true when a new file was successfully opened. +func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) bool { // Skip directories info, err := os.Stat(path) if err != nil || info.IsDir() { - return + return false } // Skip excluded files if w.shouldExcludeFile(path) { - return + return false } // Check if this path should be watched according to server registrations if watched, _ := w.isPathWatched(path); watched { - // Don't need to check if it's already open - the client.OpenFile handles that - if err := w.client.OpenFile(ctx, path); err != nil && watcherLogger.IsLevelEnabled(logging.LevelDebug) { - watcherLogger.Debug("Error opening file %s: %v", path, err) + if w.client.IsFileOpen(path) { + return false } + + if err := w.client.OpenFile(ctx, path); err != nil { + if watcherLogger.IsLevelEnabled(logging.LevelDebug) { + watcherLogger.Debug("Error opening file %s: %v", path, err) + } + return false + } + + return true } + + return false } diff --git a/main.go b/main.go index f6f3ed5c..856a1b1f 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,26 @@ package main import ( "context" + "errors" "flag" "fmt" + "io" + "log" "os" "os/exec" "os/signal" "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" "syscall" "time" "github.com/isaacphi/mcp-language-server/internal/logging" "github.com/isaacphi/mcp-language-server/internal/lsp" "github.com/isaacphi/mcp-language-server/internal/watcher" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -21,9 +29,12 @@ import ( var coreLogger = logging.NewLogger(logging.Core) type config struct { - workspaceDir string - lspCommand string - lspArgs []string + workspaceDir string + lspCommand string + lspArgs []string + watcherPreopenOnRegister bool + watcherPreopenMaxFiles int + idleTimeout time.Duration } type mcpServer struct { @@ -33,12 +44,22 @@ type mcpServer struct { ctx context.Context cancelFunc context.CancelFunc workspaceWatcher *watcher.WorkspaceWatcher + done chan struct{} + shutdownOnce sync.Once + lastActivityNs atomic.Int64 } func parseConfig() (*config, error) { + preopenDefault := getEnvBool(true, "WATCHER_PREOPEN_ON_REGISTER", "MCP_WATCHER_PREOPEN_ON_REGISTER") + preopenMaxDefault := getEnvInt(0, "WATCHER_PREOPEN_MAX_FILES", "MCP_WATCHER_PREOPEN_MAX_FILES") + idleTimeoutDefault := getEnvDuration(0, "IDLE_TIMEOUT", "MCP_IDLE_TIMEOUT") + cfg := &config{} flag.StringVar(&cfg.workspaceDir, "workspace", "", "Path to workspace directory") flag.StringVar(&cfg.lspCommand, "lsp", "", "LSP command to run (args should be passed after --)") + flag.BoolVar(&cfg.watcherPreopenOnRegister, "watcher-preopen-on-register", preopenDefault, "Whether watcher registration should pre-open matching files") + flag.IntVar(&cfg.watcherPreopenMaxFiles, "watcher-preopen-max-files", preopenMaxDefault, "Maximum files to pre-open on watcher registration (0 = unlimited)") + flag.DurationVar(&cfg.idleTimeout, "idle-timeout", idleTimeoutDefault, "Idle timeout for automatic shutdown (0 = disabled)") flag.Parse() // Get remaining args after -- as LSP arguments @@ -68,16 +89,27 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("LSP command not found: %s", cfg.lspCommand) } + if cfg.watcherPreopenMaxFiles < 0 { + return nil, fmt.Errorf("watcher-preopen-max-files must be >= 0") + } + + if cfg.idleTimeout < 0 { + return nil, fmt.Errorf("idle-timeout must be >= 0") + } + return cfg, nil } func newServer(config *config) (*mcpServer, error) { ctx, cancel := context.WithCancel(context.Background()) - return &mcpServer{ + s := &mcpServer{ config: *config, ctx: ctx, cancelFunc: cancel, - }, nil + done: make(chan struct{}), + } + s.touchActivity() + return s, nil } func (s *mcpServer) initializeLSP() error { @@ -90,7 +122,11 @@ func (s *mcpServer) initializeLSP() error { return fmt.Errorf("failed to create LSP client: %v", err) } s.lspClient = client - s.workspaceWatcher = watcher.NewWorkspaceWatcher(client) + + watcherConfig := watcher.DefaultWatcherConfig() + watcherConfig.PreopenOnRegistration = s.config.watcherPreopenOnRegister + watcherConfig.PreopenMaxFiles = s.config.watcherPreopenMaxFiles + s.workspaceWatcher = watcher.NewWorkspaceWatcherWithConfig(client, watcherConfig) initResult, err := client.InitializeLSPClient(s.ctx, s.config.workspaceDir) if err != nil { @@ -108,11 +144,17 @@ func (s *mcpServer) start() error { return err } + hooks := &server.Hooks{} + hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) { + s.touchActivity() + }) + s.mcpServer = server.NewMCPServer( "MCP Language Server", "v0.0.2", server.WithLogging(), server.WithRecovery(), + server.WithHooks(hooks), ) err := s.registerTools() @@ -120,22 +162,28 @@ func (s *mcpServer) start() error { return fmt.Errorf("tool registration failed: %v", err) } - return server.ServeStdio(s.mcpServer) + if s.config.idleTimeout > 0 { + coreLogger.Info("Idle timeout enabled: %s", s.config.idleTimeout) + go s.monitorIdleTimeout() + } + + stdioServer := server.NewStdioServer(s.mcpServer) + stdioServer.SetErrorLogger(log.New(os.Stderr, "", log.LstdFlags)) + return stdioServer.Listen(s.ctx, os.Stdin, os.Stdout) } func main() { coreLogger.Info("MCP Language Server starting") - done := make(chan struct{}) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - config, err := parseConfig() + cfg, err := parseConfig() if err != nil { coreLogger.Fatal("%v", err) } - server, err := newServer(config) + srv, err := newServer(cfg) if err != nil { coreLogger.Fatal("%v", err) } @@ -148,6 +196,9 @@ func main() { go func() { ppid := os.Getppid() coreLogger.Debug("Monitoring parent process: %d", ppid) + if ppid == 1 { + coreLogger.Warn("Server started with parent PID 1; parent-death monitoring may be ineffective") + } ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -161,7 +212,7 @@ func main() { close(parentDeath) return } - case <-done: + case <-srv.done: return } } @@ -172,74 +223,184 @@ func main() { select { case sig := <-sigChan: coreLogger.Info("Received signal %v in PID: %d", sig, os.Getpid()) - cleanup(server, done) + cleanup(srv) case <-parentDeath: coreLogger.Info("Parent death detected, initiating shutdown") - cleanup(server, done) + cleanup(srv) + case <-srv.done: + return } }() - if err := server.start(); err != nil { - coreLogger.Error("Server error: %v", err) - cleanup(server, done) - os.Exit(1) + exitCode := 0 + if err := srv.start(); err != nil { + if isClosed(srv.done) || errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) || srv.ctx.Err() != nil { + coreLogger.Info("Server exited during shutdown: %v", err) + } else { + coreLogger.Error("Server error: %v", err) + exitCode = 1 + } } - <-done + // Always run cleanup once the stdio server loop exits. This handles normal EOF + // (client disconnected) and prevents lingering processes waiting on srv.done. + cleanup(srv) + + <-srv.done coreLogger.Info("Server shutdown complete for PID: %d", os.Getpid()) - os.Exit(0) + os.Exit(exitCode) +} + +func (s *mcpServer) touchActivity() { + s.lastActivityNs.Store(time.Now().UnixNano()) +} + +func (s *mcpServer) monitorIdleTimeout() { + checkInterval := s.config.idleTimeout / 4 + if checkInterval < 5*time.Second { + checkInterval = 5 * time.Second + } + + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + last := time.Unix(0, s.lastActivityNs.Load()) + idleFor := time.Since(last) + if idleFor >= s.config.idleTimeout { + coreLogger.Warn("Idle timeout reached (%s >= %s), initiating shutdown", idleFor.Round(time.Second), s.config.idleTimeout) + cleanup(s) + return + } + case <-s.done: + return + case <-s.ctx.Done(): + return + } + } } -func cleanup(s *mcpServer, done chan struct{}) { - coreLogger.Info("Cleanup initiated for PID: %d", os.Getpid()) +func cleanup(s *mcpServer) { + s.shutdownOnce.Do(func() { + coreLogger.Info("Cleanup initiated for PID: %d", os.Getpid()) + + // Stop background goroutines tied to server context. + s.cancelFunc() + + // Close stdin so mcp-go's stdio loop unblocks and exits. + if err := os.Stdin.Close(); err != nil { + coreLogger.Debug("Failed to close stdin during cleanup: %v", err) + } + + // Create a context with timeout for shutdown operations. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() - // Create a context with timeout for shutdown operations - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + if s.lspClient != nil { + coreLogger.Info("Closing open files") + s.lspClient.CloseAllFiles(ctx) - if s.lspClient != nil { - coreLogger.Info("Closing open files") - s.lspClient.CloseAllFiles(ctx) + // Create a shorter timeout context for the shutdown request. + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer shutdownCancel() - // Create a shorter timeout context for the shutdown request - shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) - defer shutdownCancel() + // Run shutdown in a goroutine with timeout to avoid blocking if LSP doesn't respond. + shutdownDone := make(chan struct{}) + go func() { + coreLogger.Info("Sending shutdown request") + if err := s.lspClient.Shutdown(shutdownCtx); err != nil { + coreLogger.Error("Shutdown request failed: %v", err) + } + close(shutdownDone) + }() - // Run shutdown in a goroutine with timeout to avoid blocking if LSP doesn't respond - shutdownDone := make(chan struct{}) - go func() { - coreLogger.Info("Sending shutdown request") - if err := s.lspClient.Shutdown(shutdownCtx); err != nil { - coreLogger.Error("Shutdown request failed: %v", err) + select { + case <-shutdownDone: + coreLogger.Info("Shutdown request completed") + case <-time.After(1 * time.Second): + coreLogger.Warn("Shutdown request timed out, proceeding with exit") } - close(shutdownDone) - }() - // Wait for shutdown with timeout - select { - case <-shutdownDone: - coreLogger.Info("Shutdown request completed") - case <-time.After(1 * time.Second): - coreLogger.Warn("Shutdown request timed out, proceeding with exit") + coreLogger.Info("Sending exit notification") + if err := s.lspClient.Exit(ctx); err != nil { + coreLogger.Error("Exit notification failed: %v", err) + } + + coreLogger.Info("Closing LSP client") + if err := s.lspClient.Close(); err != nil { + coreLogger.Error("Failed to close LSP client: %v", err) + } + } + + close(s.done) + coreLogger.Info("Cleanup completed for PID: %d", os.Getpid()) + }) +} + +func isClosed(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +func getEnvBool(defaultValue bool, keys ...string) bool { + for _, key := range keys { + value, ok := os.LookupEnv(key) + if !ok { + continue + } + + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "t", "yes", "y", "on": + return true + case "0", "false", "f", "no", "n", "off": + return false + default: + coreLogger.Warn("Invalid boolean value %q for %s, using default %v", value, key, defaultValue) + return defaultValue } + } + + return defaultValue +} - coreLogger.Info("Sending exit notification") - if err := s.lspClient.Exit(ctx); err != nil { - coreLogger.Error("Exit notification failed: %v", err) +func getEnvInt(defaultValue int, keys ...string) int { + for _, key := range keys { + value, ok := os.LookupEnv(key) + if !ok { + continue } - coreLogger.Info("Closing LSP client") - if err := s.lspClient.Close(); err != nil { - coreLogger.Error("Failed to close LSP client: %v", err) + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + coreLogger.Warn("Invalid integer value %q for %s, using default %d", value, key, defaultValue) + return defaultValue } + return parsed } - // Send signal to the done channel - select { - case <-done: // Channel already closed - default: - close(done) + return defaultValue +} + +func getEnvDuration(defaultValue time.Duration, keys ...string) time.Duration { + for _, key := range keys { + value, ok := os.LookupEnv(key) + if !ok { + continue + } + + parsed, err := time.ParseDuration(strings.TrimSpace(value)) + if err != nil { + coreLogger.Warn("Invalid duration value %q for %s, using default %s", value, key, defaultValue) + return defaultValue + } + return parsed } - coreLogger.Info("Cleanup completed for PID: %d", os.Getpid()) + return defaultValue }