Skip to content

Commit

Permalink
inotify: add recursive watcher
Browse files Browse the repository at this point in the history
This adds a recursive watcher for inotify; per my suggestion in [1] it
uses the "/..." syntax in the path to indicate recursive watches,
similar to how Go tools work:

	w, _ := fsnotify.NewWatcher()
	w.Add("some/dir/...")

This will watch "some/dir" as well as any subdirectories of "some/dir".

The upshot of using a syntax like this is that we can:

1. With AddRecursive(path string) adding new Add* methods would become
   hard; for example AddMask("path", fsnotify.Create) to only get CREATE
   events; we'd then also have to add AddMaskRecursive(). Plus we'd
   have to add a RemoveRecursive() as well.

2. With Watcher.SetRecursive() like in #339 it's not possible to add
   some paths recursively and others non-recursively, which may be
   useful in some cases. Also, it makes it a bit easier to accept user
   input; in the CLI or config you can set "dir/..." and just pass that
   as-is to fsnotify, without applications having to write special code.

For other watchers it will return ErrRecursionUnsupported for now;
Windows support is already mostly finished in #339, and kqueue can be
added in much the same way as inotify in a future PR.

I also moved all test helpers to helper_test.go, and added a bunch of
"shell-like" functions so you're not forever typing error checks and
filepath.Join(). The new "eventCollector" is also useful in tests to
conveniently collect a slice of events.

TODO:

- Also support recursion in Remove(), and deal with paths added with
  "...". I just updated the documentation but didn't actually implement
  anything.

- A few test cases don't seem quite right; want to get #470 merged first
  as it really confuses things.

- Maybe think of a few more test cases.

[1]: #339 (comment)
  • Loading branch information
arp242 committed Jul 25, 2022
1 parent c4e64e4 commit 26e63b3
Show file tree
Hide file tree
Showing 7 changed files with 595 additions and 111 deletions.
63 changes: 49 additions & 14 deletions fsnotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,11 @@ package fsnotify
import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"
)

// Event represents a single file system notification.
type Event struct {
Name string // Relative path to the file or directory.
Op Op // File operation that triggered the event.
}

// Op describes a set of file operations.
type Op uint32

// These are the generalized file operations that can trigger a notification.
const (
Create Op = 1 << iota
Expand All @@ -32,6 +25,23 @@ const (
Chmod
)

// Common errors that can be reported by a watcher
var (
ErrNonExistentWatch = errors.New("can't remove non-existent watcher")
ErrEventOverflow = errors.New("fsnotify queue overflow")
ErrNotDirectory = errors.New("not a directory")
ErrRecursionUnsupported = errors.New("recursion not supported")
)

// Event represents a single file system notification.
type Event struct {
Name string // Relative path to the file or directory.
Op Op // File operation that triggered the event.
}

// Op describes a set of file operations.
type Op uint32

func (op Op) String() string {
// Use a builder for efficient string concatenation
var builder strings.Builder
Expand Down Expand Up @@ -63,8 +73,33 @@ func (e Event) String() string {
return fmt.Sprintf("%q: %s", e.Name, e.Op.String())
}

// Common errors that can be reported by a watcher
var (
ErrNonExistentWatch = errors.New("can't remove non-existent watcher")
ErrEventOverflow = errors.New("fsnotify queue overflow")
)
// findDirs finds all directories under path (return value *includes* path as
// the first entry).
func findDirs(path string) ([]string, error) {
dirs := make([]string, 0, 8)
err := filepath.WalkDir(path, func(root string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if root == path && !d.IsDir() {
return fmt.Errorf("%q: %w", path, ErrNotDirectory)
}
if d.IsDir() {
dirs = append(dirs, root)
}
return nil
})
if err != nil {
return nil, err
}
return dirs, nil
}

// Check if this path is recursive (ends with "/..."), and return the path with
// the /... stripped.
func recursivePath(path string) (string, bool) {
if filepath.Base(path) == "..." {
return filepath.Dir(path), true
}
return path, false
}
47 changes: 47 additions & 0 deletions fsnotify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ package fsnotify

import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -69,3 +71,48 @@ func TestWatcherClose(t *testing.T) {
t.Fatal(err)
}
}

func TestFindDirs(t *testing.T) {
join := func(list ...string) string {
return "\n\t" + strings.Join(list, "\n\t")
}

t.Run("finds dirs", func(t *testing.T) {
tmp := t.TempDir()

mkdirAll(t, tmp, "/one/two/three/four")
cat(t, "asd", tmp, "one/two/file.txt")
symlink(t, "/", tmp, "link")

dirs, err := findDirs(tmp)
if err != nil {
t.Fatal(err)
}

have := join(dirs...)
want := join([]string{
tmp,
filepath.Join(tmp, "one"),
filepath.Join(tmp, "one/two"),
filepath.Join(tmp, "one/two/three"),
filepath.Join(tmp, "one/two/three/four"),
}...)

if have != want {
t.Errorf("\nhave: %s\nwant: %s", have, want)
}
})

t.Run("file", func(t *testing.T) {
tmp := t.TempDir()
cat(t, "asd", tmp, "file")

dirs, err := findDirs(filepath.Join(tmp, "file"))
if !errorContains(err, "not a directory") {
t.Errorf("wrong error: %s", err)
}
if len(dirs) > 0 {
t.Errorf("dirs contains entries: %s", dirs)
}
})
}
243 changes: 243 additions & 0 deletions helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
//go:build !plan9 && !solaris
// +build !plan9,!solaris

package fsnotify

import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)

const (
eventSeparator = 50 * time.Millisecond
waitForEvents = 500 * time.Millisecond
)

// newWatcher initializes an fsnotify Watcher instance.
func newWatcher(t *testing.T) *Watcher {
t.Helper()
watcher, err := NewWatcher()
if err != nil {
t.Fatalf("NewWatcher() failed: %s", err)
}
return watcher
}

// addWatch adds a watch for a directory
func addWatch(t *testing.T, watcher *Watcher, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("addWatch: path must have at least one element: %s", path)
}
err := watcher.Add(filepath.Join(path...))
if err != nil {
t.Fatalf("addWatch(%q): %s", filepath.Join(path...), err)
}
}

// mkdir -p
func mkdirAll(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("mkdirAll: path must have at least one element: %s", path)
}
err := os.MkdirAll(filepath.Join(path...), 0o0755)
if err != nil {
t.Fatalf("mkdirAll(%q): %s", filepath.Join(path...), err)
}
}

// ln -s
func symlink(t *testing.T, target string, link ...string) {
t.Helper()
if len(link) < 1 {
t.Fatalf("symlink: link must have at least one element: %s", link)
}
err := os.Symlink(target, filepath.Join(link...))
if err != nil {
t.Fatalf("symlink(%q, %q): %s", target, filepath.Join(link...), err)
}
}

// cat
func cat(t *testing.T, data string, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("cat: path must have at least one element: %s", path)
}
err := os.WriteFile(filepath.Join(path...), []byte(data), 0o644)
if err != nil {
t.Fatalf("cat(%q): %s", filepath.Join(path...), err)
}
}

// touch
func touch(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("touch: path must have at least one element: %s", path)
}
fp, err := os.Create(filepath.Join(path...))
if err != nil {
t.Fatalf("touch(%q): %s", filepath.Join(path...), err)
}
err = fp.Close()
if err != nil {
t.Fatalf("touch(%q): %s", filepath.Join(path...), err)
}
}

// mv
func mv(t *testing.T, src string, dst ...string) {
t.Helper()
if len(dst) < 1 {
t.Fatalf("mv: dst must have at least one element: %s", dst)
}

var err error
switch runtime.GOOS {
case "windows", "plan9":
err = os.Rename(src, filepath.Join(dst...))
default:
err = exec.Command("mv", src, filepath.Join(dst...)).Run()
}
if err != nil {
t.Fatalf("mv(%q, %q): %s", src, filepath.Join(dst...), err)
}
}

// rm
func rm(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("rm: path must have at least one element: %s", path)
}
err := os.Remove(filepath.Join(path...))
if err != nil {
t.Fatalf("rm(%q): %s", filepath.Join(path...), err)
}
}

// rm -r
func rmAll(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("rmAll: path must have at least one element: %s", path)
}
err := os.RemoveAll(filepath.Join(path...))
if err != nil {
t.Fatalf("rmAll(%q): %s", filepath.Join(path...), err)
}
}

func errorContains(out error, want string) bool {
if out == nil {
return want == ""
}
if want == "" {
return false
}
return strings.Contains(out.Error(), want)
}

// tempMkdir makes a temporary directory
//
// Deprecated: use t.TempDir()
func tempMkdir(t *testing.T) string {
dir, err := ioutil.TempDir("", "fsnotify")
if err != nil {
t.Fatalf("failed to create test directory: %s", err)
}
return dir
}

// tempMkFile makes a temporary file.
//
// Deprecated: use t.TempDir() and cat()
func tempMkFile(t *testing.T, dir string) string {
f, err := ioutil.TempFile(dir, "fsnotify")
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
defer f.Close()
return f.Name()
}

// An atomic counter
type counter struct{ val int32 }

func (c *counter) increment() { atomic.AddInt32(&c.val, 1) }
func (c *counter) value() int32 { return atomic.LoadInt32(&c.val) }
func (c *counter) reset() { atomic.StoreInt32(&c.val, 0) }

// Collect all events in an array.
//
// w := newCollector(t)
// w.collect(r)
//
// .. do stuff ..
//
// events := w.stop(t)
type eventCollector struct {
w *Watcher
events Events
mu sync.Mutex
}
type Events []Event

func (e Events) String() string {
b := new(strings.Builder)
for i, ee := range e {
if i > 0 {
b.WriteString("\n")
}
fmt.Fprintf(b, "%-20s %q", ee.Op.String(), ee.Name)
}
return b.String()
}

func newCollector(t *testing.T) *eventCollector {
return &eventCollector{w: newWatcher(t)}
}

func (w *eventCollector) stop(t *testing.T) Events {
time.Sleep(waitForEvents)
err := w.w.Close()
if err != nil {
t.Fatal(err)
}
w.mu.Lock()
defer w.mu.Unlock()
return w.events
}

func (w *eventCollector) collect(t *testing.T) {
go func() {
for {
select {
case e, ok := <-w.w.Errors:
if !ok {
return
}
t.Error(e)
return
case e, ok := <-w.w.Events:
if !ok {
return
}
w.mu.Lock()
w.events = append(w.events, e)
w.mu.Unlock()
}
}
}()
}
Loading

0 comments on commit 26e63b3

Please sign in to comment.