Add polling as a fallback to native filesystem events in server watch

Fixes #8720
Fixes #6849
Fixes #7930
This commit is contained in:
Bjørn Erik Pedersen 2021-07-02 09:54:03 +02:00
parent 0019d60f67
commit 24ce98b6d1
No known key found for this signature in database
GPG key ID: 330E6E2BD4859D8F
8 changed files with 731 additions and 13 deletions

View file

@ -204,6 +204,7 @@ type hugoBuilderCommon struct {
environment string
buildWatch bool
poll bool
gc bool
@ -291,6 +292,7 @@ func (cc *hugoBuilderCommon) handleFlags(cmd *cobra.Command) {
cmd.Flags().StringVarP(&cc.baseURL, "baseURL", "b", "", "hostname (and path) to the root, e.g. http://spf13.com/")
cmd.Flags().Bool("enableGitInfo", false, "add Git revision, date and author info to the pages")
cmd.Flags().BoolVar(&cc.gc, "gc", false, "enable to run some cleanup tasks (remove unused cache files) after the build")
cmd.Flags().BoolVar(&cc.poll, "poll", false, "use a poll based approach to watch for file system changes")
cmd.Flags().Bool("templateMetrics", false, "display metrics about template executions")
cmd.Flags().Bool("templateMetricsHints", false, "calculate some improvement hints when combined with --templateMetrics")

View file

@ -523,7 +523,7 @@ func (c *commandeer) build() error {
c.logger.Printf("Watching for changes in %s%s{%s}\n", baseWatchDir, helpers.FilePathSeparator, rootWatchDirs)
c.logger.Println("Press Ctrl+C to stop")
watcher, err := c.newWatcher(watchDirs...)
watcher, err := c.newWatcher(c.h.poll, watchDirs...)
checkErr(c.Logger, err)
defer watcher.Close()
@ -820,7 +820,7 @@ func (c *commandeer) fullRebuild(changeType string) {
}
// newWatcher creates a new watcher to watch filesystem events.
func (c *commandeer) newWatcher(dirList ...string) (*watcher.Batcher, error) {
func (c *commandeer) newWatcher(poll bool, dirList ...string) (*watcher.Batcher, error) {
if runtime.GOOS == "darwin" {
tweakLimit()
}
@ -830,7 +830,10 @@ func (c *commandeer) newWatcher(dirList ...string) (*watcher.Batcher, error) {
return nil, err
}
watcher, err := watcher.New(1 * time.Second)
// The second interval is used by the poll based watcher.
// Setting a shorter interval would make it snappier,
// but it would consume more CPU.
watcher, err := watcher.New(500*time.Millisecond, 700*time.Millisecond, poll)
if err != nil {
return nil, err
}
@ -859,7 +862,7 @@ func (c *commandeer) newWatcher(dirList ...string) (*watcher.Batcher, error) {
// Need to reload browser to show the error
livereload.ForceRefresh()
}
case err := <-watcher.Errors:
case err := <-watcher.Errors():
if err != nil {
c.logger.Errorln("Error while watching:", err)
}

View file

@ -262,7 +262,7 @@ func (sc *serverCmd) server(cmd *cobra.Command, args []string) error {
for _, group := range watchGroups {
jww.FEEDBACK.Printf("Watching for changes in %s\n", group)
}
watcher, err := c.newWatcher(watchDirs...)
watcher, err := c.newWatcher(sc.poll, watchDirs...)
if err != nil {
return err
}

View file

@ -17,11 +17,12 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"github.com/gohugoio/hugo/watcher/filenotify"
)
// Batcher batches file watch events in a given interval.
type Batcher struct {
*fsnotify.Watcher
filenotify.FileWatcher
interval time.Duration
done chan struct{}
@ -29,12 +30,25 @@ type Batcher struct {
}
// New creates and starts a Batcher with the given time interval.
func New(interval time.Duration) (*Batcher, error) {
watcher, err := fsnotify.NewWatcher()
// It will fall back to a poll based watcher if native isn's supported.
// To always use polling, set poll to true.
func New(intervalBatcher, intervalPoll time.Duration, poll bool) (*Batcher, error) {
var err error
var watcher filenotify.FileWatcher
if poll {
watcher = filenotify.NewPollingWatcher(intervalPoll)
} else {
watcher, err = filenotify.New(intervalPoll)
}
if err != nil {
return nil, err
}
batcher := &Batcher{}
batcher.Watcher = watcher
batcher.interval = interval
batcher.FileWatcher = watcher
batcher.interval = intervalBatcher
batcher.done = make(chan struct{}, 1)
batcher.Events = make(chan []fsnotify.Event, 1)
@ -42,7 +56,7 @@ func New(interval time.Duration) (*Batcher, error) {
go batcher.run()
}
return batcher, err
return batcher, nil
}
func (b *Batcher) run() {
@ -51,7 +65,7 @@ func (b *Batcher) run() {
OuterLoop:
for {
select {
case ev := <-b.Watcher.Events:
case ev := <-b.FileWatcher.Events():
evs = append(evs, ev)
case <-tick:
if len(evs) == 0 {
@ -69,5 +83,5 @@ OuterLoop:
// Close stops the watching of the files.
func (b *Batcher) Close() {
b.done <- struct{}{}
b.Watcher.Close()
b.FileWatcher.Close()
}

View file

@ -0,0 +1,49 @@
// Package filenotify provides a mechanism for watching file(s) for changes.
// Generally leans on fsnotify, but provides a poll-based notifier which fsnotify does not support.
// These are wrapped up in a common interface so that either can be used interchangeably in your code.
//
// This package is adapted from https://github.com/moby/moby/tree/master/pkg/filenotify, Apache-2.0 License.
// Hopefully this can be replaced with an external package sometime in the future, see https://github.com/fsnotify/fsnotify/issues/9
package filenotify
import (
"time"
"github.com/fsnotify/fsnotify"
)
// FileWatcher is an interface for implementing file notification watchers
type FileWatcher interface {
Events() <-chan fsnotify.Event
Errors() <-chan error
Add(name string) error
Remove(name string) error
Close() error
}
// New tries to use an fs-event watcher, and falls back to the poller if there is an error
func New(interval time.Duration) (FileWatcher, error) {
if watcher, err := NewEventWatcher(); err == nil {
return watcher, nil
}
return NewPollingWatcher(interval), nil
}
// NewPollingWatcher returns a poll-based file watcher
func NewPollingWatcher(interval time.Duration) FileWatcher {
return &filePoller{
interval: interval,
done: make(chan struct{}),
events: make(chan fsnotify.Event),
errors: make(chan error),
}
}
// NewEventWatcher returns an fs-event based file watcher
func NewEventWatcher() (FileWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
return &fsNotifyWatcher{watcher}, nil
}

View file

@ -0,0 +1,20 @@
// Package filenotify is adapted from https://github.com/moby/moby/tree/master/pkg/filenotify, Apache-2.0 License.
// Hopefully this can be replaced with an external package sometime in the future, see https://github.com/fsnotify/fsnotify/issues/9
package filenotify
import "github.com/fsnotify/fsnotify"
// fsNotifyWatcher wraps the fsnotify package to satisfy the FileNotifier interface
type fsNotifyWatcher struct {
*fsnotify.Watcher
}
// Events returns the fsnotify event channel receiver
func (w *fsNotifyWatcher) Events() <-chan fsnotify.Event {
return w.Watcher.Events
}
// Errors returns the fsnotify error channel receiver
func (w *fsNotifyWatcher) Errors() <-chan error {
return w.Watcher.Errors
}

View file

@ -0,0 +1,326 @@
// Package filenotify is adapted from https://github.com/moby/moby/tree/master/pkg/filenotify, Apache-2.0 License.
// Hopefully this can be replaced with an external package sometime in the future, see https://github.com/fsnotify/fsnotify/issues/9
package filenotify
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
var (
// errPollerClosed is returned when the poller is closed
errPollerClosed = errors.New("poller is closed")
// errNoSuchWatch is returned when trying to remove a watch that doesn't exist
errNoSuchWatch = errors.New("watch does not exist")
)
// filePoller is used to poll files for changes, especially in cases where fsnotify
// can't be run (e.g. when inotify handles are exhausted)
// filePoller satisfies the FileWatcher interface
type filePoller struct {
// the duration between polls.
interval time.Duration
// watches is the list of files currently being polled, close the associated channel to stop the watch
watches map[string]struct{}
// Will be closed when done.
done chan struct{}
// events is the channel to listen to for watch events
events chan fsnotify.Event
// errors is the channel to listen to for watch errors
errors chan error
// mu locks the poller for modification
mu sync.Mutex
// closed is used to specify when the poller has already closed
closed bool
}
// Add adds a filename to the list of watches
// once added the file is polled for changes in a separate goroutine
func (w *filePoller) Add(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return errPollerClosed
}
item, err := newItemToWatch(name)
if err != nil {
return err
}
if item.left.FileInfo == nil {
return os.ErrNotExist
}
if w.watches == nil {
w.watches = make(map[string]struct{})
}
if _, exists := w.watches[name]; exists {
return fmt.Errorf("watch exists")
}
w.watches[name] = struct{}{}
go w.watch(item)
return nil
}
// Remove stops and removes watch with the specified name
func (w *filePoller) Remove(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
return w.remove(name)
}
func (w *filePoller) remove(name string) error {
if w.closed {
return errPollerClosed
}
_, exists := w.watches[name]
if !exists {
return errNoSuchWatch
}
delete(w.watches, name)
return nil
}
// Events returns the event channel
// This is used for notifications on events about watched files
func (w *filePoller) Events() <-chan fsnotify.Event {
return w.events
}
// Errors returns the errors channel
// This is used for notifications about errors on watched files
func (w *filePoller) Errors() <-chan error {
return w.errors
}
// Close closes the poller
// All watches are stopped, removed, and the poller cannot be added to
func (w *filePoller) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
close(w.done)
for name := range w.watches {
w.remove(name)
}
return nil
}
// sendEvent publishes the specified event to the events channel
func (w *filePoller) sendEvent(e fsnotify.Event) error {
select {
case w.events <- e:
case <-w.done:
return fmt.Errorf("closed")
}
return nil
}
// sendErr publishes the specified error to the errors channel
func (w *filePoller) sendErr(e error) error {
select {
case w.errors <- e:
case <-w.done:
return fmt.Errorf("closed")
}
return nil
}
// watch watches item for changes until done is closed.
func (w *filePoller) watch(item *itemToWatch) {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
case <-w.done:
return
}
evs, err := item.checkForChanges()
if err != nil {
if err := w.sendErr(err); err != nil {
return
}
}
item.left, item.right = item.right, item.left
for _, ev := range evs {
if err := w.sendEvent(ev); err != nil {
return
}
}
}
}
// recording records the state of a file or a dir.
type recording struct {
os.FileInfo
// Set if FileInfo is a dir.
entries map[string]os.FileInfo
}
func (r *recording) clear() {
r.FileInfo = nil
if r.entries != nil {
for k := range r.entries {
delete(r.entries, k)
}
}
}
func (r *recording) record(filename string) error {
r.clear()
fi, err := os.Stat(filename)
if err != nil && !os.IsNotExist(err) {
return err
}
if fi == nil {
return nil
}
r.FileInfo = fi
// If fi is a dir, we watch the files inside that directory (not recursively).
// This matches the behaviour of fsnotity.
if fi.IsDir() {
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
fis, err := f.Readdir(-1)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, fi := range fis {
r.entries[fi.Name()] = fi
}
}
return nil
}
// itemToWatch may be a file or a dir.
type itemToWatch struct {
// Full path to the filename.
filename string
// Snapshots of the stat state of this file or dir.
left *recording
right *recording
}
func newItemToWatch(filename string) (*itemToWatch, error) {
r := &recording{
entries: make(map[string]os.FileInfo),
}
err := r.record(filename)
if err != nil {
return nil, err
}
return &itemToWatch{filename: filename, left: r}, nil
}
func (item *itemToWatch) checkForChanges() ([]fsnotify.Event, error) {
if item.right == nil {
item.right = &recording{
entries: make(map[string]os.FileInfo),
}
}
err := item.right.record(item.filename)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
dirOp := checkChange(item.left.FileInfo, item.right.FileInfo)
if dirOp != 0 {
evs := []fsnotify.Event{fsnotify.Event{Op: dirOp, Name: item.filename}}
return evs, nil
}
if item.left.FileInfo == nil || !item.left.IsDir() {
// Done.
return nil, nil
}
leftIsIn := false
left, right := item.left.entries, item.right.entries
if len(right) > len(left) {
left, right = right, left
leftIsIn = true
}
var evs []fsnotify.Event
for name, fi1 := range left {
fi2 := right[name]
fil, fir := fi1, fi2
if leftIsIn {
fil, fir = fir, fil
}
op := checkChange(fil, fir)
if op != 0 {
evs = append(evs, fsnotify.Event{Op: op, Name: filepath.Join(item.filename, name)})
}
}
return evs, nil
}
func checkChange(fi1, fi2 os.FileInfo) fsnotify.Op {
if fi1 == nil && fi2 != nil {
return fsnotify.Create
}
if fi1 != nil && fi2 == nil {
return fsnotify.Remove
}
if fi1 == nil && fi2 == nil {
return 0
}
if fi1.IsDir() || fi2.IsDir() {
return 0
}
if fi1.Mode() != fi2.Mode() {
return fsnotify.Chmod
}
if fi1.ModTime() != fi2.ModTime() || fi1.Size() != fi2.Size() {
return fsnotify.Write
}
return 0
}

View file

@ -0,0 +1,304 @@
// Package filenotify is adapted from https://github.com/moby/moby/tree/master/pkg/filenotify, Apache-2.0 License.
// Hopefully this can be replaced with an external package sometime in the future, see https://github.com/fsnotify/fsnotify/issues/9
package filenotify
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"testing"
"time"
qt "github.com/frankban/quicktest"
"github.com/fsnotify/fsnotify"
"github.com/gohugoio/hugo/htesting"
)
const (
subdir1 = "subdir1"
subdir2 = "subdir2"
watchWaitTime = 200 * time.Millisecond
)
var (
isMacOs = runtime.GOOS == "darwin"
isWindows = runtime.GOOS == "windows"
isCI = htesting.IsCI()
)
func TestPollerAddRemove(t *testing.T) {
c := qt.New(t)
w := NewPollingWatcher(watchWaitTime)
c.Assert(w.Add("foo"), qt.Not(qt.IsNil))
c.Assert(w.Remove("foo"), qt.Not(qt.IsNil))
f, err := ioutil.TempFile("", "asdf")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(f.Name())
c.Assert(w.Add(f.Name()), qt.IsNil)
c.Assert(w.Remove(f.Name()), qt.IsNil)
}
func TestPollerEvent(t *testing.T) {
c := qt.New(t)
for _, poll := range []bool{true, false} {
if !(poll || isMacOs) || isCI {
// Only run the fsnotify tests on MacOS locally.
continue
}
method := "fsnotify"
if poll {
method = "poll"
}
c.Run(fmt.Sprintf("%s, Watch dir", method), func(c *qt.C) {
dir, w := preparePollTest(c, poll)
subdir := filepath.Join(dir, subdir1)
c.Assert(w.Add(subdir), qt.IsNil)
filename := filepath.Join(subdir, "file1")
// Write to one file.
c.Assert(ioutil.WriteFile(filename, []byte("changed"), 0600), qt.IsNil)
var expected []fsnotify.Event
if poll {
expected = append(expected, fsnotify.Event{Name: filename, Op: fsnotify.Write})
assertEvents(c, w, expected...)
} else {
// fsnotify sometimes emits Chmod before Write,
// which is hard to test, so skip it here.
drainEvents(c, w)
}
// Remove one file.
filename = filepath.Join(subdir, "file2")
c.Assert(os.Remove(filename), qt.IsNil)
assertEvents(c, w, fsnotify.Event{Name: filename, Op: fsnotify.Remove})
// Add one file.
filename = filepath.Join(subdir, "file3")
c.Assert(ioutil.WriteFile(filename, []byte("new"), 0600), qt.IsNil)
assertEvents(c, w, fsnotify.Event{Name: filename, Op: fsnotify.Create})
// Remove entire directory.
subdir = filepath.Join(dir, subdir2)
c.Assert(w.Add(subdir), qt.IsNil)
c.Assert(os.RemoveAll(subdir), qt.IsNil)
expected = expected[:0]
// This looks like a bug in fsnotify on MacOS. There are
// 3 files in this directory, yet we get Remove events
// for one of them + the directory.
if !poll {
expected = append(expected, fsnotify.Event{Name: filepath.Join(subdir, "file2"), Op: fsnotify.Remove})
}
expected = append(expected, fsnotify.Event{Name: subdir, Op: fsnotify.Remove})
assertEvents(c, w, expected...)
})
c.Run(fmt.Sprintf("%s, Add should not trigger event", method), func(c *qt.C) {
dir, w := preparePollTest(c, poll)
subdir := filepath.Join(dir, subdir1)
w.Add(subdir)
assertEvents(c, w)
// Create a new sub directory and add it to the watcher.
subdir = filepath.Join(dir, subdir1, subdir2)
c.Assert(os.Mkdir(subdir, 0777), qt.IsNil)
w.Add(subdir)
// This should create only one event.
assertEvents(c, w, fsnotify.Event{Name: subdir, Op: fsnotify.Create})
})
}
}
func TestPollerClose(t *testing.T) {
c := qt.New(t)
w := NewPollingWatcher(watchWaitTime)
f1, err := ioutil.TempFile("", "f1")
c.Assert(err, qt.IsNil)
f2, err := ioutil.TempFile("", "f2")
c.Assert(err, qt.IsNil)
filename1 := f1.Name()
filename2 := f2.Name()
f1.Close()
f2.Close()
c.Assert(w.Add(filename1), qt.IsNil)
c.Assert(w.Add(filename2), qt.IsNil)
c.Assert(w.Close(), qt.IsNil)
c.Assert(w.Close(), qt.IsNil)
c.Assert(ioutil.WriteFile(filename1, []byte("new"), 0600), qt.IsNil)
c.Assert(ioutil.WriteFile(filename2, []byte("new"), 0600), qt.IsNil)
// No more event as the watchers are closed.
assertEvents(c, w)
f2, err = ioutil.TempFile("", "f2")
c.Assert(err, qt.IsNil)
defer os.Remove(f2.Name())
c.Assert(w.Add(f2.Name()), qt.Not(qt.IsNil))
}
func TestCheckChange(t *testing.T) {
c := qt.New(t)
dir := prepareTestDirWithSomeFiles(c, "check-change")
stat := func(s ...string) os.FileInfo {
fi, err := os.Stat(filepath.Join(append([]string{dir}, s...)...))
c.Assert(err, qt.IsNil)
return fi
}
f0, f1, f2 := stat(subdir2, "file0"), stat(subdir2, "file1"), stat(subdir2, "file2")
d1 := stat(subdir1)
// Note that on Windows, only the 0200 bit (owner writable) of mode is used.
c.Assert(os.Chmod(filepath.Join(filepath.Join(dir, subdir2, "file1")), 0400), qt.IsNil)
f1_2 := stat(subdir2, "file1")
c.Assert(ioutil.WriteFile(filepath.Join(filepath.Join(dir, subdir2, "file2")), []byte("changed"), 0600), qt.IsNil)
f2_2 := stat(subdir2, "file2")
c.Assert(checkChange(f0, nil), qt.Equals, fsnotify.Remove)
c.Assert(checkChange(nil, f0), qt.Equals, fsnotify.Create)
c.Assert(checkChange(f1, f1_2), qt.Equals, fsnotify.Chmod)
c.Assert(checkChange(f2, f2_2), qt.Equals, fsnotify.Write)
c.Assert(checkChange(nil, nil), qt.Equals, fsnotify.Op(0))
c.Assert(checkChange(d1, f1), qt.Equals, fsnotify.Op(0))
c.Assert(checkChange(f1, d1), qt.Equals, fsnotify.Op(0))
}
func BenchmarkPoller(b *testing.B) {
runBench := func(b *testing.B, item *itemToWatch) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
evs, err := item.checkForChanges()
if err != nil {
b.Fatal(err)
}
if len(evs) != 0 {
b.Fatal("got events")
}
}
}
b.Run("Check for changes in dir", func(b *testing.B) {
c := qt.New(b)
dir := prepareTestDirWithSomeFiles(c, "bench-check")
item, err := newItemToWatch(dir)
c.Assert(err, qt.IsNil)
runBench(b, item)
})
b.Run("Check for changes in file", func(b *testing.B) {
c := qt.New(b)
dir := prepareTestDirWithSomeFiles(c, "bench-check-file")
filename := filepath.Join(dir, subdir1, "file1")
item, err := newItemToWatch(filename)
c.Assert(err, qt.IsNil)
runBench(b, item)
})
}
func prepareTestDirWithSomeFiles(c *qt.C, id string) string {
dir, err := ioutil.TempDir("", fmt.Sprintf("test-poller-dir-%s", id))
c.Assert(err, qt.IsNil)
c.Assert(os.MkdirAll(filepath.Join(dir, subdir1), 0777), qt.IsNil)
c.Assert(os.MkdirAll(filepath.Join(dir, subdir2), 0777), qt.IsNil)
for i := 0; i < 3; i++ {
c.Assert(ioutil.WriteFile(filepath.Join(dir, subdir1, fmt.Sprintf("file%d", i)), []byte("hello1"), 0600), qt.IsNil)
}
for i := 0; i < 3; i++ {
c.Assert(ioutil.WriteFile(filepath.Join(dir, subdir2, fmt.Sprintf("file%d", i)), []byte("hello2"), 0600), qt.IsNil)
}
c.Cleanup(func() {
os.RemoveAll(dir)
})
return dir
}
func preparePollTest(c *qt.C, poll bool) (string, FileWatcher) {
var w FileWatcher
if poll {
w = NewPollingWatcher(watchWaitTime)
} else {
var err error
w, err = NewEventWatcher()
c.Assert(err, qt.IsNil)
}
dir := prepareTestDirWithSomeFiles(c, fmt.Sprint(poll))
c.Cleanup(func() {
w.Close()
})
return dir, w
}
func assertEvents(c *qt.C, w FileWatcher, evs ...fsnotify.Event) {
c.Helper()
i := 0
check := func() error {
for {
select {
case got := <-w.Events():
if i > len(evs)-1 {
return fmt.Errorf("got too many event(s): %q", got)
}
expected := evs[i]
i++
if expected.Name != got.Name {
return fmt.Errorf("got wrong filename, expected %q: %v", expected.Name, got.Name)
} else if got.Op&expected.Op != expected.Op {
return fmt.Errorf("got wrong event type, expected %q: %v", expected.Op, got.Op)
}
case e := <-w.Errors():
return fmt.Errorf("got unexpected error waiting for events %v", e)
case <-time.After(watchWaitTime + (watchWaitTime / 2)):
return nil
}
}
}
c.Assert(check(), qt.IsNil)
c.Assert(i, qt.Equals, len(evs))
}
func drainEvents(c *qt.C, w FileWatcher) {
c.Helper()
check := func() error {
for {
select {
case <-w.Events():
case e := <-w.Errors():
return fmt.Errorf("got unexpected error waiting for events %v", e)
case <-time.After(watchWaitTime * 2):
return nil
}
}
}
c.Assert(check(), qt.IsNil)
}