| // Copyright 2025 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package filewatcher |
| |
| import ( |
| "errors" |
| "io/fs" |
| "log/slog" |
| "os" |
| "path/filepath" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/fsnotify/fsnotify" |
| "golang.org/x/tools/gopls/internal/protocol" |
| ) |
| |
| // ErrClosed is used when trying to operate on a closed Watcher. |
| var ErrClosed = errors.New("file watcher: watcher already closed") |
| |
| // Watcher collects events from a [fsnotify.Watcher] and converts them into |
| // batched LSP [protocol.FileEvent]s. |
| type Watcher struct { |
| logger *slog.Logger |
| |
| stop chan struct{} // closed by Close to terminate run loop |
| |
| // errs is an internal channel for surfacing errors from the file watcher, |
| // distinct from the fsnotify watcher's error channel. |
| errs chan error |
| |
| runners sync.WaitGroup // counts the number of active run goroutines (max 1) |
| |
| watcher *fsnotify.Watcher |
| |
| mu sync.Mutex // guards all fields below |
| |
| // watchers counts the number of active watch registration goroutines, |
| // including their error handling. |
| // After [Watcher.Close] called, watchers's counter will no longer increase. |
| watchers sync.WaitGroup |
| |
| // dirCancel maps a directory path to its cancellation channel. |
| // A nil map indicates the watcher is closing and prevents new directory |
| // watch registrations. |
| dirCancel map[string]chan struct{} |
| |
| // events is the current batch of unsent file events, which will be sent |
| // when the timer expires. |
| events []protocol.FileEvent |
| } |
| |
| // New creates a new file watcher and starts its event-handling loop. The |
| // [Watcher.Close] method must be called to clean up resources. |
| // |
| // The provided handler is called sequentially with either a batch of file |
| // events or an error. Events and errors may be interleaved. The watcher blocks |
| // until the handler returns, so the handler should be fast and non-blocking. |
| func New(delay time.Duration, logger *slog.Logger, handler func([]protocol.FileEvent, error)) (*Watcher, error) { |
| watcher, err := fsnotify.NewWatcher() |
| if err != nil { |
| return nil, err |
| } |
| w := &Watcher{ |
| logger: logger, |
| watcher: watcher, |
| dirCancel: make(map[string]chan struct{}), |
| errs: make(chan error), |
| stop: make(chan struct{}), |
| } |
| |
| w.runners.Add(1) |
| go w.run(delay, handler) |
| |
| return w, nil |
| } |
| |
| // run is the main event-handling loop for the watcher. It should be run in a |
| // separate goroutine. |
| func (w *Watcher) run(delay time.Duration, handler func([]protocol.FileEvent, error)) { |
| defer w.runners.Done() |
| |
| // timer is used to debounce events. |
| timer := time.NewTimer(delay) |
| defer timer.Stop() |
| |
| for { |
| select { |
| case <-w.stop: |
| return |
| |
| case <-timer.C: |
| if events := w.drainEvents(); len(events) > 0 { |
| handler(events, nil) |
| } |
| timer.Reset(delay) |
| |
| case err, ok := <-w.watcher.Errors: |
| // When the watcher is closed, its Errors channel is closed, which |
| // unblocks this case. We continue to the next loop iteration, |
| // allowing the <-w.stop case to handle the shutdown. |
| if !ok { |
| continue |
| } |
| if err != nil { |
| handler(nil, err) |
| } |
| |
| case err, ok := <-w.errs: |
| if !ok { |
| continue |
| } |
| if err != nil { |
| handler(nil, err) |
| } |
| |
| case event, ok := <-w.watcher.Events: |
| if !ok { |
| continue |
| } |
| |
| // fsnotify does not guarantee clean filepaths. |
| event.Name = filepath.Clean(event.Name) |
| |
| // fsnotify.Event should not be handled concurrently, to preserve their |
| // original order. For example, if a file is deleted and recreated, |
| // concurrent handling could process the events in reverse order. |
| // |
| // Only reset the timer if a relevant event happened. |
| // https://github.com/fsnotify/fsnotify?tab=readme-ov-file#why-do-i-get-many-chmod-events |
| e, isDir := w.convertEvent(event) |
| if e == (protocol.FileEvent{}) { |
| continue |
| } |
| |
| if isDir { |
| switch e.Type { |
| case protocol.Created: |
| // Newly created directories are watched asynchronously to prevent |
| // a potential deadlock on Windows(see fsnotify/fsnotify#502). |
| // Errors are reported internally. |
| if done, release := w.addWatchHandle(event.Name); done != nil { |
| go func() { |
| w.errs <- w.watchDir(event.Name, done) |
| |
| // Only release after the error is sent. |
| release() |
| }() |
| } |
| case protocol.Deleted: |
| // Upon removal, we only need to remove the entries from |
| // the map. The [fsnotify.Watcher] removes the watch for |
| // us. fsnotify/fsnotify#268 |
| w.removeWatchHandle(event.Name) |
| default: |
| // convertEvent enforces that dirs are only Created or Deleted. |
| panic("impossible") |
| } |
| } |
| |
| w.addEvent(e) |
| timer.Reset(delay) |
| } |
| } |
| } |
| |
| // skipDir reports whether the input dir should be skipped. |
| // Directories that are unlikely to contain Go source files relevant for |
| // analysis, such as .git directories or testdata, should be skipped to |
| // avoid unnecessary file system notifications. This reduces noise and |
| // improves efficiency. Conversely, any directory that might contain Go |
| // source code should be watched to ensure that gopls can respond to |
| // file changes. |
| func skipDir(dirName string) bool { |
| // TODO(hxjiang): the file watcher should honor gopls directory |
| // filter or the new go.mod ignore directive, or actively listening |
| // to gopls register capability request with method |
| // "workspace/didChangeWatchedFiles" like a real LSP client. |
| return strings.HasPrefix(dirName, ".") || strings.HasPrefix(dirName, "_") || dirName == "testdata" |
| } |
| |
| // WatchDir walks through the directory and all its subdirectories, adding |
| // them to the watcher. |
| func (w *Watcher) WatchDir(path string) error { |
| return filepath.WalkDir(filepath.Clean(path), func(path string, d fs.DirEntry, err error) error { |
| if d.IsDir() { |
| if skipDir(d.Name()) { |
| return filepath.SkipDir |
| } |
| |
| done, release := w.addWatchHandle(path) |
| if done == nil { // file watcher closing |
| return filepath.SkipAll |
| } |
| defer release() |
| |
| return w.watchDir(path, done) |
| } |
| return nil |
| }) |
| } |
| |
| // convertEvent translates an [fsnotify.Event] into a [protocol.FileEvent]. |
| // It returns the translated event and a boolean indicating if the path was a |
| // directory. For directories, the event Type is either Created or Deleted. |
| // It returns empty event for events that should be ignored. |
| func (w *Watcher) convertEvent(event fsnotify.Event) (_ protocol.FileEvent, isDir bool) { |
| // Determine if the event is for a directory. |
| if info, err := os.Stat(event.Name); err == nil { |
| isDir = info.IsDir() |
| } else if os.IsNotExist(err) { |
| // Upon deletion, the file/dir has been removed. fsnotify does not |
| // provide information regarding the deleted item. |
| // Use watchHandles to determine if the deleted item was a directory. |
| isDir = w.isWatchedDir(event.Name) |
| } else { |
| // If statting failed, something is wrong with the file system. |
| // Log and move on. |
| if w.logger != nil { |
| w.logger.Error("failed to stat path, skipping event as its type (file/dir) is unknown", "path", event.Name, "err", err) |
| } |
| return protocol.FileEvent{}, false |
| } |
| |
| // Filter out events for directories and files that are not of interest. |
| if isDir { |
| if skipDir(filepath.Base(event.Name)) { |
| return protocol.FileEvent{}, true |
| } |
| } else { |
| switch strings.TrimPrefix(filepath.Ext(event.Name), ".") { |
| case "go", "mod", "sum", "work", "s": |
| default: |
| return protocol.FileEvent{}, false |
| } |
| } |
| |
| var t protocol.FileChangeType |
| switch { |
| case event.Op.Has(fsnotify.Rename): |
| // A rename is treated as a deletion of the old path because the |
| // fsnotify RENAME event doesn't include the new path. A separate |
| // CREATE event will be sent for the new path if the destination |
| // directory is watched. |
| fallthrough |
| case event.Op.Has(fsnotify.Remove): |
| // TODO(hxjiang): Directory removal events from some LSP clients may |
| // not include corresponding removal events for child files and |
| // subdirectories. Should we do some filtering when adding the dir |
| // deletion event to the events slice. |
| t = protocol.Deleted |
| case event.Op.Has(fsnotify.Create): |
| t = protocol.Created |
| case event.Op.Has(fsnotify.Write): |
| if isDir { |
| return protocol.FileEvent{}, isDir // ignore dir write events |
| } |
| t = protocol.Changed |
| default: |
| return protocol.FileEvent{}, isDir // ignore the rest of the events |
| } |
| |
| return protocol.FileEvent{ |
| URI: protocol.URIFromPath(event.Name), |
| Type: t, |
| }, isDir |
| } |
| |
| // watchDir registers a watch for a directory, retrying with backoff if it fails. |
| // It can be canceled by calling removeWatchHandle. |
| // Returns nil on success or cancellation; otherwise, the last error after all |
| // retries. |
| func (w *Watcher) watchDir(path string, done chan struct{}) error { |
| // On darwin, watching a directory will fail if it contains broken symbolic |
| // links. This state can occur temporarily during operations like a git |
| // branch switch. To handle this, we retry multiple times with exponential |
| // backoff, allowing time for the symbolic link's target to be created. |
| |
| // TODO(hxjiang): Address a race condition where file or directory creations |
| // under current directory might be missed between the current directory |
| // creation and the establishment of the file watch. |
| // |
| // To fix this, we should: |
| // 1. Retrospectively check for and trigger creation events for any new |
| // files/directories. |
| // 2. Recursively add watches for any newly created subdirectories. |
| var ( |
| delay = 500 * time.Millisecond |
| err error |
| ) |
| |
| for i := range 5 { |
| if i > 0 { |
| select { |
| case <-time.After(delay): |
| delay *= 2 |
| case <-done: |
| return nil // cancelled |
| } |
| } |
| // This function may block due to fsnotify/fsnotify#502. |
| err = w.watcher.Add(path) |
| if afterAddHook != nil { |
| afterAddHook(path, err) |
| } |
| if err == nil { |
| break |
| } |
| } |
| |
| return err |
| } |
| |
| var afterAddHook func(path string, err error) |
| |
| // addWatchHandle registers a new directory watch. |
| // The returned 'done' channel should be used to signal cancellation of a |
| // pending watch, the release function should be called once watch registration |
| // is done. |
| // It returns nil if the watcher is already closing. |
| func (w *Watcher) addWatchHandle(path string) (done chan struct{}, release func()) { |
| w.mu.Lock() |
| defer w.mu.Unlock() |
| |
| if w.dirCancel == nil { // file watcher is closing. |
| return nil, nil |
| } |
| |
| done = make(chan struct{}) |
| w.dirCancel[path] = done |
| |
| w.watchers.Add(1) |
| |
| return done, w.watchers.Done |
| } |
| |
| // removeWatchHandle removes the handle for a directory watch and cancels any |
| // pending watch attempt for that path. |
| func (w *Watcher) removeWatchHandle(path string) { |
| w.mu.Lock() |
| defer w.mu.Unlock() |
| |
| if done, ok := w.dirCancel[path]; ok { |
| delete(w.dirCancel, path) |
| close(done) |
| } |
| } |
| |
| // isWatchedDir reports whether the given path has a watch handle, meaning it is |
| // a directory the watcher is managing. |
| func (w *Watcher) isWatchedDir(path string) bool { |
| w.mu.Lock() |
| defer w.mu.Unlock() |
| |
| _, isDir := w.dirCancel[path] |
| return isDir |
| } |
| |
| func (w *Watcher) addEvent(event protocol.FileEvent) { |
| w.mu.Lock() |
| defer w.mu.Unlock() |
| |
| // Some systems emit duplicate change events in close |
| // succession upon file modification. While the current |
| // deduplication is naive and only handles immediate duplicates, |
| // a more robust solution is needed. |
| // |
| // TODO(hxjiang): Enhance deduplication. The current batching of |
| // events means all duplicates, regardless of proximity, should |
| // be removed. Consider checking the entire buffered slice or |
| // using a map for this. |
| if len(w.events) == 0 || w.events[len(w.events)-1] != event { |
| w.events = append(w.events, event) |
| } |
| } |
| |
| func (w *Watcher) drainEvents() []protocol.FileEvent { |
| w.mu.Lock() |
| events := w.events |
| w.events = nil |
| w.mu.Unlock() |
| |
| return events |
| } |
| |
| // Close shuts down the watcher, waits for the internal goroutine to terminate, |
| // and returns any final error. |
| func (w *Watcher) Close() error { |
| // Set dirCancel to nil which prevent any future watch attempts. |
| w.mu.Lock() |
| dirCancel := w.dirCancel |
| w.dirCancel = nil |
| w.mu.Unlock() |
| |
| // Cancel any ongoing watch registration. |
| for _, ch := range dirCancel { |
| close(ch) |
| } |
| |
| // Wait for all watch registration goroutines to finish, including their |
| // error handling. This ensures that: |
| // - All [Watcher.watchDir] goroutines have exited and it's error is sent |
| // to the internal error channel. So it is safe to close the internal |
| // error channel. |
| // - There are no ongoing [fsnotify.Watcher.Add] calls, so it is safe to |
| // close the fsnotify watcher (see fsnotify/fsnotify#704). |
| w.watchers.Wait() |
| close(w.errs) |
| |
| err := w.watcher.Close() |
| |
| // Wait for the main run loop to terminate. |
| close(w.stop) |
| w.runners.Wait() |
| |
| return err |
| } |