Use poll based file watcher

fsnotify/fsnotify can't watch a folder that contains a symlink into
a socket or named pipe.

Use poll-based mechanism to watch the file for the time being until
we find a better way or fix the issue in the upstream.

Signed-off-by: Fata Nugraha <fatanugraha@outlook.com>
This commit is contained in:
Fata Nugraha
2025-01-22 00:21:52 +08:00
parent 31b50f324d
commit c56ed7ab4a
6 changed files with 117 additions and 117 deletions

View File

@@ -1,84 +1,63 @@
package utils
import (
"fmt"
"path/filepath"
"os"
"time"
"github.com/fsnotify/fsnotify"
)
// FileWatcher is an utility that
type FileWatcher struct {
w *fsnotify.Watcher
path string
writeGracePeriod time.Duration
timer *time.Timer
closeCh chan struct{}
pollInterval time.Duration
}
func NewFileWatcher(path string) (*FileWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
func NewFileWatcher(path string) *FileWatcher {
return &FileWatcher{
path: path,
pollInterval: 5 * time.Second, // 5s is the default inode cache timeout in linux for most systems.
closeCh: make(chan struct{}),
}
return &FileWatcher{w: watcher, path: path, writeGracePeriod: 200 * time.Millisecond}, nil
}
func (fw *FileWatcher) Start(changeHandler func()) error {
// Ensure that the target that we're watching is not a symlink as we won't get any events when we're watching
// a symlink.
fileRealPath, err := filepath.EvalSymlinks(fw.path)
if err != nil {
return fmt.Errorf("adding watcher failed: %s", err)
}
// watch the directory instead of the individual file to ensure the notification still works when the file is modified
// through moving/renaming rather than writing into it directly (like what most modern editor does by default).
// ref: https://github.com/fsnotify/fsnotify/blob/a9bc2e01792f868516acf80817f7d7d7b3315409/README.md#watching-a-file-doesnt-work-well
if err = fw.w.Add(filepath.Dir(fileRealPath)); err != nil {
return fmt.Errorf("adding watcher failed: %s", err)
}
func (fw *FileWatcher) Start(changeHandler func()) {
prevModTime := fw.fileModTime(fw.path)
// use polling-based approach to detect file changes
// we can't use fsnotify/fsnotify due to issues with symlink+socket. see #462.
go func() {
for {
select {
case _, ok := <-fw.w.Errors:
if !ok {
return // watcher is closed.
}
case event, ok := <-fw.w.Events:
case _, ok := <-fw.closeCh:
if !ok {
return // watcher is closed.
}
case <-time.After(fw.pollInterval):
}
if event.Name != fileRealPath {
continue // we don't care about this file.
}
modTime := fw.fileModTime(fw.path)
if modTime.IsZero() {
continue // file does not exists
}
// Create may not always followed by Write e.g. when we replace the file with mv.
if event.Op.Has(fsnotify.Create) || event.Op.Has(fsnotify.Write) {
// as per the documentation, receiving Write does not mean that the write is finished.
// we try our best here to ignore "unfinished" write by assuming that after [writeGracePeriod] of
// inactivity the write has been finished.
fw.debounce(changeHandler)
}
if !prevModTime.Equal(modTime) {
changeHandler()
prevModTime = modTime
}
}
}()
return nil
}
func (fw *FileWatcher) debounce(fn func()) {
if fw.timer != nil {
fw.timer.Stop()
func (fw *FileWatcher) fileModTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Time{}
}
fw.timer = time.AfterFunc(fw.writeGracePeriod, fn)
return info.ModTime()
}
func (fw *FileWatcher) Stop() error {
return fw.w.Close()
func (fw *FileWatcher) Stop() {
close(fw.closeCh)
}