From 544f3b722a53d4fb72424c795f5dd70d196b6355 Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sat, 13 Sep 2025 21:03:10 +0200 Subject: [PATCH] feat: fix too many open files issue (#1033) * feat: fix too many open files issue * fix: go.sum * chore: cleanup go.mod --------- Co-authored-by: Christian Rocha Co-authored-by: kujtimiihoxha --- go.mod | 3 +- go.sum | 3 + internal/lsp/watcher/global_watcher.go | 237 +++++++++----------- internal/lsp/watcher/global_watcher_test.go | 143 ++++++------ 4 files changed, 184 insertions(+), 202 deletions(-) diff --git a/go.mod b/go.mod index c98a8dadf7eea28938015e8e82271527b2c5a5d8..ff4eab1623e5a73e91d403ad89dc7b40150112eb 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,6 @@ require ( github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec - github.com/fsnotify/fsnotify v1.9.0 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -85,6 +84,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -113,6 +113,7 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 + github.com/rjeczalik/notify v0.9.3 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.7.1 // indirect diff --git a/go.sum b/go.sum index 24bcee6f9da82ac0e9d380d7b21e048e1efc45b4..74a8425c0431ce926f07e0bb3c3bd7ac540367c7 100644 --- a/go.sum +++ b/go.sum @@ -237,6 +237,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= +github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -361,6 +363,7 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/lsp/watcher/global_watcher.go b/internal/lsp/watcher/global_watcher.go index 29b19f316ba0f654ae779526b5926b1fe9785819..0e60027daee654483f8fd7fb54a76587455ba5cf 100644 --- a/internal/lsp/watcher/global_watcher.go +++ b/internal/lsp/watcher/global_watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "sync" "sync/atomic" "time" @@ -13,22 +14,23 @@ import ( "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/lsp/protocol" - "github.com/fsnotify/fsnotify" + "github.com/rjeczalik/notify" ) -// global manages a single fsnotify.Watcher instance shared across all LSP clients. +// global manages file watching shared across all LSP clients. // -// IMPORTANT: This implementation only watches directories, not individual files. -// The fsnotify library automatically provides events for all files within watched -// directories, making this approach much more efficient than watching individual files. +// IMPORTANT: This implementation uses github.com/rjeczalik/notify which provides +// recursive watching on all platforms. On macOS it uses FSEvents, on Linux it +// uses inotify (with recursion handled by the library), and on Windows it uses +// ReadDirectoryChangesW. // -// Key benefits of directory-only watching: -// - Significantly fewer file descriptors used -// - Automatic coverage of new files created in watched directories -// - Better performance with large codebases -// - fsnotify handles deduplication internally (no need to track watched dirs) +// Key benefits: +// - Single watch point for entire directory tree +// - Automatic recursive watching without manually adding subdirectories +// - No file descriptor exhaustion issues type global struct { - watcher *fsnotify.Watcher + // Channel for receiving file system events + events chan notify.EventInfo // Map of workspace watchers by client name watchers *csync.Map[string, *Client] @@ -54,6 +56,7 @@ type global struct { var instance = sync.OnceValue(func() *global { ctx, cancel := context.WithCancel(context.Background()) gw := &global{ + events: make(chan notify.EventInfo, 4096), // Large buffer to prevent dropping events watchers: csync.NewMap[string, *Client](), debounceTime: 300 * time.Millisecond, debounceMap: csync.NewMap[string, *time.Timer](), @@ -61,15 +64,6 @@ var instance = sync.OnceValue(func() *global { cancel: cancel, } - // Initialize the fsnotify watcher - watcher, err := fsnotify.NewWatcher() - if err != nil { - slog.Error("lsp watcher: Failed to create global file watcher", "error", err) - return gw - } - - gw.watcher = watcher - return gw }) @@ -85,11 +79,11 @@ func (gw *global) unregister(name string) { slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name) } -// Start walks the given path and sets up the watcher on it. +// Start sets up recursive watching on the workspace root. // -// Note: We only watch directories, not individual files. fsnotify automatically provides -// events for all files within watched directories. Multiple calls with the same workspace -// are safe since fsnotify handles directory deduplication internally. +// Note: We use github.com/rjeczalik/notify which provides recursive watching +// with a single watch point. The "..." suffix means watch recursively. +// This is much more efficient than manually walking and watching each directory. func Start() error { gw := instance() @@ -107,59 +101,33 @@ func Start() error { gw.root = root gw.started.Store(true) - // Start the event processing goroutine now that we're initialized + // Start the event processing goroutine gw.wg.Add(1) go gw.processEvents() - // Walk the workspace and add only directories to the watcher - // fsnotify will automatically provide events for all files within these directories - // Multiple calls with the same directories are safe (fsnotify deduplicates) - err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - // Add directory to watcher (fsnotify handles deduplication automatically) - if err := gw.addDirectoryToWatcher(path); err != nil { - slog.Error("lsp watcher: Error watching directory", "path", path, "error", err) - } - - return nil - }) - if err != nil { - return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err) - } - - return nil -} + // Set up recursive watching on the root directory + // The "..." suffix tells notify to watch recursively + watchPath := filepath.Join(root, "...") -// addDirectoryToWatcher adds a directory to the fsnotify watcher. -// fsnotify handles deduplication internally, so we don't need to track watched directories. -func (gw *global) addDirectoryToWatcher(dirPath string) error { - if gw.watcher == nil { - return fmt.Errorf("lsp watcher: global watcher not initialized") - } + // Watch for all event types we care about + events := notify.Create | notify.Write | notify.Remove | notify.Rename - // Add directory to fsnotify watcher - fsnotify handles deduplication - // "A path can only be watched once; watching it more than once is a no-op" - err := gw.watcher.Add(dirPath) - if err != nil { - return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err) + if err := notify.Watch(watchPath, gw.events, events); err != nil { + return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err) } - slog.Debug("lsp watcher: watching directory", "path", dirPath) + slog.Info("lsp watcher: Started recursive watching", "root", root) return nil } -// processEvents processes file system events and handles them centrally. -// Since we only watch directories, we automatically get events for all files -// within those directories. When new directories are created, we add them -// to the watcher to ensure complete coverage. +// processEvents processes file system events from the notify library. +// Since notify handles recursive watching for us, we don't need to manually +// add new directories - they're automatically included. func (gw *global) processEvents() { defer gw.wg.Done() cfg := config.Get() - if gw.watcher == nil || !gw.started.Load() { + if !gw.started.Load() { slog.Error("lsp watcher: Global watcher not initialized") return } @@ -169,68 +137,89 @@ func (gw *global) processEvents() { case <-gw.ctx.Done(): return - case event, ok := <-gw.watcher.Events: + case event, ok := <-gw.events: if !ok { return } - // Handle directory creation globally (only once) - // When new directories are created, we need to add them to the watcher - // to ensure we get events for files created within them - if event.Op&fsnotify.Create != 0 { - if info, err := os.Stat(event.Name); err == nil && info.IsDir() { - if !fsext.ShouldExcludeFile(gw.root, event.Name) { - if err := gw.addDirectoryToWatcher(event.Name); err != nil { - slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err) - } - } else if cfg != nil && cfg.Options.DebugLSP { - slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name) - } - } + path := event.Path() + + // Skip ignored files + if fsext.ShouldExcludeFile(gw.root, path) { + continue } if cfg != nil && cfg.Options.DebugLSP { - slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String()) + slog.Debug("lsp watcher: Global watcher received event", "path", path, "event", event.Event().String()) } - // Process the event centrally + // Convert notify event to our internal format and handle it gw.handleFileEvent(event) - - case err, ok := <-gw.watcher.Errors: - if !ok { - return - } - slog.Error("lsp watcher: Global watcher error", "error", err) } } } // handleFileEvent processes a file system event and distributes notifications to relevant clients -func (gw *global) handleFileEvent(event fsnotify.Event) { +func (gw *global) handleFileEvent(event notify.EventInfo) { cfg := config.Get() - uri := string(protocol.URIFromPath(event.Name)) - - // Handle file creation for all relevant clients (only once) - if event.Op&fsnotify.Create != 0 { - if info, err := os.Stat(event.Name); err == nil && !info.IsDir() { - if !fsext.ShouldExcludeFile(gw.root, event.Name) { - gw.openMatchingFileForClients(event.Name) + path := event.Path() + uri := string(protocol.URIFromPath(path)) + + // Map notify events to our change types + var changeType protocol.FileChangeType + var watchKindNeeded protocol.WatchKind + + switch event.Event() { + case notify.Create: + changeType = protocol.FileChangeType(protocol.Created) + watchKindNeeded = protocol.WatchCreate + // Handle file creation for all relevant clients + if !isDir(path) && !fsext.ShouldExcludeFile(gw.root, path) { + gw.openMatchingFileForClients(path) + } + case notify.Write: + changeType = protocol.FileChangeType(protocol.Changed) + watchKindNeeded = protocol.WatchChange + case notify.Remove: + changeType = protocol.FileChangeType(protocol.Deleted) + watchKindNeeded = protocol.WatchDelete + case notify.Rename: + // Treat rename as delete + create + // First handle as delete + for _, watcher := range gw.watchers.Seq2() { + if !watcher.client.HandlesFile(path) { + continue } + if watched, watchKind := watcher.isPathWatched(path); watched { + if watchKind&protocol.WatchDelete != 0 { + gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted)) + } + } + } + // Then check if renamed file exists and treat as create + if !isDir(path) { + changeType = protocol.FileChangeType(protocol.Created) + watchKindNeeded = protocol.WatchCreate + } else { + return // Already handled delete, nothing more to do for directories } + default: + // Unknown event type, skip + return } // Process the event for each relevant client for client, watcher := range gw.watchers.Seq2() { - if !watcher.client.HandlesFile(event.Name) { + if !watcher.client.HandlesFile(path) { continue // client doesn't handle this filetype } // Debug logging per client if cfg.Options.DebugLSP { - matched, kind := watcher.isPathWatched(event.Name) + matched, kind := watcher.isPathWatched(path) slog.Debug("lsp watcher: File event for client", - "path", event.Name, - "operation", event.Op.String(), + "path", path, + "event", event.Event().String(), "watched", matched, "kind", kind, "client", client, @@ -238,46 +227,31 @@ func (gw *global) handleFileEvent(event fsnotify.Event) { } // Check if this path should be watched according to server registrations - if watched, watchKind := watcher.isPathWatched(event.Name); watched { - switch { - case event.Op&fsnotify.Write != 0: - if watchKind&protocol.WatchChange != 0 { - gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed)) - } - case event.Op&fsnotify.Create != 0: - // File creation was already handled globally above - // Just send the notification if needed - info, err := os.Stat(event.Name) - if err != nil { - if !os.IsNotExist(err) { - slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err) - } + if watched, watchKind := watcher.isPathWatched(path); watched { + if watchKind&watchKindNeeded != 0 { + // Skip directory events for non-delete operations + if changeType != protocol.FileChangeType(protocol.Deleted) && isDir(path) { continue } - if !info.IsDir() && watchKind&protocol.WatchCreate != 0 { - gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created)) - } - case event.Op&fsnotify.Remove != 0: - if watchKind&protocol.WatchDelete != 0 { - gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted)) - } - case event.Op&fsnotify.Rename != 0: - // For renames, first delete - if watchKind&protocol.WatchDelete != 0 { - gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted)) - } - // Then check if the new file exists and create an event - if info, err := os.Stat(event.Name); err == nil && !info.IsDir() { - if watchKind&protocol.WatchCreate != 0 { - gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created)) - } + if changeType == protocol.FileChangeType(protocol.Deleted) { + // Don't debounce deletes + gw.handleFileEventForClient(watcher, uri, changeType) + } else { + // Debounce creates and changes + gw.debounceHandleFileEventForClient(watcher, uri, changeType) } } } } } +// isDir checks if a path is a directory +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + // openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file) func (gw *global) openMatchingFileForClients(path string) { // Skip directories @@ -349,10 +323,9 @@ func (gw *global) shutdown() { gw.cancel() } - if gw.watcher != nil { - gw.watcher.Close() - gw.watcher = nil - } + // Stop watching and close the event channel + notify.Stop(gw.events) + close(gw.events) gw.wg.Wait() slog.Debug("lsp watcher: Global watcher shutdown complete") diff --git a/internal/lsp/watcher/global_watcher_test.go b/internal/lsp/watcher/global_watcher_test.go index 09124cd6a570b9b46b003b06b5f76dcbcbef22ff..cfbe8a51fb9be09fdfdc9f37be830c92d6b6eab8 100644 --- a/internal/lsp/watcher/global_watcher_test.go +++ b/internal/lsp/watcher/global_watcher_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/charmbracelet/crush/internal/csync" - "github.com/fsnotify/fsnotify" + "github.com/rjeczalik/notify" ) func TestGlobalWatcher(t *testing.T) { @@ -60,15 +60,8 @@ func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Create a real fsnotify watcher for testing - watcher, err := fsnotify.NewWatcher() - if err != nil { - t.Fatalf("Failed to create fsnotify watcher: %v", err) - } - defer watcher.Close() - gw := &global{ - watcher: watcher, + events: make(chan notify.EventInfo, 100), watchers: csync.NewMap[string, *Client](), debounceTime: 300 * time.Millisecond, debounceMap: csync.NewMap[string, *time.Timer](), @@ -77,26 +70,31 @@ func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) { } // Test that watching the same workspace multiple times is safe (idempotent) - err1 := gw.addDirectoryToWatcher(tempDir) + // With notify, we use recursive watching with "..." + watchPath := filepath.Join(tempDir, "...") + + err1 := notify.Watch(watchPath, gw.events, notify.All) if err1 != nil { - t.Fatalf("First addDirectoryToWatcher call failed: %v", err1) + t.Fatalf("First Watch call failed: %v", err1) } + defer notify.Stop(gw.events) - err2 := gw.addDirectoryToWatcher(tempDir) + // Watching the same path again should be safe (notify handles this) + err2 := notify.Watch(watchPath, gw.events, notify.All) if err2 != nil { - t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2) + t.Fatalf("Second Watch call failed: %v", err2) } - err3 := gw.addDirectoryToWatcher(tempDir) + err3 := notify.Watch(watchPath, gw.events, notify.All) if err3 != nil { - t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3) + t.Fatalf("Third Watch call failed: %v", err3) } - // All calls should succeed - fsnotify handles deduplication internally - // This test verifies that multiple WatchWorkspace calls are safe + // All calls should succeed - notify handles deduplication internally + // This test verifies that multiple Watch calls are safe } -func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) { +func TestGlobalWatcherRecursiveWatching(t *testing.T) { t.Parallel() // Create a temporary directory structure for testing @@ -120,29 +118,24 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Create a real fsnotify watcher for testing - watcher, err := fsnotify.NewWatcher() - if err != nil { - t.Fatalf("Failed to create fsnotify watcher: %v", err) - } - defer watcher.Close() - gw := &global{ - watcher: watcher, + events: make(chan notify.EventInfo, 100), watchers: csync.NewMap[string, *Client](), debounceTime: 300 * time.Millisecond, debounceMap: csync.NewMap[string, *time.Timer](), ctx: ctx, cancel: cancel, + root: tempDir, } - // Watch the workspace - err = gw.addDirectoryToWatcher(tempDir) - if err != nil { - t.Fatalf("addDirectoryToWatcher failed: %v", err) + // Set up recursive watching on the root directory + watchPath := filepath.Join(tempDir, "...") + if err := notify.Watch(watchPath, gw.events, notify.All); err != nil { + t.Fatalf("Failed to set up recursive watch: %v", err) } + defer notify.Stop(gw.events) - // Verify that our expected directories exist and can be watched + // Verify that our expected directories and files exist expectedDirs := []string{tempDir, subDir} for _, expectedDir := range expectedDirs { @@ -153,15 +146,9 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) { if !info.IsDir() { t.Fatalf("Expected %s to be a directory, but it's not", expectedDir) } - - // Try to add it again - fsnotify should handle this gracefully - err = gw.addDirectoryToWatcher(expectedDir) - if err != nil { - t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err) - } } - // Verify that files exist but we don't try to watch them directly + // Verify that files exist testFiles := []string{file1, file2} for _, file := range testFiles { info, err := os.Stat(file) @@ -172,39 +159,61 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) { t.Fatalf("Expected %s to be a file, but it's a directory", file) } } + + // Create a new file in the subdirectory to test recursive watching + newFile := filepath.Join(subDir, "new.txt") + if err := os.WriteFile(newFile, []byte("new content"), 0o644); err != nil { + t.Fatalf("Failed to create new file: %v", err) + } + + // We should receive an event for the file creation + select { + case event := <-gw.events: + // On macOS, paths might have /private prefix, so we need to compare the real paths + eventPath, _ := filepath.EvalSymlinks(event.Path()) + expectedPath, _ := filepath.EvalSymlinks(newFile) + if eventPath != expectedPath { + // Also try comparing just the base names as a fallback + if filepath.Base(event.Path()) != filepath.Base(newFile) { + t.Errorf("Expected event for %s, got %s", newFile, event.Path()) + } + } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for file creation event") + } } -func TestFsnotifyDeduplication(t *testing.T) { +func TestNotifyDeduplication(t *testing.T) { t.Parallel() // Create a temporary directory for testing tempDir := t.TempDir() - // Create a real fsnotify watcher - watcher, err := fsnotify.NewWatcher() - if err != nil { - t.Fatalf("Failed to create fsnotify watcher: %v", err) - } - defer watcher.Close() + // Create an event channel + events := make(chan notify.EventInfo, 100) + defer close(events) + + // Add the same directory multiple times with recursive watching + watchPath := filepath.Join(tempDir, "...") - // Add the same directory multiple times - err1 := watcher.Add(tempDir) + err1 := notify.Watch(watchPath, events, notify.All) if err1 != nil { - t.Fatalf("First Add failed: %v", err1) + t.Fatalf("First Watch failed: %v", err1) } + defer notify.Stop(events) - err2 := watcher.Add(tempDir) + err2 := notify.Watch(watchPath, events, notify.All) if err2 != nil { - t.Fatalf("Second Add failed: %v", err2) + t.Fatalf("Second Watch failed: %v", err2) } - err3 := watcher.Add(tempDir) + err3 := notify.Watch(watchPath, events, notify.All) if err3 != nil { - t.Fatalf("Third Add failed: %v", err3) + t.Fatalf("Third Watch failed: %v", err3) } - // All should succeed - fsnotify handles deduplication internally - // This test verifies the fsnotify behavior we're relying on + // All should succeed - notify handles deduplication internally + // This test verifies the notify behavior we're relying on } func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) { @@ -241,31 +250,26 @@ func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Create a real fsnotify watcher for testing - watcher, err := fsnotify.NewWatcher() - if err != nil { - t.Fatalf("Failed to create fsnotify watcher: %v", err) - } - defer watcher.Close() - gw := &global{ - watcher: watcher, + events: make(chan notify.EventInfo, 100), watchers: csync.NewMap[string, *Client](), debounceTime: 300 * time.Millisecond, debounceMap: csync.NewMap[string, *time.Timer](), ctx: ctx, cancel: cancel, + root: tempDir, } - // Watch the workspace - err = gw.addDirectoryToWatcher(tempDir) - if err != nil { - t.Fatalf("addDirectoryToWatcher failed: %v", err) + // Set up recursive watching + watchPath := filepath.Join(tempDir, "...") + if err := notify.Watch(watchPath, gw.events, notify.All); err != nil { + t.Fatalf("Failed to set up recursive watch: %v", err) } + defer notify.Stop(gw.events) - // This test verifies that the watcher can successfully add directories to fsnotify - // The actual ignore logic is tested in the fsext package - // Here we just verify that the watcher integration works + // The notify library watches everything, but our processEvents + // function should filter out ignored files using fsext.ShouldExcludeFile + // This test verifies that the structure is set up correctly } func TestGlobalWatcherShutdown(t *testing.T) { @@ -277,6 +281,7 @@ func TestGlobalWatcherShutdown(t *testing.T) { // Create a temporary global watcher for testing gw := &global{ + events: make(chan notify.EventInfo, 100), watchers: csync.NewMap[string, *Client](), debounceTime: 300 * time.Millisecond, debounceMap: csync.NewMap[string, *time.Timer](),