on main help sign in
feat: fix too many open files issue (#1033)
544f3b7Raphael Amorim
go.mod+2−1
@@ -23,7 +23,6 @@ require (
23 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
23 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
24 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
24 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
25 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
25 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
26− github.com/fsnotify/fsnotify v1.9.0
27 github.com/google/uuid v1.6.0
26 github.com/google/uuid v1.6.0
28 github.com/invopop/jsonschema v0.13.0
27 github.com/invopop/jsonschema v0.13.0
29 github.com/joho/godotenv v1.5.1
28 github.com/joho/godotenv v1.5.1
@@ -85,6 +84,7 @@ require (
85 github.com/dlclark/regexp2 v1.11.5 // indirect
84 github.com/dlclark/regexp2 v1.11.5 // indirect
86 github.com/dustin/go-humanize v1.0.1 // indirect
85 github.com/dustin/go-humanize v1.0.1 // indirect
87 github.com/felixge/httpsnoop v1.0.4 // indirect
86 github.com/felixge/httpsnoop v1.0.4 // indirect
87+ github.com/fsnotify/fsnotify v1.9.0 // indirect
88 github.com/go-logfmt/logfmt v0.6.0 // indirect
88 github.com/go-logfmt/logfmt v0.6.0 // indirect
89 github.com/go-logr/logr v1.4.3 // indirect
89 github.com/go-logr/logr v1.4.3 // indirect
90 github.com/go-logr/stdr v1.2.2 // indirect
90 github.com/go-logr/stdr v1.2.2 // indirect
@@ -113,6 +113,7 @@ require (
113 github.com/pierrec/lz4/v4 v4.1.22 // indirect
113 github.com/pierrec/lz4/v4 v4.1.22 // indirect
114 github.com/pmezard/go-difflib v1.0.0 // indirect
114 github.com/pmezard/go-difflib v1.0.0 // indirect
115 github.com/rivo/uniseg v0.4.7
115 github.com/rivo/uniseg v0.4.7
116+ github.com/rjeczalik/notify v0.9.3
116 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
117 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
117 github.com/sethvargo/go-retry v0.3.0 // indirect
118 github.com/sethvargo/go-retry v0.3.0 // indirect
118 github.com/spf13/cast v1.7.1 // indirect
119 github.com/spf13/cast v1.7.1 // indirect
go.sum+3
@@ -237,6 +237,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
237github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
237github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
238github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
238github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
239github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
239github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
240+github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
241+github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc=
240github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
242github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
241github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
243github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
242github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
244github.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=
361golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
363golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
362golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
364golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
363golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
365golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
366+golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
364golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
367golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
365golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
368golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
366golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
369golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/lsp/watcher/global_watcher.go+105−132
@@ -5,6 +5,7 @@ import (
5 "fmt"
5 "fmt"
6 "log/slog"
6 "log/slog"
7 "os"
7 "os"
8+ "path/filepath"
8 "sync"
9 "sync"
9 "sync/atomic"
10 "sync/atomic"
10 "time"
11 "time"
@@ -13,22 +14,23 @@ import (
13 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/lsp/protocol"
16 "github.com/charmbracelet/crush/internal/lsp/protocol"
16− "github.com/fsnotify/fsnotify"
17+ "github.com/rjeczalik/notify"
17)
18)
18
19
19−// global manages a single fsnotify.Watcher instance shared across all LSP clients.
20+// global manages file watching shared across all LSP clients.
20//
21//
21−// IMPORTANT: This implementation only watches directories, not individual files.
22+// IMPORTANT: This implementation uses github.com/rjeczalik/notify which provides
22−// The fsnotify library automatically provides events for all files within watched
23+// recursive watching on all platforms. On macOS it uses FSEvents, on Linux it
23−// directories, making this approach much more efficient than watching individual files.
24+// uses inotify (with recursion handled by the library), and on Windows it uses
25+// ReadDirectoryChangesW.
24//
26//
25−// Key benefits of directory-only watching:
27+// Key benefits:
26−// - Significantly fewer file descriptors used
28+// - Single watch point for entire directory tree
27−// - Automatic coverage of new files created in watched directories
29+// - Automatic recursive watching without manually adding subdirectories
28−// - Better performance with large codebases
30+// - No file descriptor exhaustion issues
29−// - fsnotify handles deduplication internally (no need to track watched dirs)
30type global struct {
31type global struct {
31− watcher *fsnotify.Watcher
32+ // Channel for receiving file system events
33+ events chan notify.EventInfo
32
34
33 // Map of workspace watchers by client name
35 // Map of workspace watchers by client name
34 watchers *csync.Map[string, *Client]
36 watchers *csync.Map[string, *Client]
@@ -54,6 +56,7 @@ type global struct {
54var instance = sync.OnceValue(func() *global {
56var instance = sync.OnceValue(func() *global {
55 ctx, cancel := context.WithCancel(context.Background())
57 ctx, cancel := context.WithCancel(context.Background())
56 gw := &global{
58 gw := &global{
59+ events: make(chan notify.EventInfo, 4096), // Large buffer to prevent dropping events
57 watchers: csync.NewMap[string, *Client](),
60 watchers: csync.NewMap[string, *Client](),
58 debounceTime: 300 * time.Millisecond,
61 debounceTime: 300 * time.Millisecond,
59 debounceMap: csync.NewMap[string, *time.Timer](),
62 debounceMap: csync.NewMap[string, *time.Timer](),
@@ -61,15 +64,6 @@ var instance = sync.OnceValue(func() *global {
61 cancel: cancel,
64 cancel: cancel,
62 }
65 }
63
66
64− // Initialize the fsnotify watcher
65− watcher, err := fsnotify.NewWatcher()
66− if err != nil {
67− slog.Error("lsp watcher: Failed to create global file watcher", "error", err)
68− return gw
69− }
70−
71− gw.watcher = watcher
72−
73 return gw
67 return gw
74})
68})
75
69
@@ -85,11 +79,11 @@ func (gw *global) unregister(name string) {
85 slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
79 slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
86}
80}
87
81
88−// Start walks the given path and sets up the watcher on it.
82+// Start sets up recursive watching on the workspace root.
89//
83//
90−// Note: We only watch directories, not individual files. fsnotify automatically provides
84+// Note: We use github.com/rjeczalik/notify which provides recursive watching
91−// events for all files within watched directories. Multiple calls with the same workspace
85+// with a single watch point. The "..." suffix means watch recursively.
92−// are safe since fsnotify handles directory deduplication internally.
86+// This is much more efficient than manually walking and watching each directory.
93func Start() error {
87func Start() error {
94 gw := instance()
88 gw := instance()
95
89
@@ -107,59 +101,33 @@ func Start() error {
107 gw.root = root
101 gw.root = root
108 gw.started.Store(true)
102 gw.started.Store(true)
109
103
110− // Start the event processing goroutine now that we're initialized
104+ // Start the event processing goroutine
111 gw.wg.Add(1)
105 gw.wg.Add(1)
112 go gw.processEvents()
106 go gw.processEvents()
113
107
114− // Walk the workspace and add only directories to the watcher
108+ // Set up recursive watching on the root directory
115− // fsnotify will automatically provide events for all files within these directories
109+ // The "..." suffix tells notify to watch recursively
116− // Multiple calls with the same directories are safe (fsnotify deduplicates)
110+ watchPath := filepath.Join(root, "...")
117− err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error {
118− if err != nil {
119− return err
120− }
121−
122− // Add directory to watcher (fsnotify handles deduplication automatically)
123− if err := gw.addDirectoryToWatcher(path); err != nil {
124− slog.Error("lsp watcher: Error watching directory", "path", path, "error", err)
125− }
126−
127− return nil
128− })
129− if err != nil {
130− return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err)
131− }
132−
133− return nil
134−}
135
111
136−// addDirectoryToWatcher adds a directory to the fsnotify watcher.
112+ // Watch for all event types we care about
137−// fsnotify handles deduplication internally, so we don't need to track watched directories.
113+ events := notify.Create | notify.Write | notify.Remove | notify.Rename
138−func (gw *global) addDirectoryToWatcher(dirPath string) error {
139− if gw.watcher == nil {
140− return fmt.Errorf("lsp watcher: global watcher not initialized")
141− }
142
114
143− // Add directory to fsnotify watcher - fsnotify handles deduplication
115+ if err := notify.Watch(watchPath, gw.events, events); err != nil {
144− // "A path can only be watched once; watching it more than once is a no-op"
145− err := gw.watcher.Add(dirPath)
146− if err != nil {
147− return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err)
116+ return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err)
148 }
117 }
149
118
150− slog.Debug("lsp watcher: watching directory", "path", dirPath)
119+ slog.Info("lsp watcher: Started recursive watching", "root", root)
151 return nil
120 return nil
152}
121}
153
122
154−// processEvents processes file system events and handles them centrally.
123+// processEvents processes file system events from the notify library.
155−// Since we only watch directories, we automatically get events for all files
124+// Since notify handles recursive watching for us, we don't need to manually
156−// within those directories. When new directories are created, we add them
125+// add new directories - they're automatically included.
157−// to the watcher to ensure complete coverage.
158func (gw *global) processEvents() {
126func (gw *global) processEvents() {
159 defer gw.wg.Done()
127 defer gw.wg.Done()
160 cfg := config.Get()
128 cfg := config.Get()
161
129
162− if gw.watcher == nil || !gw.started.Load() {
130+ if !gw.started.Load() {
163 slog.Error("lsp watcher: Global watcher not initialized")
131 slog.Error("lsp watcher: Global watcher not initialized")
164 return
132 return
165 }
133 }
@@ -169,68 +137,89 @@ func (gw *global) processEvents() {
169 case <-gw.ctx.Done():
137 case <-gw.ctx.Done():
170 return
138 return
171
139
172− case event, ok := <-gw.watcher.Events:
140+ case event, ok := <-gw.events:
173 if !ok {
141 if !ok {
174 return
142 return
175 }
143 }
176
144
177− // Handle directory creation globally (only once)
145+ path := event.Path()
178− // When new directories are created, we need to add them to the watcher
146+
179− // to ensure we get events for files created within them
147+ // Skip ignored files
180− if event.Op&fsnotify.Create != 0 {
181− if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
182− if !fsext.ShouldExcludeFile(gw.root, event.Name) {
148+ if fsext.ShouldExcludeFile(gw.root, path) {
183− if err := gw.addDirectoryToWatcher(event.Name); err != nil {
149+ continue
184− slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err)
185− }
186− } else if cfg != nil && cfg.Options.DebugLSP {
187− slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name)
188− }
189− }
190 }
150 }
191
151
192 if cfg != nil && cfg.Options.DebugLSP {
152 if cfg != nil && cfg.Options.DebugLSP {
193− slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String())
153+ slog.Debug("lsp watcher: Global watcher received event", "path", path, "event", event.Event().String())
194 }
154 }
195
155
196− // Process the event centrally
156+ // Convert notify event to our internal format and handle it
197 gw.handleFileEvent(event)
157 gw.handleFileEvent(event)
198−
199− case err, ok := <-gw.watcher.Errors:
200− if !ok {
201− return
202− }
203− slog.Error("lsp watcher: Global watcher error", "error", err)
204 }
158 }
205 }
159 }
206}
160}
207
161
208// handleFileEvent processes a file system event and distributes notifications to relevant clients
162// handleFileEvent processes a file system event and distributes notifications to relevant clients
209−func (gw *global) handleFileEvent(event fsnotify.Event) {
163+func (gw *global) handleFileEvent(event notify.EventInfo) {
210 cfg := config.Get()
164 cfg := config.Get()
165+ path := event.Path()
211− uri := string(protocol.URIFromPath(event.Name))
166+ uri := string(protocol.URIFromPath(path))
212−
167+
168+ // Map notify events to our change types
169+ var changeType protocol.FileChangeType
170+ var watchKindNeeded protocol.WatchKind
171+
172+ switch event.Event() {
173+ case notify.Create:
174+ changeType = protocol.FileChangeType(protocol.Created)
175+ watchKindNeeded = protocol.WatchCreate
213− // Handle file creation for all relevant clients (only once)
176+ // Handle file creation for all relevant clients
214− if event.Op&fsnotify.Create != 0 {
215− if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
216− if !fsext.ShouldExcludeFile(gw.root, event.Name) {
177+ if !isDir(path) && !fsext.ShouldExcludeFile(gw.root, path) {
217− gw.openMatchingFileForClients(event.Name)
178+ gw.openMatchingFileForClients(path)
179+ }
180+ case notify.Write:
181+ changeType = protocol.FileChangeType(protocol.Changed)
182+ watchKindNeeded = protocol.WatchChange
183+ case notify.Remove:
184+ changeType = protocol.FileChangeType(protocol.Deleted)
185+ watchKindNeeded = protocol.WatchDelete
186+ case notify.Rename:
187+ // Treat rename as delete + create
188+ // First handle as delete
189+ for _, watcher := range gw.watchers.Seq2() {
190+ if !watcher.client.HandlesFile(path) {
191+ continue
218 }
192 }
193+ if watched, watchKind := watcher.isPathWatched(path); watched {
194+ if watchKind&protocol.WatchDelete != 0 {
195+ gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
196+ }
197+ }
198+ }
199+ // Then check if renamed file exists and treat as create
200+ if !isDir(path) {
201+ changeType = protocol.FileChangeType(protocol.Created)
202+ watchKindNeeded = protocol.WatchCreate
203+ } else {
204+ return // Already handled delete, nothing more to do for directories
219 }
205 }
206+ default:
207+ // Unknown event type, skip
208+ return
220 }
209 }
221
210
222 // Process the event for each relevant client
211 // Process the event for each relevant client
223 for client, watcher := range gw.watchers.Seq2() {
212 for client, watcher := range gw.watchers.Seq2() {
224− if !watcher.client.HandlesFile(event.Name) {
213+ if !watcher.client.HandlesFile(path) {
225 continue // client doesn't handle this filetype
214 continue // client doesn't handle this filetype
226 }
215 }
227
216
228 // Debug logging per client
217 // Debug logging per client
229 if cfg.Options.DebugLSP {
218 if cfg.Options.DebugLSP {
230− matched, kind := watcher.isPathWatched(event.Name)
219+ matched, kind := watcher.isPathWatched(path)
231 slog.Debug("lsp watcher: File event for client",
220 slog.Debug("lsp watcher: File event for client",
232− "path", event.Name,
221+ "path", path,
233− "operation", event.Op.String(),
222+ "event", event.Event().String(),
234 "watched", matched,
223 "watched", matched,
235 "kind", kind,
224 "kind", kind,
236 "client", client,
225 "client", client,
@@ -238,46 +227,31 @@ func (gw *global) handleFileEvent(event fsnotify.Event) {
238 }
227 }
239
228
240 // Check if this path should be watched according to server registrations
229 // Check if this path should be watched according to server registrations
241− if watched, watchKind := watcher.isPathWatched(event.Name); watched {
230+ if watched, watchKind := watcher.isPathWatched(path); watched {
242− switch {
231+ if watchKind&watchKindNeeded != 0 {
243− case event.Op&fsnotify.Write != 0:
232+ // Skip directory events for non-delete operations
244− if watchKind&protocol.WatchChange != 0 {
245− gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed))
233+ if changeType != protocol.FileChangeType(protocol.Deleted) && isDir(path) {
246− }
247− case event.Op&fsnotify.Create != 0:
248− // File creation was already handled globally above
249− // Just send the notification if needed
250− info, err := os.Stat(event.Name)
251− if err != nil {
252− if !os.IsNotExist(err) {
253− slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err)
254− }
255 continue
234 continue
256 }
235 }
257− if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
258− gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
259− }
260− case event.Op&fsnotify.Remove != 0:
261− if watchKind&protocol.WatchDelete != 0 {
262− gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
263− }
264− case event.Op&fsnotify.Rename != 0:
265− // For renames, first delete
266− if watchKind&protocol.WatchDelete != 0 {
267− gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
268− }
269
236
270− // Then check if the new file exists and create an event
271− if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
272− if watchKind&protocol.WatchCreate != 0 {
273− gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
237+ if changeType == protocol.FileChangeType(protocol.Deleted) {
274− }
238+ // Don't debounce deletes
239+ gw.handleFileEventForClient(watcher, uri, changeType)
240+ } else {
241+ // Debounce creates and changes
242+ gw.debounceHandleFileEventForClient(watcher, uri, changeType)
275 }
243 }
276 }
244 }
277 }
245 }
278 }
246 }
279}
247}
280
248
249+// isDir checks if a path is a directory
250+func isDir(path string) bool {
251+ info, err := os.Stat(path)
252+ return err == nil && info.IsDir()
253+}
254+
281// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
255// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
282func (gw *global) openMatchingFileForClients(path string) {
256func (gw *global) openMatchingFileForClients(path string) {
283 // Skip directories
257 // Skip directories
@@ -349,10 +323,9 @@ func (gw *global) shutdown() {
349 gw.cancel()
323 gw.cancel()
350 }
324 }
351
325
352− if gw.watcher != nil {
326+ // Stop watching and close the event channel
353− gw.watcher.Close()
327+ notify.Stop(gw.events)
354− gw.watcher = nil
328+ close(gw.events)
355− }
356
329
357 gw.wg.Wait()
330 gw.wg.Wait()
358 slog.Debug("lsp watcher: Global watcher shutdown complete")
331 slog.Debug("lsp watcher: Global watcher shutdown complete")
internal/lsp/watcher/global_watcher_test.go+74−69
@@ -8,7 +8,7 @@ import (
8 "time"
8 "time"
9
9
10 "github.com/charmbracelet/crush/internal/csync"
10 "github.com/charmbracelet/crush/internal/csync"
11− "github.com/fsnotify/fsnotify"
11+ "github.com/rjeczalik/notify"
12)
12)
13
13
14func TestGlobalWatcher(t *testing.T) {
14func TestGlobalWatcher(t *testing.T) {
@@ -60,15 +60,8 @@ func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
60 ctx, cancel := context.WithCancel(context.Background())
60 ctx, cancel := context.WithCancel(context.Background())
61 defer cancel()
61 defer cancel()
62
62
63− // Create a real fsnotify watcher for testing
64− watcher, err := fsnotify.NewWatcher()
65− if err != nil {
66− t.Fatalf("Failed to create fsnotify watcher: %v", err)
67− }
68− defer watcher.Close()
69−
70 gw := &global{
63 gw := &global{
71− watcher: watcher,
64+ events: make(chan notify.EventInfo, 100),
72 watchers: csync.NewMap[string, *Client](),
65 watchers: csync.NewMap[string, *Client](),
73 debounceTime: 300 * time.Millisecond,
66 debounceTime: 300 * time.Millisecond,
74 debounceMap: csync.NewMap[string, *time.Timer](),
67 debounceMap: csync.NewMap[string, *time.Timer](),
@@ -77,26 +70,31 @@ func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
77 }
70 }
78
71
79 // Test that watching the same workspace multiple times is safe (idempotent)
72 // Test that watching the same workspace multiple times is safe (idempotent)
80− err1 := gw.addDirectoryToWatcher(tempDir)
73+ // With notify, we use recursive watching with "..."
74+ watchPath := filepath.Join(tempDir, "...")
75+
76+ err1 := notify.Watch(watchPath, gw.events, notify.All)
81 if err1 != nil {
77 if err1 != nil {
82− t.Fatalf("First addDirectoryToWatcher call failed: %v", err1)
78+ t.Fatalf("First Watch call failed: %v", err1)
83 }
79 }
80+ defer notify.Stop(gw.events)
84
81
85− err2 := gw.addDirectoryToWatcher(tempDir)
82+ // Watching the same path again should be safe (notify handles this)
83+ err2 := notify.Watch(watchPath, gw.events, notify.All)
86 if err2 != nil {
84 if err2 != nil {
87− t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2)
85+ t.Fatalf("Second Watch call failed: %v", err2)
88 }
86 }
89
87
90− err3 := gw.addDirectoryToWatcher(tempDir)
88+ err3 := notify.Watch(watchPath, gw.events, notify.All)
91 if err3 != nil {
89 if err3 != nil {
92− t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3)
90+ t.Fatalf("Third Watch call failed: %v", err3)
93 }
91 }
94
92
95− // All calls should succeed - fsnotify handles deduplication internally
93+ // All calls should succeed - notify handles deduplication internally
96− // This test verifies that multiple WatchWorkspace calls are safe
94+ // This test verifies that multiple Watch calls are safe
97}
95}
98
96
99−func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
97+func TestGlobalWatcherRecursiveWatching(t *testing.T) {
100 t.Parallel()
98 t.Parallel()
101
99
102 // Create a temporary directory structure for testing
100 // Create a temporary directory structure for testing
@@ -120,29 +118,24 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
120 ctx, cancel := context.WithCancel(context.Background())
118 ctx, cancel := context.WithCancel(context.Background())
121 defer cancel()
119 defer cancel()
122
120
123− // Create a real fsnotify watcher for testing
124− watcher, err := fsnotify.NewWatcher()
125− if err != nil {
126− t.Fatalf("Failed to create fsnotify watcher: %v", err)
127− }
128− defer watcher.Close()
129−
130 gw := &global{
121 gw := &global{
131− watcher: watcher,
122+ events: make(chan notify.EventInfo, 100),
132 watchers: csync.NewMap[string, *Client](),
123 watchers: csync.NewMap[string, *Client](),
133 debounceTime: 300 * time.Millisecond,
124 debounceTime: 300 * time.Millisecond,
134 debounceMap: csync.NewMap[string, *time.Timer](),
125 debounceMap: csync.NewMap[string, *time.Timer](),
135 ctx: ctx,
126 ctx: ctx,
136 cancel: cancel,
127 cancel: cancel,
128+ root: tempDir,
137 }
129 }
138
130
139− // Watch the workspace
131+ // Set up recursive watching on the root directory
140− err = gw.addDirectoryToWatcher(tempDir)
132+ watchPath := filepath.Join(tempDir, "...")
141− if err != nil {
133+ if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
142− t.Fatalf("addDirectoryToWatcher failed: %v", err)
134+ t.Fatalf("Failed to set up recursive watch: %v", err)
143 }
135 }
136+ defer notify.Stop(gw.events)
144
137
145− // Verify that our expected directories exist and can be watched
138+ // Verify that our expected directories and files exist
146 expectedDirs := []string{tempDir, subDir}
139 expectedDirs := []string{tempDir, subDir}
147
140
148 for _, expectedDir := range expectedDirs {
141 for _, expectedDir := range expectedDirs {
@@ -153,15 +146,9 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
153 if !info.IsDir() {
146 if !info.IsDir() {
154 t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
147 t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
155 }
148 }
156−
157− // Try to add it again - fsnotify should handle this gracefully
158− err = gw.addDirectoryToWatcher(expectedDir)
159− if err != nil {
160− t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err)
161− }
162 }
149 }
163
150
164− // Verify that files exist but we don't try to watch them directly
151+ // Verify that files exist
165 testFiles := []string{file1, file2}
152 testFiles := []string{file1, file2}
166 for _, file := range testFiles {
153 for _, file := range testFiles {
167 info, err := os.Stat(file)
154 info, err := os.Stat(file)
@@ -172,39 +159,61 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
172 t.Fatalf("Expected %s to be a file, but it's a directory", file)
159 t.Fatalf("Expected %s to be a file, but it's a directory", file)
173 }
160 }
174 }
161 }
162+
163+ // Create a new file in the subdirectory to test recursive watching
164+ newFile := filepath.Join(subDir, "new.txt")
165+ if err := os.WriteFile(newFile, []byte("new content"), 0o644); err != nil {
166+ t.Fatalf("Failed to create new file: %v", err)
167+ }
168+
169+ // We should receive an event for the file creation
170+ select {
171+ case event := <-gw.events:
172+ // On macOS, paths might have /private prefix, so we need to compare the real paths
173+ eventPath, _ := filepath.EvalSymlinks(event.Path())
174+ expectedPath, _ := filepath.EvalSymlinks(newFile)
175+ if eventPath != expectedPath {
176+ // Also try comparing just the base names as a fallback
177+ if filepath.Base(event.Path()) != filepath.Base(newFile) {
178+ t.Errorf("Expected event for %s, got %s", newFile, event.Path())
179+ }
180+ }
181+ case <-time.After(2 * time.Second):
182+ t.Fatal("Timeout waiting for file creation event")
183+ }
175}
184}
176
185
177−func TestFsnotifyDeduplication(t *testing.T) {
186+func TestNotifyDeduplication(t *testing.T) {
178 t.Parallel()
187 t.Parallel()
179
188
180 // Create a temporary directory for testing
189 // Create a temporary directory for testing
181 tempDir := t.TempDir()
190 tempDir := t.TempDir()
182
191
183− // Create a real fsnotify watcher
192+ // Create an event channel
184− watcher, err := fsnotify.NewWatcher()
193+ events := make(chan notify.EventInfo, 100)
185− if err != nil {
194+ defer close(events)
186− t.Fatalf("Failed to create fsnotify watcher: %v", err)
195+
187− }
196+ // Add the same directory multiple times with recursive watching
188− defer watcher.Close()
197+ watchPath := filepath.Join(tempDir, "...")
189
198
190− // Add the same directory multiple times
199+ err1 := notify.Watch(watchPath, events, notify.All)
191− err1 := watcher.Add(tempDir)
192 if err1 != nil {
200 if err1 != nil {
193− t.Fatalf("First Add failed: %v", err1)
201+ t.Fatalf("First Watch failed: %v", err1)
194 }
202 }
203+ defer notify.Stop(events)
195
204
196− err2 := watcher.Add(tempDir)
205+ err2 := notify.Watch(watchPath, events, notify.All)
197 if err2 != nil {
206 if err2 != nil {
198− t.Fatalf("Second Add failed: %v", err2)
207+ t.Fatalf("Second Watch failed: %v", err2)
199 }
208 }
200
209
201− err3 := watcher.Add(tempDir)
210+ err3 := notify.Watch(watchPath, events, notify.All)
202 if err3 != nil {
211 if err3 != nil {
203− t.Fatalf("Third Add failed: %v", err3)
212+ t.Fatalf("Third Watch failed: %v", err3)
204 }
213 }
205
214
206− // All should succeed - fsnotify handles deduplication internally
215+ // All should succeed - notify handles deduplication internally
207− // This test verifies the fsnotify behavior we're relying on
216+ // This test verifies the notify behavior we're relying on
208}
217}
209
218
210func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
219func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
@@ -241,31 +250,26 @@ func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
241 ctx, cancel := context.WithCancel(context.Background())
250 ctx, cancel := context.WithCancel(context.Background())
242 defer cancel()
251 defer cancel()
243
252
244− // Create a real fsnotify watcher for testing
245− watcher, err := fsnotify.NewWatcher()
246− if err != nil {
247− t.Fatalf("Failed to create fsnotify watcher: %v", err)
248− }
249− defer watcher.Close()
250−
251 gw := &global{
253 gw := &global{
252− watcher: watcher,
254+ events: make(chan notify.EventInfo, 100),
253 watchers: csync.NewMap[string, *Client](),
255 watchers: csync.NewMap[string, *Client](),
254 debounceTime: 300 * time.Millisecond,
256 debounceTime: 300 * time.Millisecond,
255 debounceMap: csync.NewMap[string, *time.Timer](),
257 debounceMap: csync.NewMap[string, *time.Timer](),
256 ctx: ctx,
258 ctx: ctx,
257 cancel: cancel,
259 cancel: cancel,
260+ root: tempDir,
258 }
261 }
259
262
260− // Watch the workspace
263+ // Set up recursive watching
261− err = gw.addDirectoryToWatcher(tempDir)
264+ watchPath := filepath.Join(tempDir, "...")
262− if err != nil {
265+ if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
263− t.Fatalf("addDirectoryToWatcher failed: %v", err)
266+ t.Fatalf("Failed to set up recursive watch: %v", err)
264 }
267 }
268+ defer notify.Stop(gw.events)
265
269
266− // This test verifies that the watcher can successfully add directories to fsnotify
270+ // The notify library watches everything, but our processEvents
267− // The actual ignore logic is tested in the fsext package
271+ // function should filter out ignored files using fsext.ShouldExcludeFile
268− // Here we just verify that the watcher integration works
272+ // This test verifies that the structure is set up correctly
269}
273}
270
274
271func TestGlobalWatcherShutdown(t *testing.T) {
275func TestGlobalWatcherShutdown(t *testing.T) {
@@ -277,6 +281,7 @@ func TestGlobalWatcherShutdown(t *testing.T) {
277
281
278 // Create a temporary global watcher for testing
282 // Create a temporary global watcher for testing
279 gw := &global{
283 gw := &global{
284+ events: make(chan notify.EventInfo, 100),
280 watchers: csync.NewMap[string, *Client](),
285 watchers: csync.NewMap[string, *Client](),
281 debounceTime: 300 * time.Millisecond,
286 debounceTime: 300 * time.Millisecond,
282 debounceMap: csync.NewMap[string, *time.Timer](),
287 debounceMap: csync.NewMap[string, *time.Timer](),