diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89f6c6de..3fa43d2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,17 +113,17 @@ jobs: # illumos testillumos: runs-on: macos-12 - name: test (illumos, 1.17) + name: test (illumos, 1.19) steps: - uses: actions/checkout@v2 - - name: test (illumos, 1.17) + - name: test (illumos, 1.19) id: test uses: papertigers/illumos-vm@r38 with: prepare: | - pkg install go-117 + pkg install go-119 run: | - /opt/ooce/go-1.17/bin/go test ./... + /opt/ooce/go-1.19/bin/go test ./... # Older Debian 6, for old Linux kernels. testDebian6: diff --git a/README.md b/README.md index d4e6080f..2f5f4fd0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ fsnotify is a Go library to provide cross-platform filesystem notifications on -Windows, Linux, macOS, and BSD systems. +Windows, Linux, macOS, BSD, and illumos. Go 1.16 or newer is required; the full documentation is at https://pkg.go.dev/github.com/fsnotify/fsnotify @@ -13,17 +13,18 @@ may include additions/changes.** Platform support: | Adapter | OS | Status | -| --------------------- | ---------------| -------------------------------------------------------------| +| --------------------- | -------------- | ------------------------------------------------------------ | | inotify | Linux 2.6.32+ | Supported | | kqueue | BSD, macOS | Supported | | ReadDirectoryChangesW | Windows | Supported | +| FEN | illumos | Supported | | FSEvents | macOS | [Planned](https://github.com/fsnotify/fsnotify/issues/11) | -| FEN | Solaris 11 | [In Progress](https://github.com/fsnotify/fsnotify/pull/371) | | fanotify | Linux 5.9+ | [Maybe](https://github.com/fsnotify/fsnotify/issues/114) | | USN Journals | Windows | [Maybe](https://github.com/fsnotify/fsnotify/issues/53) | | Polling | *All* | [Maybe](https://github.com/fsnotify/fsnotify/issues/9) | -Linux and macOS should include Android and iOS, but these are currently untested. +Linux, macOS, and illumos should include Android, iOS, and Solaris, but these +are currently untested. Usage ----- diff --git a/backend_fen.go b/backend_fen.go index 1a95ad8e..d84a1e32 100644 --- a/backend_fen.go +++ b/backend_fen.go @@ -5,6 +5,12 @@ package fsnotify import ( "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "golang.org/x/sys/unix" ) // Watcher watches a set of paths, delivering events on a channel. @@ -105,16 +111,76 @@ type Watcher struct { // Errors sends any errors. Errors chan error + + mu sync.Mutex + port *unix.EventPort + done chan struct{} // Channel for sending a "quit message" to the reader goroutine + dirs map[string]struct{} // Explicitly watched directories + watches map[string]struct{} // Explicitly watched non-directories } // NewWatcher creates a new Watcher. func NewWatcher() (*Watcher, error) { - return nil, errors.New("FEN based watcher not yet supported for fsnotify\n") + w := &Watcher{ + Events: make(chan Event), + Errors: make(chan error), + dirs: make(map[string]struct{}), + watches: make(map[string]struct{}), + done: make(chan struct{}), + } + + var err error + w.port, err = unix.NewEventPort() + if err != nil { + return nil, fmt.Errorf("fsnotify.NewWatcher: %w", err) + } + + go w.readEvents() + return w, nil +} + +// sendEvent attempts to send an event to the user, returning true if the event +// was put in the channel successfully and false if the watcher has been closed. +func (w *Watcher) sendEvent(e Event) (sent bool) { + select { + case w.Events <- e: + return true + case <-w.done: + return false + } +} + +// sendError attempts to send an error to the user, returning true if the error +// was put in the channel successfully and false if the watcher has been closed. +func (w *Watcher) sendError(err error) (sent bool) { + select { + case w.Errors <- err: + return true + case <-w.done: + return false + } +} + +func (w *Watcher) isClosed() bool { + select { + case <-w.done: + return true + default: + return false + } } // Close removes all watches and closes the events channel. func (w *Watcher) Close() error { - return nil + // Take the lock used by associateFile to prevent + // lingering events from being processed after the close + w.mu.Lock() + defer w.mu.Unlock() + if w.isClosed() { + return nil + } + close(w.done) + return w.port.Close() } // Add starts monitoring the path for changes. @@ -148,6 +214,41 @@ func (w *Watcher) Close() error { // Instead, watch the parent directory and use Event.Name to filter out files // you're not interested in. There is an example of this in [cmd/fsnotify/file.go]. func (w *Watcher) Add(name string) error { + if w.isClosed() { + return errors.New("FEN watcher already closed") + } + if w.port.PathIsWatched(name) { + return nil + } + + // Currently we resolve symlinks that were explicitly requested to be + // watched. Otherwise we would use LStat here. + stat, err := os.Stat(name) + if err != nil { + return err + } + + // Associate all files in the directory. + if stat.IsDir() { + err := w.handleDirectory(name, stat, true, w.associateFile) + if err != nil { + return err + } + + w.mu.Lock() + w.dirs[name] = struct{}{} + w.mu.Unlock() + return nil + } + + err = w.associateFile(name, stat, true) + if err != nil { + return err + } + + w.mu.Lock() + w.watches[name] = struct{}{} + w.mu.Unlock() return nil } @@ -158,5 +259,328 @@ func (w *Watcher) Add(name string) error { // // Removing a path that has not yet been added returns [ErrNonExistentWatch]. func (w *Watcher) Remove(name string) error { + if w.isClosed() { + return errors.New("FEN watcher already closed") + } + if !w.port.PathIsWatched(name) { + return fmt.Errorf("%w: %s", ErrNonExistentWatch, name) + } + + // The user has expressed an intent. Immediately remove this name + // from whichever watch list it might be in. If it's not in there + // the delete doesn't cause harm. + w.mu.Lock() + delete(w.watches, name) + delete(w.dirs, name) + w.mu.Unlock() + + stat, err := os.Stat(name) + if err != nil { + return err + } + + // Remove associations for every file in the directory. + if stat.IsDir() { + err := w.handleDirectory(name, stat, false, w.dissociateFile) + if err != nil { + return err + } + return nil + } + + err = w.port.DissociatePath(name) + if err != nil { + return err + } + + return nil +} + +// readEvents contains the main loop that runs in a goroutine watching for events. +func (w *Watcher) readEvents() { + // If this function returns, the watcher has been closed and we can + // close these channels + defer func() { + close(w.Errors) + close(w.Events) + }() + + pevents := make([]unix.PortEvent, 8) + for { + count, err := w.port.Get(pevents, 1, nil) + if err != nil && err != unix.ETIME { + // Interrupted system call (count should be 0) ignore and continue + if errors.Is(err, unix.EINTR) && count == 0 { + continue + } + // Get failed because we called w.Close() + if errors.Is(err, unix.EBADF) && w.isClosed() { + return + } + // There was an error not caused by calling w.Close() + if !w.sendError(err) { + return + } + } + + p := pevents[:count] + for _, pevent := range p { + if pevent.Source != unix.PORT_SOURCE_FILE { + // Event from unexpected source received; should never happen. + if !w.sendError(errors.New("Event from unexpected source received")) { + return + } + continue + } + + err = w.handleEvent(&pevent) + if err != nil { + if !w.sendError(err) { + return + } + } + } + } +} + +func (w *Watcher) handleDirectory(path string, stat os.FileInfo, follow bool, handler func(string, os.FileInfo, bool) error) error { + files, err := os.ReadDir(path) + if err != nil { + return err + } + + // Handle all children of the directory. + for _, entry := range files { + finfo, err := entry.Info() + if err != nil { + return err + } + err = handler(filepath.Join(path, finfo.Name()), finfo, false) + if err != nil { + return err + } + } + + // And finally handle the directory itself. + return handler(path, stat, follow) +} + +// handleEvent might need to emit more than one fsnotify event +// if the events bitmap matches more than one event type +// (e.g. the file was both modified and had the +// attributes changed between when the association +// was created and the when event was returned) +func (w *Watcher) handleEvent(event *unix.PortEvent) error { + var ( + events = event.Events + path = event.Path + fmode = event.Cookie.(os.FileMode) + reRegister = true + ) + + w.mu.Lock() + _, watchedDir := w.dirs[path] + _, watchedPath := w.watches[path] + w.mu.Unlock() + isWatched := watchedDir || watchedPath + + if events&unix.FILE_DELETE != 0 { + if !w.sendEvent(Event{path, Remove}) { + return nil + } + reRegister = false + } + if events&unix.FILE_RENAME_FROM != 0 { + if !w.sendEvent(Event{path, Rename}) { + return nil + } + // Don't keep watching the new file name + reRegister = false + } + if events&unix.FILE_RENAME_TO != 0 { + // We don't report a Rename event for this case, because + // Rename events are interpreted as referring to the _old_ name + // of the file, and in this case the event would refer to the + // new name of the file. This type of rename event is not + // supported by fsnotify. + + // inotify reports a Remove event in this case, so we simulate + // this here. + if !w.sendEvent(Event{path, Remove}) { + return nil + } + // Don't keep watching the file that was removed + reRegister = false + } + + // The file is gone, nothing left to do. + if !reRegister { + if watchedDir { + w.mu.Lock() + delete(w.dirs, path) + w.mu.Unlock() + } + if watchedPath { + w.mu.Lock() + delete(w.watches, path) + w.mu.Unlock() + } + return nil + } + + // If we didn't get a deletion the file still exists and we're going to have to watch it again. + // Let's Stat it now so that we can compare permissions and have what we need + // to continue watching the file + + stat, err := os.Lstat(path) + if err != nil { + // This is unexpected, but we should still emit an event + // This happens most often on "rm -r" of a subdirectory inside a watched directory + // We get a modify event of something happening inside, but by the time + // we get here, the sudirectory is already gone. Clearly we were watching this path + // but now it is gone. Let's tell the user that it was removed. + if !w.sendEvent(Event{path, Remove}) { + return nil + } + // Suppress extra write events on removed directories; they are not informative + // and can be confusing. + return nil + } + + // resolve symlinks that were explicitly watched as we would have at Add() time. + // this helps suppress spurious Chmod events on watched symlinks + if isWatched { + stat, err = os.Stat(path) + if err != nil { + // The symlink still exists, but the target is gone. Report the Remove similar to above. + if !w.sendEvent(Event{path, Remove}) { + return nil + } + // Don't return the error + } + } + + if events&unix.FILE_MODIFIED != 0 { + if fmode.IsDir() { + if watchedDir { + if err := w.updateDirectory(path); err != nil { + return err + } + } else { + if !w.sendEvent(Event{path, Write}) { + return nil + } + } + } else { + if !w.sendEvent(Event{path, Write}) { + return nil + } + } + } + if events&unix.FILE_ATTRIB != 0 && stat != nil { + // Only send Chmod if perms changed + if stat.Mode().Perm() != fmode.Perm() { + if !w.sendEvent(Event{path, Chmod}) { + return nil + } + } + } + + if stat != nil { + // If we get here, it means we've hit an event above that requires us to + // continue watching the file or directory + return w.associateFile(path, stat, isWatched) + } return nil } + +func (w *Watcher) updateDirectory(path string) error { + // The directory was modified, so we must find unwatched entities and + // watch them. If something was removed from the directory, nothing will + // happen, as everything else should still be watched. + files, err := os.ReadDir(path) + if err != nil { + return err + } + + for _, entry := range files { + path := filepath.Join(path, entry.Name()) + if w.port.PathIsWatched(path) { + continue + } + + finfo, err := entry.Info() + if err != nil { + return err + } + err = w.associateFile(path, finfo, false) + if err != nil { + if !w.sendError(err) { + return nil + } + } + if !w.sendEvent(Event{path, Create}) { + return nil + } + } + return nil +} + +func (w *Watcher) associateFile(path string, stat os.FileInfo, follow bool) error { + if w.isClosed() { + return errors.New("FEN watcher already closed") + } + // This is primarily protecting the call to AssociatePath + // but it is important and intentional that the call to + // PathIsWatched is also protected by this mutex. + // Without this mutex, AssociatePath has been seen + // to error out that the path is already associated. + w.mu.Lock() + defer w.mu.Unlock() + + if w.port.PathIsWatched(path) { + // Remove the old association in favor of this one + // If we get ENOENT, then while the x/sys/unix wrapper + // still thought that this path was associated, + // the underlying event port did not. This call will + // have cleared up that discrepancy. The most likely + // cause is that the event has fired but we haven't + // processed it yet. + err := w.port.DissociatePath(path) + if err != nil && err != unix.ENOENT { + return err + } + } + // FILE_NOFOLLOW means we watch symlinks themselves rather than their targets + events := unix.FILE_MODIFIED|unix.FILE_ATTRIB|unix.FILE_NOFOLLOW + if follow { + // We *DO* follow symlinks for explicitly watched entries + events = unix.FILE_MODIFIED|unix.FILE_ATTRIB + } + return w.port.AssociatePath(path, stat, + events, + stat.Mode()) +} + +func (w *Watcher) dissociateFile(path string, stat os.FileInfo, unused bool) error { + if !w.port.PathIsWatched(path) { + return nil + } + return w.port.DissociatePath(path) +} + +// WatchList returns all paths added with Add() (and are not yet removed). +func (w *Watcher) WatchList() []string { + w.mu.Lock() + defer w.mu.Unlock() + + entries := make([]string, 0, len(w.watches)+len(w.dirs)) + for pathname := range w.dirs { + entries = append(entries, pathname) + } + for pathname := range w.watches { + entries = append(entries, pathname) + } + + return entries +} diff --git a/backend_fen_test.go b/backend_fen_test.go new file mode 100644 index 00000000..16df761f --- /dev/null +++ b/backend_fen_test.go @@ -0,0 +1,57 @@ +//go:build solaris +// +build solaris + +package fsnotify + +import ( + "fmt" + "path/filepath" + "strings" + "testing" +) + +func TestRemoveState(t *testing.T) { + var ( + tmp = t.TempDir() + dir = filepath.Join(tmp, "dir") + file = filepath.Join(dir, "file") + ) + mkdir(t, dir) + touch(t, file) + + w := newWatcher(t, tmp) + addWatch(t, w, tmp) + addWatch(t, w, file) + + check := func(wantDirs, wantFiles int) { + t.Helper() + if len(w.watches) != wantFiles { + var d []string + for k, v := range w.watches { + d = append(d, fmt.Sprintf("%#v = %#v", k, v)) + } + t.Errorf("unexpected number of entries in w.watches (have %d, want %d):\n%v", + len(w.watches), wantFiles, strings.Join(d, "\n")) + } + if len(w.dirs) != wantDirs { + var d []string + for k, v := range w.dirs { + d = append(d, fmt.Sprintf("%#v = %#v", k, v)) + } + t.Errorf("unexpected number of entries in w.dirs (have %d, want %d):\n%v", + len(w.dirs), wantDirs, strings.Join(d, "\n")) + } + } + + check(1, 1) + + if err := w.Remove(file); err != nil { + t.Fatal(err) + } + check(1, 0) + + if err := w.Remove(tmp); err != nil { + t.Fatal(err) + } + check(0, 0) +} diff --git a/fsnotify_test.go b/fsnotify_test.go index d8c7f5e2..ad322d35 100644 --- a/fsnotify_test.go +++ b/fsnotify_test.go @@ -1,5 +1,5 @@ -//go:build !plan9 && !solaris -// +build !plan9,!solaris +//go:build !plan9 +// +build !plan9 package fsnotify @@ -90,7 +90,12 @@ func TestWatch(t *testing.T) { create /sub create /file remove /file - + fen: + create /sub + create /file + write /sub + remove /sub + remove /file # Windows includes a write for the /sub dir too, two of them even(?) windows: create /sub @@ -466,6 +471,11 @@ func TestWatchRename(t *testing.T) { CREATE "/dir" # mkdir CREATE "/dir-renamed" # mv REMOVE|RENAME "/dir" + fen: + CREATE "/dir" # mkdir + RENAME "/dir" # mv + CREATE "/dir-renamed" + WRITE "/dir-renamed" # touch `}, {"rename watched file", func(t *testing.T, w *Watcher, tmp string) { @@ -484,7 +494,7 @@ func TestWatchRename(t *testing.T) { rename /file # mv rename rename-two # TODO: seems to lose the watch? - kqueue: + kqueue, fen: rename /file # It's actually more correct on Windows. @@ -517,7 +527,7 @@ func TestWatchRename(t *testing.T) { WRITE "" # TODO: wrong. - kqueue: + kqueue, fen: RENAME "/file" WRITE "/file" `}, @@ -573,7 +583,7 @@ func TestWatchSymlink(t *testing.T) { write /link create /link - linux, windows: + linux, windows, fen: remove /link create /link write /link @@ -705,6 +715,9 @@ func TestWatchRm(t *testing.T) { linux: remove /file remove / + fen: + remove / + remove /file windows: remove /file remove / @@ -724,7 +737,7 @@ func TestClose(t *testing.T) { // Need a small sleep as Close() on kqueue does all sorts of things, // which may take a little bit. switch runtime.GOOS { - case "freebsd", "openbsd", "netbsd", "dragonfly", "darwin": + case "freebsd", "openbsd", "netbsd", "dragonfly", "darwin", "solaris", "illumos": time.Sleep(5 * time.Millisecond) } diff --git a/helpers_test.go b/helpers_test.go index ec1c3bcb..3edda65a 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -521,13 +521,9 @@ func newEvents(t *testing.T, s string) Events { if e, ok := events["kqueue"]; ok { return e } - // Fall back to solaris for illumos, and vice versa. - case "solaris": - if e, ok := events["illumos"]; ok { - return e - } - case "illumos": - if e, ok := events["solaris"]; ok { + // fen shortcut + case "solaris", "illumos": + if e, ok := events["fen"]; ok { return e } } diff --git a/internal/debug_solaris.go b/internal/debug_solaris.go new file mode 100644 index 00000000..e432818e --- /dev/null +++ b/internal/debug_solaris.go @@ -0,0 +1,37 @@ +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func Debug(name string, mask int32) { + names := []struct { + n string + m int32 + }{ + {"FILE_ACCESS", unix.FILE_ACCESS}, + {"FILE_MODIFIED", unix.FILE_MODIFIED}, + {"FILE_ATTRIB", unix.FILE_ATTRIB}, + {"FILE_TRUNC", unix.FILE_TRUNC}, + {"FILE_NOFOLLOW", unix.FILE_NOFOLLOW}, + {"FILE_DELETE", unix.FILE_DELETE}, + {"FILE_RENAME_TO", unix.FILE_RENAME_TO}, + {"FILE_RENAME_FROM", unix.FILE_RENAME_FROM}, + {"UNMOUNTED", unix.UNMOUNTED}, + {"MOUNTEDOVER", unix.MOUNTEDOVER}, + {"FILE_EXCEPTION", unix.FILE_EXCEPTION}, + } + + var l []string + for _, n := range names { + if mask&n.m == n.m { + l = append(l, n.n) + } + } + fmt.Fprintf(os.Stderr, "%s %-20s → %s\n", time.Now().Format("15:04:05.0000"), strings.Join(l, " | "), name) +}