fix: request MaximizeOpenFileLimit for unix

Raphael Amorim created

Change summary

internal/lsp/watcher/global_watcher.go | 27 +++++++++++++
internal/lsp/watcher/rlimit_stub.go    | 12 +++++
internal/lsp/watcher/rlimit_unix.go    | 57 ++++++++++++++++++++++++++++
3 files changed, 96 insertions(+)

Detailed changes

internal/lsp/watcher/global_watcher.go 🔗

@@ -2,12 +2,14 @@ package watcher
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"log/slog"
 	"os"
 	"path/filepath"
 	"sync"
 	"sync/atomic"
+	"syscall"
 	"time"
 
 	"github.com/charmbracelet/crush/internal/config"
@@ -113,8 +115,24 @@ func Start() error {
 	events := notify.Create | notify.Write | notify.Remove | notify.Rename
 
 	if err := notify.Watch(watchPath, gw.events, events); err != nil {
+		// Check if the error might be due to file descriptor limits
+		if isFileLimitError(err) {
+			slog.Warn("lsp watcher: Hit file descriptor limit, attempting to increase", "error", err)
+			if newLimit, rlimitErr := MaximizeOpenFileLimit(); rlimitErr == nil {
+				slog.Info("lsp watcher: Increased file descriptor limit", "limit", newLimit)
+				// Retry the watch operation
+				if err = notify.Watch(watchPath, gw.events, events); err == nil {
+					slog.Info("lsp watcher: Successfully set up watch after increasing limit")
+					goto watchSuccess
+				}
+				err = fmt.Errorf("still failed after increasing limit: %w", err)
+			} else {
+				slog.Warn("lsp watcher: Failed to increase file descriptor limit", "error", rlimitErr)
+			}
+		}
 		return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err)
 	}
+watchSuccess:
 
 	slog.Info("lsp watcher: Started recursive watching", "root", root)
 	return nil
@@ -335,3 +353,12 @@ func (gw *global) shutdown() {
 func Shutdown() {
 	instance().shutdown()
 }
+
+// isFileLimitError checks if an error is related to file descriptor limits
+func isFileLimitError(err error) bool {
+	if err == nil {
+		return false
+	}
+	// Check for common file limit errors
+	return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE)
+}

internal/lsp/watcher/rlimit_stub.go 🔗

@@ -0,0 +1,12 @@
+//go:build !unix
+
+package watcher
+
+// MaximizeOpenFileLimit is a no-op on non-Unix systems.
+// Returns a high value to indicate no practical limit.
+func MaximizeOpenFileLimit() (int, error) {
+	// Windows and other non-Unix systems don't have file descriptor limits
+	// in the same way Unix systems do. Return a high value to indicate
+	// there's no practical limit to worry about.
+	return 1<<20, nil // 1M, effectively unlimited
+}

internal/lsp/watcher/rlimit_unix.go 🔗

@@ -0,0 +1,57 @@
+//go:build unix
+
+// This file contains code inspired by Syncthing's rlimit implementation
+// Syncthing is licensed under the Mozilla Public License Version 2.0
+// See: https://github.com/syncthing/syncthing/blob/main/LICENSE
+
+package watcher
+
+import (
+	"runtime"
+	"syscall"
+)
+
+const (
+	// macOS has a specific limit for RLIMIT_NOFILE
+	darwinOpenMax = 10240
+)
+
+// MaximizeOpenFileLimit tries to set the resource limit RLIMIT_NOFILE (number
+// of open file descriptors) to the max (hard limit), if the current (soft
+// limit) is below the max. Returns the new (though possibly unchanged) limit,
+// or an error if it could not be changed.
+func MaximizeOpenFileLimit() (int, error) {
+	// Get the current limit on number of open files.
+	var lim syscall.Rlimit
+	if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
+		return 0, err
+	}
+
+	// If we're already at max, there's no need to try to raise the limit.
+	if lim.Cur >= lim.Max {
+		return int(lim.Cur), nil
+	}
+
+	// macOS doesn't like a soft limit greater than OPEN_MAX
+	if runtime.GOOS == "darwin" && lim.Max > darwinOpenMax {
+		lim.Max = darwinOpenMax
+	}
+
+	// Try to increase the limit to the max.
+	oldLimit := lim.Cur
+	lim.Cur = lim.Max
+	if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
+		return int(oldLimit), err
+	}
+
+	// If the set succeeded, perform a new get to see what happened. We might
+	// have gotten a value lower than the one in lim.Max, if lim.Max was
+	// something that indicated "unlimited" (i.e. intmax).
+	if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
+		// We don't really know the correct value here since Getrlimit
+		// mysteriously failed after working once... Shouldn't ever happen.
+		return 0, err
+	}
+
+	return int(lim.Cur), nil
+}