blob: 5d52f1e55deb17cd8f6c7794489e890127f497bd [file] [log] [blame]
// 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
}