feat: fix too many open files issue (#1033)

Raphael Amorim , Christian Rocha , and kujtimiihoxha created

* feat: fix too many open files issue
* fix: go.sum
* chore: cleanup go.mod

---------

Co-authored-by: Christian Rocha <christian@rocha.is>
Co-authored-by: kujtimiihoxha <kujtimii.h@gmail.com>

Change summary

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

Detailed changes

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

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=

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

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