]> go.fuhry.dev Git - fsnotify.git/commitdiff
Add FEN backend to support illumos and Solaris (#371)
authorNahum Shalman <nahamu@gmail.com>
Thu, 13 Oct 2022 01:25:12 +0000 (21:25 -0400)
committerGitHub <noreply@github.com>
Thu, 13 Oct 2022 01:25:12 +0000 (03:25 +0200)
Add support for illumos and Solaris.

Co-authored-by: Martin Tournoij <martin@arp242.net>
.github/workflows/test.yml
README.md
backend_fen.go
backend_fen_test.go [new file with mode: 0644]
fsnotify_test.go
helpers_test.go
internal/debug_solaris.go [new file with mode: 0644]

index 89f6c6de99d2af9b29f398ee720661451cb01097..3fa43d2ce13c06328749116215bb5b6f50f3e731 100644 (file)
@@ -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:
index d4e6080feb269700b59f3fdd49de475a05c6abd4..2f5f4fd01ab150a02a39d87f8a92efd12c3d6710 100644 (file)
--- 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
 -----
index 1a95ad8e7ce641228f74590c0cb512eec08d3187..d84a1e326b11533abc57e073a8783684f4253417 100644 (file)
@@ -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 (file)
index 0000000..16df761
--- /dev/null
@@ -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)
+}
index d8c7f5e2aaad6df22b00e734bc58e0a2347f93ed..ad322d3539b47286a3057fbadd4e52c554f727e5 100644 (file)
@@ -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)
                }
 
index ec1c3bcbc5e96efdd34c04f2183144071b1cad1d..3edda65af1dc38ac34187852462f72fc055e0338 100644 (file)
@@ -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 (file)
index 0000000..e432818
--- /dev/null
@@ -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)
+}