hugo

Unnamed repository; edit this file 'description' to name the repository.

git clone git://git.shimmy1996.com/hugo.git
commit 24ce98b6d10b2088af61c15112f5c5ed915a0c35
parent 0019d60f67b6c4dde085753641a917fcd0aa4c76
Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Date:   Fri,  2 Jul 2021 09:54:03 +0200

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

Fixes #8720
Fixes #6849
Fixes #7930

Diffstat:
Mcommands/commands.go | 2++
Mcommands/hugo.go | 11+++++++----
Mcommands/server.go | 2+-
Mwatcher/batcher.go | 30++++++++++++++++++++++--------
Awatcher/filenotify/filenotify.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Awatcher/filenotify/fsnotify.go | 20++++++++++++++++++++
Awatcher/filenotify/poller.go | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awatcher/filenotify/poller_test.go | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 731 insertions(+), 13 deletions(-)
diff --git a/commands/commands.go b/commands/commands.go
@@ -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")
diff --git a/commands/hugo.go b/commands/hugo.go
@@ -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)
 				}
diff --git a/commands/server.go b/commands/server.go
@@ -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
 		}
diff --git a/watcher/batcher.go b/watcher/batcher.go
@@ -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()
 }
diff --git a/watcher/filenotify/filenotify.go b/watcher/filenotify/filenotify.go
@@ -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
+}
diff --git a/watcher/filenotify/fsnotify.go b/watcher/filenotify/fsnotify.go
@@ -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
+}
diff --git a/watcher/filenotify/poller.go b/watcher/filenotify/poller.go
@@ -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
+}
diff --git a/watcher/filenotify/poller_test.go b/watcher/filenotify/poller_test.go
@@ -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)
+}