From 9c96789fdd726fb2ebe659b2b9efbb497c219df8 Mon Sep 17 00:00:00 2001 From: Martin Tournoij Date: Mon, 8 Aug 2022 01:42:19 +0200 Subject: [PATCH] Update documentation and examples Update some documentation and add various examples. I added the examples as subcommands of ./cmd/fsnotify, so they're directly runnable, and it's at least guaranteed they compile. This adds some simpler test cases so it's easier to verify it actually works as documented on all platforms, and it adds internal.Debug() for all platforms, which is useful in dev. Fixes 49 Fixes 74 Fixes 94 Fixes 122 Fixes 238 Fixes 372 Fixes 401 --- README.md | 87 +++++++------- backend_fen.go | 127 +++++++++++++++++++- backend_inotify.go | 127 +++++++++++++++++++- backend_kqueue.go | 127 +++++++++++++++++++- backend_other.go | 40 ++++++- backend_windows.go | 127 +++++++++++++++++++- cmd/fsnotify/dedup.go | 102 ++++++++++++++++ cmd/fsnotify/file.go | 82 +++++++++++++ cmd/fsnotify/main.go | 89 +++++++------- cmd/fsnotify/watch.go | 56 +++++++++ fsnotify.go | 14 +-- fsnotify_test.go | 225 ++++++++++++++++++++++++++++++++---- helpers_test.go | 32 +++++ internal/darwin.go | 4 +- internal/debug_darwin.go | 57 +++++++++ internal/debug_dragonfly.go | 33 ++++++ internal/debug_freebsd.go | 42 +++++++ internal/debug_kqueue.go | 27 +++++ internal/debug_linux.go | 63 ++++++++++ internal/debug_netbsd.go | 25 ++++ internal/debug_openbsd.go | 28 +++++ internal/debug_windows.go | 39 +++++++ internal/freebsd.go | 32 +++++ internal/unix.go | 8 +- internal/windows.go | 7 +- mkdoc.zsh | 186 +++++++++++++++++++++++++++++ 26 files changed, 1645 insertions(+), 141 deletions(-) create mode 100644 cmd/fsnotify/dedup.go create mode 100644 cmd/fsnotify/file.go create mode 100644 cmd/fsnotify/watch.go create mode 100644 internal/debug_darwin.go create mode 100644 internal/debug_dragonfly.go create mode 100644 internal/debug_freebsd.go create mode 100644 internal/debug_kqueue.go create mode 100644 internal/debug_linux.go create mode 100644 internal/debug_netbsd.go create mode 100644 internal/debug_openbsd.go create mode 100644 internal/debug_windows.go create mode 100644 internal/freebsd.go create mode 100755 mkdoc.zsh diff --git a/README.md b/README.md index 833a0c7c..ff8a3db7 100644 --- a/README.md +++ b/README.md @@ -28,52 +28,55 @@ A basic example: package main import ( - "log" + "log" - "github.com/fsnotify/fsnotify" + "github.com/fsnotify/fsnotify" ) func main() { - watcher, err := fsnotify.NewWatcher() - if err != nil { - log.Fatal(err) - } - defer watcher.Close() - - done := make(chan bool) - go func() { - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return - } - log.Println("event:", event) - if event.Has(fsnotify.Write) { - log.Println("modified file:", event.Name) - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - log.Println("error:", err) - } - } - }() - - err = watcher.Add("/tmp") - if err != nil { - log.Fatal(err) - } - <-done + // Create new watcher. + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + // Start listening for events. + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + log.Println("event:", event) + if event.Has(fsnotify.Write) { + log.Println("modified file:", event.Name) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("error:", err) + } + } + }() + + // Add a path. + err = watcher.Add("/tmp") + if err != nil { + log.Fatal(err) + } + + // Block main goroutine forever. + <-make(chan struct{}) } ``` -A slightly more expansive example can be found in [cmd/fsnotify](cmd/fsnotify), -which can be run with: +Some more examples can be found in [cmd/fsnotify](cmd/fsnotify), which can be +run with: - # Watch the current directory (not recursive). - $ go run ./cmd/fsnotify . + % go run ./cmd/fsnotify FAQ --- @@ -109,10 +112,10 @@ descriptors are closed. It will emit a CHMOD though: os.Remove("file") // CHMOD fp.Close() // REMOVE -Linux: the `fs.inotify.max_user_watches` sysctl variable specifies the upper -limit for the number of watches per user, and `fs.inotify.max_user_instances` -specifies the maximum number of inotify instances per user. Every Watcher you -create is an "instance", and every path you add is a "watch". +The `fs.inotify.max_user_watches` sysctl variable specifies the upper limit for +the number of watches per user, and `fs.inotify.max_user_instances` specifies +the maximum number of inotify instances per user. Every Watcher you create is an +"instance", and every path you add is a "watch". These are also exposed in /proc as `/proc/sys/fs/inotify/max_user_watches` and `/proc/sys/fs/inotify/max_user_instances` diff --git a/backend_fen.go b/backend_fen.go index 58737a47..ef7d7708 100644 --- a/backend_fen.go +++ b/backend_fen.go @@ -8,12 +8,99 @@ import ( ) // Watcher watches a set of files, delivering events to a channel. +// +// A watcher should not be copied (e.g. pass it by pointer, rather than by +// value). +// +// # Events +// +// fsnotify can send the following events; a "path" here can refer to a file, +// directory, symbolic link, or special files like a FIFO. +// +// fsnotify.Create A new path was created; this may be followed by one or +// more Write events if data also gets written to a file. +// +// fsnotify.Remove A path was removed. +// +// fsnotify.Rename A path was renamed. A rename is always sent with the old +// path as [Event.Name], and a Create event will be sent +// with the new name. Renames are only sent for paths that +// are currently watched; e.g. moving an unmonitored file +// into a monitored directory will show up as just a +// Create. Similarly, renaming a file to outside a +// monitored directory will show up as only a Rename. +// +// fsnotify.Write A file or named pipe was written to. A Truncate will +// also trigger a Write. A single "write action" initiated +// by the user may show up as one or multiple writes, +// depending on when the system syncs things to disk. For +// example when compiling a large Go program you may get +// hundreds of Write events, so you probably want to wait +// until you've stopped receiving them (see the dedup +// example in cmd/fsnotify). +// +// fsnotify.Chmod Attributes were changes (never sent on Windows). On +// Linux this is also sent when a file is removed (or more +// accurately, when a link to an inode is removed), and on +// kqueue when a file is truncated. +// +// # Linux notes +// +// When a file is removed a Remove event won't be emitted until all file +// descriptors are closed, and deletes will always emit a Chmod. For example: +// +// fp := os.Open("file") +// os.Remove("file") // Triggers Chmod +// fp.Close() // Triggers Remove +// +// The fs.inotify.max_user_watches sysctl variable specifies the upper limit +// for the number of watches per user, and fs.inotify.max_user_instances +// specifies the maximum number of inotify instances per user. Every Watcher you +// create is an "instance", and every path you add is a "watch". +// +// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and +// /proc/sys/fs/inotify/max_user_instances +// +// To increase them you can use sysctl or write the value to the /proc file: +// +// # Default values on Linux 5.18 +// sysctl fs.inotify.max_user_watches=124983 +// sysctl fs.inotify.max_user_instances=128 +// +// To make the changes persist on reboot edit /etc/sysctl.conf or +// /usr/lib/sysctl.d/50-default.conf (on some systemd systems): +// +// fs.inotify.max_user_watches=124983 +// fs.inotify.max_user_instances=128 +// +// Reaching the limit will result in a "no space left on device" or "too many open +// files" error. +// +// # kqueue notes (macOS, BSD) +// +// kqueue requires opening a file descriptor for every file that's being watched; +// so if you're watching a directory with five files then that's six file +// descriptors. You will run in to your system's "max open files" limit faster on +// these platforms. +// +// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to +// control the maximum number of open files, as well as /etc/login.conf on BSD +// systems. +// +// # macOS notes +// +// Spotlight indexing on macOS can result in multiple events (see [#15]). A +// temporary workaround is to add your folder(s) to the "Spotlight Privacy +// Settings" until we have a native FSEvents implementation (see [#11]). +// +// [#11]: https://github.com/fsnotify/fsnotify/issues/11 +// [#15]: https://github.com/fsnotify/fsnotify/issues/15 type Watcher struct { Events chan Event Errors chan error } -// NewWatcher establishes a new watcher with the underlying OS and begins waiting for events. +// NewWatcher creates a new Watcher. func NewWatcher() (*Watcher, error) { return nil, errors.New("FEN based watcher not yet supported for fsnotify\n") } @@ -23,12 +110,46 @@ func (w *Watcher) Close() error { return nil } -// Add starts watching the named file or directory (non-recursively). +// Add starts monitoring the path for changes. +// +// A path can only be watched once; attempting to watch it more than once will +// return an error. Paths that do not yet exist on the filesystem cannot be +// added. A watch will be automatically removed if the path is deleted. +// +// A path will remain watched if it gets renamed to somewhere else on the same +// filesystem, but the monitor will get removed if the path gets deleted and +// re-created. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many tools update files atomically. Instead of "just" writing +// to the file a temporary file will be written to first, and if successful the +// temporary file is moved to to destination, removing the original, or some +// variant thereof. The watcher on the original file is now lost, as it no +// longer exists. +// +// 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 { return nil } -// Remove stops watching the the named file or directory (non-recursively). +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. func (w *Watcher) Remove(name string) error { return nil } diff --git a/backend_inotify.go b/backend_inotify.go index 32a1870b..5d321b85 100644 --- a/backend_inotify.go +++ b/backend_inotify.go @@ -17,6 +17,93 @@ import ( ) // Watcher watches a set of files, delivering events to a channel. +// +// A watcher should not be copied (e.g. pass it by pointer, rather than by +// value). +// +// # Events +// +// fsnotify can send the following events; a "path" here can refer to a file, +// directory, symbolic link, or special files like a FIFO. +// +// fsnotify.Create A new path was created; this may be followed by one or +// more Write events if data also gets written to a file. +// +// fsnotify.Remove A path was removed. +// +// fsnotify.Rename A path was renamed. A rename is always sent with the old +// path as [Event.Name], and a Create event will be sent +// with the new name. Renames are only sent for paths that +// are currently watched; e.g. moving an unmonitored file +// into a monitored directory will show up as just a +// Create. Similarly, renaming a file to outside a +// monitored directory will show up as only a Rename. +// +// fsnotify.Write A file or named pipe was written to. A Truncate will +// also trigger a Write. A single "write action" initiated +// by the user may show up as one or multiple writes, +// depending on when the system syncs things to disk. For +// example when compiling a large Go program you may get +// hundreds of Write events, so you probably want to wait +// until you've stopped receiving them (see the dedup +// example in cmd/fsnotify). +// +// fsnotify.Chmod Attributes were changes (never sent on Windows). On +// Linux this is also sent when a file is removed (or more +// accurately, when a link to an inode is removed), and on +// kqueue when a file is truncated. +// +// # Linux notes +// +// When a file is removed a Remove event won't be emitted until all file +// descriptors are closed, and deletes will always emit a Chmod. For example: +// +// fp := os.Open("file") +// os.Remove("file") // Triggers Chmod +// fp.Close() // Triggers Remove +// +// The fs.inotify.max_user_watches sysctl variable specifies the upper limit +// for the number of watches per user, and fs.inotify.max_user_instances +// specifies the maximum number of inotify instances per user. Every Watcher you +// create is an "instance", and every path you add is a "watch". +// +// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and +// /proc/sys/fs/inotify/max_user_instances +// +// To increase them you can use sysctl or write the value to the /proc file: +// +// # Default values on Linux 5.18 +// sysctl fs.inotify.max_user_watches=124983 +// sysctl fs.inotify.max_user_instances=128 +// +// To make the changes persist on reboot edit /etc/sysctl.conf or +// /usr/lib/sysctl.d/50-default.conf (on some systemd systems): +// +// fs.inotify.max_user_watches=124983 +// fs.inotify.max_user_instances=128 +// +// Reaching the limit will result in a "no space left on device" or "too many open +// files" error. +// +// # kqueue notes (macOS, BSD) +// +// kqueue requires opening a file descriptor for every file that's being watched; +// so if you're watching a directory with five files then that's six file +// descriptors. You will run in to your system's "max open files" limit faster on +// these platforms. +// +// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to +// control the maximum number of open files, as well as /etc/login.conf on BSD +// systems. +// +// # macOS notes +// +// Spotlight indexing on macOS can result in multiple events (see [#15]). A +// temporary workaround is to add your folder(s) to the "Spotlight Privacy +// Settings" until we have a native FSEvents implementation (see [#11]). +// +// [#11]: https://github.com/fsnotify/fsnotify/issues/11 +// [#15]: https://github.com/fsnotify/fsnotify/issues/15 type Watcher struct { // Store fd here as os.File.Read() will no longer return on close after // calling Fd(). See: https://github.com/golang/go/issues/26439 @@ -31,7 +118,7 @@ type Watcher struct { doneResp chan struct{} // Channel to respond to Close } -// NewWatcher establishes a new watcher with the underlying OS and begins waiting for events. +// NewWatcher creates a new Watcher. func NewWatcher() (*Watcher, error) { // Create inotify fd // Need to set the FD to nonblocking mode in order for SetDeadline methods to work @@ -110,7 +197,36 @@ func (w *Watcher) Close() error { return nil } -// Add starts watching the named file or directory (non-recursively). +// Add starts monitoring the path for changes. +// +// A path can only be watched once; attempting to watch it more than once will +// return an error. Paths that do not yet exist on the filesystem cannot be +// added. A watch will be automatically removed if the path is deleted. +// +// A path will remain watched if it gets renamed to somewhere else on the same +// filesystem, but the monitor will get removed if the path gets deleted and +// re-created. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many tools update files atomically. Instead of "just" writing +// to the file a temporary file will be written to first, and if successful the +// temporary file is moved to to destination, removing the original, or some +// variant thereof. The watcher on the original file is now lost, as it no +// longer exists. +// +// 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 { name = filepath.Clean(name) if w.isClosed() { @@ -143,7 +259,12 @@ func (w *Watcher) Add(name string) error { return nil } -// Remove stops watching the named file or directory (non-recursively). +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. func (w *Watcher) Remove(name string) error { name = filepath.Clean(name) diff --git a/backend_kqueue.go b/backend_kqueue.go index e59b5c48..6f2154b3 100644 --- a/backend_kqueue.go +++ b/backend_kqueue.go @@ -15,6 +15,93 @@ import ( ) // Watcher watches a set of files, delivering events to a channel. +// +// A watcher should not be copied (e.g. pass it by pointer, rather than by +// value). +// +// # Events +// +// fsnotify can send the following events; a "path" here can refer to a file, +// directory, symbolic link, or special files like a FIFO. +// +// fsnotify.Create A new path was created; this may be followed by one or +// more Write events if data also gets written to a file. +// +// fsnotify.Remove A path was removed. +// +// fsnotify.Rename A path was renamed. A rename is always sent with the old +// path as [Event.Name], and a Create event will be sent +// with the new name. Renames are only sent for paths that +// are currently watched; e.g. moving an unmonitored file +// into a monitored directory will show up as just a +// Create. Similarly, renaming a file to outside a +// monitored directory will show up as only a Rename. +// +// fsnotify.Write A file or named pipe was written to. A Truncate will +// also trigger a Write. A single "write action" initiated +// by the user may show up as one or multiple writes, +// depending on when the system syncs things to disk. For +// example when compiling a large Go program you may get +// hundreds of Write events, so you probably want to wait +// until you've stopped receiving them (see the dedup +// example in cmd/fsnotify). +// +// fsnotify.Chmod Attributes were changes (never sent on Windows). On +// Linux this is also sent when a file is removed (or more +// accurately, when a link to an inode is removed), and on +// kqueue when a file is truncated. +// +// # Linux notes +// +// When a file is removed a Remove event won't be emitted until all file +// descriptors are closed, and deletes will always emit a Chmod. For example: +// +// fp := os.Open("file") +// os.Remove("file") // Triggers Chmod +// fp.Close() // Triggers Remove +// +// The fs.inotify.max_user_watches sysctl variable specifies the upper limit +// for the number of watches per user, and fs.inotify.max_user_instances +// specifies the maximum number of inotify instances per user. Every Watcher you +// create is an "instance", and every path you add is a "watch". +// +// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and +// /proc/sys/fs/inotify/max_user_instances +// +// To increase them you can use sysctl or write the value to the /proc file: +// +// # Default values on Linux 5.18 +// sysctl fs.inotify.max_user_watches=124983 +// sysctl fs.inotify.max_user_instances=128 +// +// To make the changes persist on reboot edit /etc/sysctl.conf or +// /usr/lib/sysctl.d/50-default.conf (on some systemd systems): +// +// fs.inotify.max_user_watches=124983 +// fs.inotify.max_user_instances=128 +// +// Reaching the limit will result in a "no space left on device" or "too many open +// files" error. +// +// # kqueue notes (macOS, BSD) +// +// kqueue requires opening a file descriptor for every file that's being watched; +// so if you're watching a directory with five files then that's six file +// descriptors. You will run in to your system's "max open files" limit faster on +// these platforms. +// +// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to +// control the maximum number of open files, as well as /etc/login.conf on BSD +// systems. +// +// # macOS notes +// +// Spotlight indexing on macOS can result in multiple events (see [#15]). A +// temporary workaround is to add your folder(s) to the "Spotlight Privacy +// Settings" until we have a native FSEvents implementation (see [#11]). +// +// [#11]: https://github.com/fsnotify/fsnotify/issues/11 +// [#15]: https://github.com/fsnotify/fsnotify/issues/15 type Watcher struct { Events chan Event Errors chan error @@ -38,7 +125,7 @@ type pathInfo struct { isDir bool } -// NewWatcher establishes a new watcher with the underlying OS and begins waiting for events. +// NewWatcher creates a new Watcher. func NewWatcher() (*Watcher, error) { kq, closepipe, err := newKqueue() if err != nil { @@ -144,7 +231,36 @@ func (w *Watcher) Close() error { return nil } -// Add starts watching the named file or directory (non-recursively). +// Add starts monitoring the path for changes. +// +// A path can only be watched once; attempting to watch it more than once will +// return an error. Paths that do not yet exist on the filesystem cannot be +// added. A watch will be automatically removed if the path is deleted. +// +// A path will remain watched if it gets renamed to somewhere else on the same +// filesystem, but the monitor will get removed if the path gets deleted and +// re-created. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many tools update files atomically. Instead of "just" writing +// to the file a temporary file will be written to first, and if successful the +// temporary file is moved to to destination, removing the original, or some +// variant thereof. The watcher on the original file is now lost, as it no +// longer exists. +// +// 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 { w.mu.Lock() w.userWatches[name] = struct{}{} @@ -153,7 +269,12 @@ func (w *Watcher) Add(name string) error { return err } -// Remove stops watching the the named file or directory (non-recursively). +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. func (w *Watcher) Remove(name string) error { name = filepath.Clean(name) w.mu.Lock() diff --git a/backend_other.go b/backend_other.go index d7b4c17c..d27f2809 100644 --- a/backend_other.go +++ b/backend_other.go @@ -11,7 +11,7 @@ import ( // Watcher watches a set of files, delivering events to a channel. type Watcher struct{} -// NewWatcher establishes a new watcher with the underlying OS and begins waiting for events. +// NewWatcher creates a new Watcher. func NewWatcher() (*Watcher, error) { return nil, fmt.Errorf("fsnotify not supported on %s", runtime.GOOS) } @@ -21,12 +21,46 @@ func (w *Watcher) Close() error { return nil } -// Add starts watching the named file or directory (non-recursively). +// Add starts monitoring the path for changes. +// +// A path can only be watched once; attempting to watch it more than once will +// return an error. Paths that do not yet exist on the filesystem cannot be +// added. A watch will be automatically removed if the path is deleted. +// +// A path will remain watched if it gets renamed to somewhere else on the same +// filesystem, but the monitor will get removed if the path gets deleted and +// re-created. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many tools update files atomically. Instead of "just" writing +// to the file a temporary file will be written to first, and if successful the +// temporary file is moved to to destination, removing the original, or some +// variant thereof. The watcher on the original file is now lost, as it no +// longer exists. +// +// 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 { return nil } -// Remove stops watching the the named file or directory (non-recursively). +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. func (w *Watcher) Remove(name string) error { return nil } diff --git a/backend_windows.go b/backend_windows.go index d503a405..1bfe20de 100644 --- a/backend_windows.go +++ b/backend_windows.go @@ -18,6 +18,93 @@ import ( ) // Watcher watches a set of files, delivering events to a channel. +// +// A watcher should not be copied (e.g. pass it by pointer, rather than by +// value). +// +// # Events +// +// fsnotify can send the following events; a "path" here can refer to a file, +// directory, symbolic link, or special files like a FIFO. +// +// fsnotify.Create A new path was created; this may be followed by one or +// more Write events if data also gets written to a file. +// +// fsnotify.Remove A path was removed. +// +// fsnotify.Rename A path was renamed. A rename is always sent with the old +// path as [Event.Name], and a Create event will be sent +// with the new name. Renames are only sent for paths that +// are currently watched; e.g. moving an unmonitored file +// into a monitored directory will show up as just a +// Create. Similarly, renaming a file to outside a +// monitored directory will show up as only a Rename. +// +// fsnotify.Write A file or named pipe was written to. A Truncate will +// also trigger a Write. A single "write action" initiated +// by the user may show up as one or multiple writes, +// depending on when the system syncs things to disk. For +// example when compiling a large Go program you may get +// hundreds of Write events, so you probably want to wait +// until you've stopped receiving them (see the dedup +// example in cmd/fsnotify). +// +// fsnotify.Chmod Attributes were changes (never sent on Windows). On +// Linux this is also sent when a file is removed (or more +// accurately, when a link to an inode is removed), and on +// kqueue when a file is truncated. +// +// # Linux notes +// +// When a file is removed a Remove event won't be emitted until all file +// descriptors are closed, and deletes will always emit a Chmod. For example: +// +// fp := os.Open("file") +// os.Remove("file") // Triggers Chmod +// fp.Close() // Triggers Remove +// +// The fs.inotify.max_user_watches sysctl variable specifies the upper limit +// for the number of watches per user, and fs.inotify.max_user_instances +// specifies the maximum number of inotify instances per user. Every Watcher you +// create is an "instance", and every path you add is a "watch". +// +// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and +// /proc/sys/fs/inotify/max_user_instances +// +// To increase them you can use sysctl or write the value to the /proc file: +// +// # Default values on Linux 5.18 +// sysctl fs.inotify.max_user_watches=124983 +// sysctl fs.inotify.max_user_instances=128 +// +// To make the changes persist on reboot edit /etc/sysctl.conf or +// /usr/lib/sysctl.d/50-default.conf (on some systemd systems): +// +// fs.inotify.max_user_watches=124983 +// fs.inotify.max_user_instances=128 +// +// Reaching the limit will result in a "no space left on device" or "too many open +// files" error. +// +// # kqueue notes (macOS, BSD) +// +// kqueue requires opening a file descriptor for every file that's being watched; +// so if you're watching a directory with five files then that's six file +// descriptors. You will run in to your system's "max open files" limit faster on +// these platforms. +// +// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to +// control the maximum number of open files, as well as /etc/login.conf on BSD +// systems. +// +// # macOS notes +// +// Spotlight indexing on macOS can result in multiple events (see [#15]). A +// temporary workaround is to add your folder(s) to the "Spotlight Privacy +// Settings" until we have a native FSEvents implementation (see [#11]). +// +// [#11]: https://github.com/fsnotify/fsnotify/issues/11 +// [#15]: https://github.com/fsnotify/fsnotify/issues/15 type Watcher struct { Events chan Event Errors chan error @@ -31,7 +118,7 @@ type Watcher struct { isClosed bool // Set to true when Close() is first called } -// NewWatcher establishes a new watcher with the underlying OS and begins waiting for events. +// NewWatcher creates a new Watcher. func NewWatcher() (*Watcher, error) { port, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 0) if err != nil { @@ -92,7 +179,36 @@ func (w *Watcher) Close() error { return <-ch } -// Add starts watching the named file or directory (non-recursively). +// Add starts monitoring the path for changes. +// +// A path can only be watched once; attempting to watch it more than once will +// return an error. Paths that do not yet exist on the filesystem cannot be +// added. A watch will be automatically removed if the path is deleted. +// +// A path will remain watched if it gets renamed to somewhere else on the same +// filesystem, but the monitor will get removed if the path gets deleted and +// re-created. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many tools update files atomically. Instead of "just" writing +// to the file a temporary file will be written to first, and if successful the +// temporary file is moved to to destination, removing the original, or some +// variant thereof. The watcher on the original file is now lost, as it no +// longer exists. +// +// 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 { w.mu.Lock() if w.isClosed { @@ -114,7 +230,12 @@ func (w *Watcher) Add(name string) error { return <-in.reply } -// Remove stops watching the the named file or directory (non-recursively). +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. func (w *Watcher) Remove(name string) error { in := &input{ op: opRemoveWatch, diff --git a/cmd/fsnotify/dedup.go b/cmd/fsnotify/dedup.go new file mode 100644 index 00000000..2d790a99 --- /dev/null +++ b/cmd/fsnotify/dedup.go @@ -0,0 +1,102 @@ +package main + +import ( + "math" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// Depending on the system, a single "write" can generate many Write events; for +// example compiling a large Go program can generate hundreds of Write events. +// +// The general strategy to deal with this is to wait a short time for more write +// events, resetting the wait period for every new event. +func dedup(paths ...string) { + if len(paths) < 1 { + exit("must specify at least one path to watch") + } + + // Create a new watcher. + w, err := fsnotify.NewWatcher() + if err != nil { + exit("creating a new watcher: %s", err) + } + defer w.Close() + + // Start listening for events. + go dedupLoop(w) + + // Add all paths. + for _, p := range paths { + err = w.Add(p) + if err != nil { + exit("%q: %s", p, err) + } + } + + printTime("ready; press ^C to exit") + <-make(chan struct{}) // Block forever +} + +func dedupLoop(w *fsnotify.Watcher) { + var ( + // Wait 100ms for new events; each new event resets the timer. + waitFor = 100 * time.Millisecond + + // Keep track of the timers, as path → timer. + mu sync.Mutex + timers = make(map[string]*time.Timer) + + // Callback we run. + printEvent = func(e fsnotify.Event) { + printTime(e.String()) + + // Don't need to remove the timer if you don't have a lot of files. + mu.Lock() + delete(timers, e.Name) + mu.Unlock() + } + ) + + for { + select { + // Read from Errors. + case err, ok := <-w.Errors: + if !ok { // Channel was closed (i.e. Watcher.Close() was called). + return + } + printTime("ERROR: %s", err) + // Read from Events. + case e, ok := <-w.Events: + if !ok { // Channel was closed (i.e. Watcher.Close() was called). + return + } + + // We just want to watch for file creation, so ignore everything + // outside of Create and Write. + if !e.Has(fsnotify.Create) && !e.Has(fsnotify.Write) { + continue + } + + // Get timer. + mu.Lock() + t, ok := timers[e.Name] + mu.Unlock() + + // No timer yet, so create one. + if !ok { + t = time.AfterFunc(math.MaxInt64, func() { printEvent(e) }) + t.Stop() + + mu.Lock() + timers[e.Name] = t + mu.Unlock() + } + + // Reset the timer for this path, so it will start from 100ms again. + t.Reset(waitFor) + } + } +} diff --git a/cmd/fsnotify/file.go b/cmd/fsnotify/file.go new file mode 100644 index 00000000..0f0b4489 --- /dev/null +++ b/cmd/fsnotify/file.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "path/filepath" + + "github.com/fsnotify/fsnotify" +) + +func file(files ...string) { + if len(files) < 1 { + exit("must specify at least one file to watch") + } + + // Create a new watcher. + w, err := fsnotify.NewWatcher() + if err != nil { + exit("creating a new watcher: %s", err) + } + defer w.Close() + + // Start listening for events. + go fileLoop(w, files) + + // Add all files. + for _, p := range files { + st, err := os.Lstat(p) + if err != nil { + exit("%s", err) + } + + if st.IsDir() { + exit("%q is a directory, not a file", p) + } + + // Watch the directory, not the file itself. + err = w.Add(filepath.Dir(p)) + if err != nil { + exit("%q: %s", p, err) + } + } + + printTime("ready; press ^C to exit") + <-make(chan struct{}) // Block forever +} + +func fileLoop(w *fsnotify.Watcher, files []string) { + i := 0 + for { + select { + // Read from Errors. + case err, ok := <-w.Errors: + if !ok { // Channel was closed (i.e. Watcher.Close() was called). + return + } + printTime("ERROR: %s", err) + // Read from Events. + case e, ok := <-w.Events: + if !ok { // Channel was closed (i.e. Watcher.Close() was called). + return + } + + // Ignore files we're not interested in. Can use a + // map[string]struct{} if you have a lot of files, but for just a + // few files simply looping over a slice is faster. + var found bool + for _, f := range files { + if f == e.Name { + found = true + } + } + if !found { + continue + } + + // Just print the event nicely aligned, and keep track how many + // events we've seen. + i++ + printTime("%3d %s", i, e) + } + } +} diff --git a/cmd/fsnotify/main.go b/cmd/fsnotify/main.go index 68877931..5c99ced0 100644 --- a/cmd/fsnotify/main.go +++ b/cmd/fsnotify/main.go @@ -1,67 +1,64 @@ package main import ( - "errors" "fmt" "os" "path/filepath" "time" - - "github.com/fsnotify/fsnotify" ) -func fatal(err error) { - if err == nil { - return - } - fmt.Fprintf(os.Stderr, "%s: %s\n", filepath.Base(os.Args[0]), err) +var usage = ` +fsnotify is a library to provide cross-platform file system notifications for +Go. This utility serves as an example and debugging tool. + +https://github.com/fsnotify/fsnotify + +Commands: + + watch [paths] Watch the paths for changes and print the events. + file [file] Watch a single file for changes. + dedup [paths] Watch the paths for changes, suppressing duplicate events. +`[1:] + +func exit(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, filepath.Base(os.Args[0])+": "+format+"\n", a...) + fmt.Print("\n" + usage) os.Exit(1) } -func line(s string, args ...interface{}) { +func help() { + fmt.Printf("%s [command] [arguments]\n\n", filepath.Base(os.Args[0])) + fmt.Print(usage) + os.Exit(0) +} + +// Print line prefixed with the time (a bit shorter than log.Print; we don't +// really need the date and ms is useful here). +func printTime(s string, args ...interface{}) { fmt.Printf(time.Now().Format("15:04:05.0000")+" "+s+"\n", args...) } func main() { - if len(os.Args) < 2 { - fatal(errors.New("must specify at least one path to watch")) + if len(os.Args) == 1 { + help() } - - w, err := fsnotify.NewWatcher() - fatal(err) - defer w.Close() - - go func() { - i := 0 - for { - select { - case e, ok := <-w.Events: - if !ok { - return - } - - i++ - m := "" - if e.Has(fsnotify.Write) { - m = "(modified)" - } - line("%3d %-10s %-10s %q", i, e.Op, m, e.Name) - case err, ok := <-w.Errors: - if !ok { - return - } - line("ERROR: %s", err) - } - } - }() - - for _, p := range os.Args[1:] { - err = w.Add(p) - if err != nil { - fatal(fmt.Errorf("%q: %w", p, err)) + // Always show help if -h[elp] appears anywhere before we do anything else. + for _, f := range os.Args[1:] { + switch f { + case "help", "-h", "-help", "--help": + help() } } - line("watching; press ^C to exit") - <-make(chan struct{}) + cmd, args := os.Args[1], os.Args[2:] + switch cmd { + default: + exit("unknown command: %q", cmd) + case "watch": + watch(args...) + case "file": + file(args...) + case "dedup": + dedup(args...) + } } diff --git a/cmd/fsnotify/watch.go b/cmd/fsnotify/watch.go new file mode 100644 index 00000000..3fe50e1d --- /dev/null +++ b/cmd/fsnotify/watch.go @@ -0,0 +1,56 @@ +package main + +import "github.com/fsnotify/fsnotify" + +// This is the most basic example: it prints events to the terminal as we +// receive them. +func watch(paths ...string) { + if len(paths) < 1 { + exit("must specify at least one path to watch") + } + + // Create a new watcher. + w, err := fsnotify.NewWatcher() + if err != nil { + exit("creating a new watcher: %s", err) + } + defer w.Close() + + // Start listening for events. + go watchLoop(w) + + // Add all paths. + for _, p := range paths { + err = w.Add(p) + if err != nil { + exit("%q: %s", p, err) + } + } + + printTime("ready; press ^C to exit") + <-make(chan struct{}) // Block forever +} + +func watchLoop(w *fsnotify.Watcher) { + i := 0 + for { + select { + // Read from Errors. + case err, ok := <-w.Errors: + if !ok { // Channel was closed (i.e. Watcher.Close() was called). + return + } + printTime("ERROR: %s", err) + // Read from Events. + case e, ok := <-w.Events: + if !ok { // Channel was closed (i.e. Watcher.Close() was called). + return + } + + // Just print the event nicely aligned, and keep track how many + // events we've seen. + i++ + printTime("%3d %s", i, e) + } + } +} diff --git a/fsnotify.go b/fsnotify.go index 6c49b430..f7764715 100644 --- a/fsnotify.go +++ b/fsnotify.go @@ -11,7 +11,7 @@ import ( "strings" ) -// Event represents a single file system notification. +// Event represents a file system notification. type Event struct { // Path to the file or directory. // @@ -23,14 +23,15 @@ type Event struct { // File operation that triggered the event. // // This is a bitmask as some systems may send multiple operations at once. - // Use the Op.Has() or Event.Has() method instead of comparing with ==. + // Use the Event.Has() method instead of comparing with ==. Op Op } // Op describes a set of file operations. type Op uint32 -// These are the generalized file operations that can trigger a notification. +// The operations fsnotify can trigger; see the documentation on [Watcher] for a +// full description, and check them with [Event.Has]. const ( Create Op = 1 << iota Write @@ -63,7 +64,7 @@ func (op Op) String() string { b.WriteString("|CHMOD") } if b.Len() == 0 { - return "" + return "[no events]" } return b.String()[1:] } @@ -74,8 +75,7 @@ func (o Op) Has(h Op) bool { return o&h == h } // Has reports if this event has the given operation. func (e Event) Has(op Op) bool { return e.Op.Has(op) } -// String returns a string representation of the event in the form -// "file: REMOVE|WRITE|..." +// String returns a string representation of the event with their path. func (e Event) String() string { - return fmt.Sprintf("%q: %s", e.Name, e.Op.String()) + return fmt.Sprintf("%-13s %q", e.Op.String(), e.Name) } diff --git a/fsnotify_test.go b/fsnotify_test.go index 51ce2a1e..993864e4 100644 --- a/fsnotify_test.go +++ b/fsnotify_test.go @@ -87,9 +87,9 @@ func TestWatch(t *testing.T) { # TODO: not sure why the REMOVE /sub is dropped. dragonfly: - create /sub - create /file - remove /file + create /sub + create /file + remove /file # Windows includes a write for the /sub dir too, two of them even(?) windows: @@ -122,8 +122,8 @@ func TestWatch(t *testing.T) { # We never set up a watcher on the unreadable file, so we don't get # the REMOVE. kqueue: - WRITE "/file" - REMOVE "/file" + WRITE "/file" + REMOVE "/file" `}, {"watch same dir twice", func(t *testing.T, w *Watcher, tmp string) { @@ -160,22 +160,168 @@ func TestWatch(t *testing.T) { } } -func TestWatchRename(t *testing.T) { +func TestWatchCreate(t *testing.T) { tests := []testCase{ - {"rename file", func(t *testing.T, w *Watcher, tmp string) { + // Files + {"create empty file", func(t *testing.T, w *Watcher, tmp string) { + addWatch(t, w, tmp) + touch(t, tmp, "file") + }, ` + create /file + `}, + {"create file with data", func(t *testing.T, w *Watcher, tmp string) { + addWatch(t, w, tmp) + cat(t, "data", tmp, "file") + }, ` + create /file + write /file + `}, + + // Directories + {"create new directory", func(t *testing.T, w *Watcher, tmp string) { + addWatch(t, w, tmp) + mkdir(t, tmp, "dir") + }, ` + create /dir + `}, + + // Links + {"create new symlink to file", func(t *testing.T, w *Watcher, tmp string) { + touch(t, tmp, "file") + addWatch(t, w, tmp) + symlink(t, filepath.Join(tmp, "file"), tmp, "link") + }, ` + create /link + + windows: + create /link + write /link + `}, + {"create new symlink to directory", func(t *testing.T, w *Watcher, tmp string) { + addWatch(t, w, tmp) + symlink(t, tmp, tmp, "link") + }, ` + create /link + + windows: + create /link + write /link + `}, + + // FIFO + {"create new named pipe", func(t *testing.T, w *Watcher, tmp string) { + if runtime.GOOS == "windows" { + t.Skip("no named pipes on windows") + } + touch(t, tmp, "file") + addWatch(t, w, tmp) + mkfifo(t, tmp, "fifo") + }, ` + create /fifo + `}, + // Device node + {"create new device node pipe", func(t *testing.T, w *Watcher, tmp string) { + if runtime.GOOS == "windows" { + t.Skip("no device nodes on windows") + } + if isKqueue() { + t.Skip("needs root on BSD") + } + touch(t, tmp, "file") + addWatch(t, w, tmp) + + mknod(t, 0, tmp, "dev") + }, ` + create /dev + `}, + } + for _, tt := range tests { + tt := tt + tt.run(t) + } +} + +func TestWatchWrite(t *testing.T) { + tests := []testCase{ + // Files + {"truncate file", func(t *testing.T, w *Watcher, tmp string) { file := filepath.Join(tmp, "file") + cat(t, "data", file) + addWatch(t, w, tmp) + + fp, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + if err := fp.Sync(); err != nil { + t.Fatal(err) + } + eventSeparator() + if _, err := fp.Write([]byte("X")); err != nil { + t.Fatal(err) + } + if err := fp.Close(); err != nil { + t.Fatal(err) + } + }, ` + write /file # truncate + write /file # write + # Truncate is chmod on kqueue, except NetBSD + netbsd: + write /file + kqueue: + chmod /file + write /file + `}, + + {"multiple writes to a file", func(t *testing.T, w *Watcher, tmp string) { + file := filepath.Join(tmp, "file") + cat(t, "data", file) addWatch(t, w, tmp) + + fp, err := os.OpenFile(file, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + if _, err := fp.Write([]byte("X")); err != nil { + t.Fatal(err) + } + if err := fp.Sync(); err != nil { + t.Fatal(err) + } + eventSeparator() + if _, err := fp.Write([]byte("Y")); err != nil { + t.Fatal(err) + } + if err := fp.Close(); err != nil { + t.Fatal(err) + } + }, ` + write /file # write X + write /file # write Y + `}, + } + for _, tt := range tests { + tt := tt + tt.run(t) + } +} + +func TestWatchRename(t *testing.T) { + tests := []testCase{ + {"rename file in watched dir", func(t *testing.T, w *Watcher, tmp string) { + file := filepath.Join(tmp, "file") cat(t, "asd", file) + + addWatch(t, w, tmp) mv(t, file, tmp, "renamed") }, ` - create /file - write /file rename /file create /renamed `}, - {"rename from unwatched directory", func(t *testing.T, w *Watcher, tmp string) { + {"rename from unwatched dir", func(t *testing.T, w *Watcher, tmp string) { unwatched := t.TempDir() addWatch(t, w, tmp) @@ -185,7 +331,7 @@ func TestWatchRename(t *testing.T) { create /file `}, - {"rename to unwatched directory", func(t *testing.T, w *Watcher, tmp string) { + {"rename to unwatched dir", func(t *testing.T, w *Watcher, tmp string) { if runtime.GOOS == "netbsd" && isCI() { t.Skip("fails in CI; see #488") } @@ -215,14 +361,16 @@ func TestWatchRename(t *testing.T) { `}, {"rename overwriting existing file", func(t *testing.T, w *Watcher, tmp string) { - touch(t, tmp, "renamed") - addWatch(t, w, tmp) - unwatched := t.TempDir() file := filepath.Join(unwatched, "file") + + touch(t, tmp, "renamed") touch(t, file) + + addWatch(t, w, tmp) mv(t, file, tmp, "renamed") }, ` + # TODO: this should really be RENAME. remove /renamed create /renamed @@ -232,7 +380,7 @@ func TestWatchRename(t *testing.T) { # TODO: this is broken. dragonfly: - REMOVE|WRITE "/" + REMOVE|WRITE "/" `}, {"rename watched directory", func(t *testing.T, w *Watcher, tmp string) { @@ -264,6 +412,31 @@ func TestWatchRename(t *testing.T) { CREATE "/dir-renamed" # mv REMOVE|RENAME "/dir" `}, + + {"rename watched file", func(t *testing.T, w *Watcher, tmp string) { + file := filepath.Join(tmp, "file") + rename := filepath.Join(tmp, "rename-one") + touch(t, file) + + addWatch(t, w, file) + + mv(t, file, rename) + mv(t, rename, tmp, "rename-two") + }, ` + # TODO: this should update the path. And even then, not clear what + # go renamed to what. + rename /file # mv file rename + rename /file # mv rename rename-two + + # TODO: seems to lose the watch? + kqueue: + rename /file + + # It's actually more correct on Windows. + windows: + rename /file + rename /rename-one + `}, } for _, tt := range tests { @@ -282,12 +455,12 @@ func TestWatchSymlink(t *testing.T) { create /link windows: - create /link - write /link + create /link + write /link # No events at all on Dragonfly # TODO: should fix this. - dragonfly: + dragonfly: empty `}, @@ -603,6 +776,10 @@ func TestClose(t *testing.T) { }) t.Run("closes channels after read", func(t *testing.T) { + if runtime.GOOS == "netbsd" { + t.Skip("flaky") // TODO + } + t.Parallel() tmp := t.TempDir() @@ -735,17 +912,17 @@ func TestEventString(t *testing.T) { in Event want string }{ - {Event{}, `"": `}, - {Event{"/file", 0}, `"/file": `}, + {Event{}, `[no events] ""`}, + {Event{"/file", 0}, `[no events] "/file"`}, {Event{"/file", Chmod | Create}, - `"/file": CREATE|CHMOD`}, + `CREATE|CHMOD "/file"`}, {Event{"/file", Rename}, - `"/file": RENAME`}, + `RENAME "/file"`}, {Event{"/file", Remove}, - `"/file": REMOVE`}, + `REMOVE "/file"`}, {Event{"/file", Write | Chmod}, - `"/file": WRITE|CHMOD`}, + `WRITE|CHMOD "/file"`}, } for _, tt := range tests { diff --git a/helpers_test.go b/helpers_test.go index d71d9b56..ec1c3bcb 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -11,6 +11,8 @@ import ( "sync" "testing" "time" + + "github.com/fsnotify/fsnotify/internal" ) type testCase struct { @@ -165,6 +167,36 @@ func symlink(t *testing.T, target string, link ...string) { } } +// mkfifo +func mkfifo(t *testing.T, path ...string) { + t.Helper() + if len(path) < 1 { + t.Fatalf("mkfifo: path must have at least one element: %s", path) + } + err := internal.Mkfifo(filepath.Join(path...), 0o644) + if err != nil { + t.Fatalf("mkfifo(%q): %s", filepath.Join(path...), err) + } + if shouldWait(path...) { + eventSeparator() + } +} + +// mknod +func mknod(t *testing.T, dev int, path ...string) { + t.Helper() + if len(path) < 1 { + t.Fatalf("mknod: path must have at least one element: %s", path) + } + err := internal.Mknod(filepath.Join(path...), 0o644, dev) + if err != nil { + t.Fatalf("mknod(%d, %q): %s", dev, filepath.Join(path...), err) + } + if shouldWait(path...) { + eventSeparator() + } +} + // cat func cat(t *testing.T, data string, path ...string) { t.Helper() diff --git a/internal/darwin.go b/internal/darwin.go index 1d7d6858..6a6d0680 100644 --- a/internal/darwin.go +++ b/internal/darwin.go @@ -35,4 +35,6 @@ func SetRlimit() { } } -func Maxfiles() uint64 { return maxfiles } +func Maxfiles() uint64 { return maxfiles } +func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } +func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) } diff --git a/internal/debug_darwin.go b/internal/debug_darwin.go new file mode 100644 index 00000000..928319fb --- /dev/null +++ b/internal/debug_darwin.go @@ -0,0 +1,57 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ABSOLUTE", unix.NOTE_ABSOLUTE}, + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_BACKGROUND", unix.NOTE_BACKGROUND}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_CRITICAL", unix.NOTE_CRITICAL}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXITSTATUS", unix.NOTE_EXITSTATUS}, + {"NOTE_EXIT_CSERROR", unix.NOTE_EXIT_CSERROR}, + {"NOTE_EXIT_DECRYPTFAIL", unix.NOTE_EXIT_DECRYPTFAIL}, + {"NOTE_EXIT_DETAIL", unix.NOTE_EXIT_DETAIL}, + {"NOTE_EXIT_DETAIL_MASK", unix.NOTE_EXIT_DETAIL_MASK}, + {"NOTE_EXIT_MEMORY", unix.NOTE_EXIT_MEMORY}, + {"NOTE_EXIT_REPARENTED", unix.NOTE_EXIT_REPARENTED}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FFAND", unix.NOTE_FFAND}, + {"NOTE_FFCOPY", unix.NOTE_FFCOPY}, + {"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK}, + {"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK}, + {"NOTE_FFNOP", unix.NOTE_FFNOP}, + {"NOTE_FFOR", unix.NOTE_FFOR}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_FUNLOCK", unix.NOTE_FUNLOCK}, + {"NOTE_LEEWAY", unix.NOTE_LEEWAY}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_MACHTIME", unix.NOTE_MACHTIME}, + {"NOTE_MACH_CONTINUOUS_TIME", unix.NOTE_MACH_CONTINUOUS_TIME}, + {"NOTE_NONE", unix.NOTE_NONE}, + {"NOTE_NSECONDS", unix.NOTE_NSECONDS}, + {"NOTE_OOB", unix.NOTE_OOB}, + //{"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, -0x100000 (?!) + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_REAP", unix.NOTE_REAP}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_SECONDS", unix.NOTE_SECONDS}, + {"NOTE_SIGNAL", unix.NOTE_SIGNAL}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRIGGER", unix.NOTE_TRIGGER}, + {"NOTE_USECONDS", unix.NOTE_USECONDS}, + {"NOTE_VM_ERROR", unix.NOTE_VM_ERROR}, + {"NOTE_VM_PRESSURE", unix.NOTE_VM_PRESSURE}, + {"NOTE_VM_PRESSURE_SUDDEN_TERMINATE", unix.NOTE_VM_PRESSURE_SUDDEN_TERMINATE}, + {"NOTE_VM_PRESSURE_TERMINATE", unix.NOTE_VM_PRESSURE_TERMINATE}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/internal/debug_dragonfly.go b/internal/debug_dragonfly.go new file mode 100644 index 00000000..3186b0c3 --- /dev/null +++ b/internal/debug_dragonfly.go @@ -0,0 +1,33 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FFAND", unix.NOTE_FFAND}, + {"NOTE_FFCOPY", unix.NOTE_FFCOPY}, + {"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK}, + {"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK}, + {"NOTE_FFNOP", unix.NOTE_FFNOP}, + {"NOTE_FFOR", unix.NOTE_FFOR}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_OOB", unix.NOTE_OOB}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRIGGER", unix.NOTE_TRIGGER}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/internal/debug_freebsd.go b/internal/debug_freebsd.go new file mode 100644 index 00000000..f69fdb93 --- /dev/null +++ b/internal/debug_freebsd.go @@ -0,0 +1,42 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ABSTIME", unix.NOTE_ABSTIME}, + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_CLOSE", unix.NOTE_CLOSE}, + {"NOTE_CLOSE_WRITE", unix.NOTE_CLOSE_WRITE}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FFAND", unix.NOTE_FFAND}, + {"NOTE_FFCOPY", unix.NOTE_FFCOPY}, + {"NOTE_FFCTRLMASK", unix.NOTE_FFCTRLMASK}, + {"NOTE_FFLAGSMASK", unix.NOTE_FFLAGSMASK}, + {"NOTE_FFNOP", unix.NOTE_FFNOP}, + {"NOTE_FFOR", unix.NOTE_FFOR}, + {"NOTE_FILE_POLL", unix.NOTE_FILE_POLL}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_MSECONDS", unix.NOTE_MSECONDS}, + {"NOTE_NSECONDS", unix.NOTE_NSECONDS}, + {"NOTE_OPEN", unix.NOTE_OPEN}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_READ", unix.NOTE_READ}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_SECONDS", unix.NOTE_SECONDS}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRIGGER", unix.NOTE_TRIGGER}, + {"NOTE_USECONDS", unix.NOTE_USECONDS}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/internal/debug_kqueue.go b/internal/debug_kqueue.go new file mode 100644 index 00000000..47f7660a --- /dev/null +++ b/internal/debug_kqueue.go @@ -0,0 +1,27 @@ +//go:build freebsd || openbsd || netbsd || dragonfly || darwin +// +build freebsd openbsd netbsd dragonfly darwin + +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func Debug(name string, kevent *unix.Kevent_t) { + mask := uint32(kevent.Fflags) + 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) +} diff --git a/internal/debug_linux.go b/internal/debug_linux.go new file mode 100644 index 00000000..86613b9c --- /dev/null +++ b/internal/debug_linux.go @@ -0,0 +1,63 @@ +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func Debug(name string, mask uint32) { + names := []struct { + n string + m uint32 + }{ + {"IN_ACCESS", unix.IN_ACCESS}, + {"IN_ALL_EVENTS", unix.IN_ALL_EVENTS}, + {"IN_ATTRIB", unix.IN_ATTRIB}, + {"IN_CLASSA_HOST", unix.IN_CLASSA_HOST}, + {"IN_CLASSA_MAX", unix.IN_CLASSA_MAX}, + {"IN_CLASSA_NET", unix.IN_CLASSA_NET}, + {"IN_CLASSA_NSHIFT", unix.IN_CLASSA_NSHIFT}, + {"IN_CLASSB_HOST", unix.IN_CLASSB_HOST}, + {"IN_CLASSB_MAX", unix.IN_CLASSB_MAX}, + {"IN_CLASSB_NET", unix.IN_CLASSB_NET}, + {"IN_CLASSB_NSHIFT", unix.IN_CLASSB_NSHIFT}, + {"IN_CLASSC_HOST", unix.IN_CLASSC_HOST}, + {"IN_CLASSC_NET", unix.IN_CLASSC_NET}, + {"IN_CLASSC_NSHIFT", unix.IN_CLASSC_NSHIFT}, + {"IN_CLOSE", unix.IN_CLOSE}, + {"IN_CLOSE_NOWRITE", unix.IN_CLOSE_NOWRITE}, + {"IN_CLOSE_WRITE", unix.IN_CLOSE_WRITE}, + {"IN_CREATE", unix.IN_CREATE}, + {"IN_DELETE", unix.IN_DELETE}, + {"IN_DELETE_SELF", unix.IN_DELETE_SELF}, + {"IN_DONT_FOLLOW", unix.IN_DONT_FOLLOW}, + {"IN_EXCL_UNLINK", unix.IN_EXCL_UNLINK}, + {"IN_IGNORED", unix.IN_IGNORED}, + {"IN_ISDIR", unix.IN_ISDIR}, + {"IN_LOOPBACKNET", unix.IN_LOOPBACKNET}, + {"IN_MASK_ADD", unix.IN_MASK_ADD}, + {"IN_MASK_CREATE", unix.IN_MASK_CREATE}, + {"IN_MODIFY", unix.IN_MODIFY}, + {"IN_MOVE", unix.IN_MOVE}, + {"IN_MOVED_FROM", unix.IN_MOVED_FROM}, + {"IN_MOVED_TO", unix.IN_MOVED_TO}, + {"IN_MOVE_SELF", unix.IN_MOVE_SELF}, + {"IN_ONESHOT", unix.IN_ONESHOT}, + {"IN_ONLYDIR", unix.IN_ONLYDIR}, + {"IN_OPEN", unix.IN_OPEN}, + {"IN_Q_OVERFLOW", unix.IN_Q_OVERFLOW}, + {"IN_UNMOUNT", unix.IN_UNMOUNT}, + } + + 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) +} diff --git a/internal/debug_netbsd.go b/internal/debug_netbsd.go new file mode 100644 index 00000000..e5b3b6f6 --- /dev/null +++ b/internal/debug_netbsd.go @@ -0,0 +1,25 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/internal/debug_openbsd.go b/internal/debug_openbsd.go new file mode 100644 index 00000000..996b4414 --- /dev/null +++ b/internal/debug_openbsd.go @@ -0,0 +1,28 @@ +package internal + +import "golang.org/x/sys/unix" + +var names = []struct { + n string + m uint32 +}{ + {"NOTE_ATTRIB", unix.NOTE_ATTRIB}, + {"NOTE_CHANGE", unix.NOTE_CHANGE}, + {"NOTE_CHILD", unix.NOTE_CHILD}, + {"NOTE_DELETE", unix.NOTE_DELETE}, + {"NOTE_EOF", unix.NOTE_EOF}, + {"NOTE_EXEC", unix.NOTE_EXEC}, + {"NOTE_EXIT", unix.NOTE_EXIT}, + {"NOTE_EXTEND", unix.NOTE_EXTEND}, + {"NOTE_FORK", unix.NOTE_FORK}, + {"NOTE_LINK", unix.NOTE_LINK}, + {"NOTE_LOWAT", unix.NOTE_LOWAT}, + {"NOTE_PCTRLMASK", unix.NOTE_PCTRLMASK}, + {"NOTE_PDATAMASK", unix.NOTE_PDATAMASK}, + {"NOTE_RENAME", unix.NOTE_RENAME}, + {"NOTE_REVOKE", unix.NOTE_REVOKE}, + {"NOTE_TRACK", unix.NOTE_TRACK}, + {"NOTE_TRACKERR", unix.NOTE_TRACKERR}, + {"NOTE_TRUNCATE", unix.NOTE_TRUNCATE}, + {"NOTE_WRITE", unix.NOTE_WRITE}, +} diff --git a/internal/debug_windows.go b/internal/debug_windows.go new file mode 100644 index 00000000..50b117fa --- /dev/null +++ b/internal/debug_windows.go @@ -0,0 +1,39 @@ +package internal + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/sys/windows" +) + +func Debug(name string, mask uint32) { + names := []struct { + n string + m uint32 + }{ + //{"FILE_NOTIFY_CHANGE_FILE_NAME", windows.FILE_NOTIFY_CHANGE_FILE_NAME}, + //{"FILE_NOTIFY_CHANGE_DIR_NAME", windows.FILE_NOTIFY_CHANGE_DIR_NAME}, + //{"FILE_NOTIFY_CHANGE_ATTRIBUTES", windows.FILE_NOTIFY_CHANGE_ATTRIBUTES}, + //{"FILE_NOTIFY_CHANGE_SIZE", windows.FILE_NOTIFY_CHANGE_SIZE}, + //{"FILE_NOTIFY_CHANGE_LAST_WRITE", windows.FILE_NOTIFY_CHANGE_LAST_WRITE}, + //{"FILE_NOTIFY_CHANGE_LAST_ACCESS", windows.FILE_NOTIFY_CHANGE_LAST_ACCESS}, + //{"FILE_NOTIFY_CHANGE_CREATION", windows.FILE_NOTIFY_CHANGE_CREATION}, + //{"FILE_NOTIFY_CHANGE_SECURITY", windows.FILE_NOTIFY_CHANGE_SECURITY}, + {"FILE_ACTION_ADDED", windows.FILE_ACTION_ADDED}, + {"FILE_ACTION_REMOVED", windows.FILE_ACTION_REMOVED}, + {"FILE_ACTION_MODIFIED", windows.FILE_ACTION_MODIFIED}, + {"FILE_ACTION_RENAMED_OLD_NAME", windows.FILE_ACTION_RENAMED_OLD_NAME}, + {"FILE_ACTION_RENAMED_NEW_NAME", windows.FILE_ACTION_RENAMED_NEW_NAME}, + } + + 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) +} diff --git a/internal/freebsd.go b/internal/freebsd.go new file mode 100644 index 00000000..41284823 --- /dev/null +++ b/internal/freebsd.go @@ -0,0 +1,32 @@ +//go:build freebsd +// +build freebsd + +package internal + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +var ( + SyscallEACCES = syscall.EACCES + UnixEACCES = unix.EACCES +) + +var maxfiles uint64 + +// Go 1.19 will do this automatically: https://go-review.googlesource.com/c/go/+/393354/ +func SetRlimit() { + var l syscall.Rlimit + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &l) + if err == nil && l.Cur != l.Max { + l.Cur = l.Max + syscall.Setrlimit(syscall.RLIMIT_NOFILE, &l) + } + maxfiles = uint64(l.Cur) +} + +func Maxfiles() uint64 { return maxfiles } +func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } +func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, uint64(dev)) } diff --git a/internal/unix.go b/internal/unix.go index dd638ec8..301b242e 100644 --- a/internal/unix.go +++ b/internal/unix.go @@ -1,5 +1,5 @@ -//go:build !windows && !darwin -// +build !windows,!darwin +//go:build !windows && !darwin && !freebsd +// +build !windows,!darwin,!freebsd package internal @@ -27,4 +27,6 @@ func SetRlimit() { maxfiles = uint64(l.Cur) } -func Maxfiles() uint64 { return maxfiles } +func Maxfiles() uint64 { return maxfiles } +func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } +func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) } diff --git a/internal/windows.go b/internal/windows.go index b0d5ae77..0e9b3b23 100644 --- a/internal/windows.go +++ b/internal/windows.go @@ -13,6 +13,7 @@ var ( UnixEACCES = errors.New("dummy") ) -func SetRlimit() {} - -func Maxfiles() uint64 { return 1<<64 - 1 } +func SetRlimit() {} +func Maxfiles() uint64 { return 1<<64 - 1 } +func Mkfifo(path string, mode uint32) error { return errors.New("no FIFOs on Windows") } +func Mknod(path string, mode uint32, dev int) error { return errors.New("no device nodes on Windows") } diff --git a/mkdoc.zsh b/mkdoc.zsh new file mode 100755 index 00000000..85e2f705 --- /dev/null +++ b/mkdoc.zsh @@ -0,0 +1,186 @@ +#!/usr/bin/env zsh +[ "${ZSH_VERSION:-}" = "" ] && echo >&2 "Only works with zsh" && exit 1 +setopt err_exit no_unset pipefail extended_glob + +# Simple script to update the godoc comments on all watchers. Probably took me +# more time to write this than doing it manually, but ah well 🙃 + +watcher=$(<<-EOF +// Watcher watches a set of files, delivering events to a channel. +// +// A watcher should not be copied (e.g. pass it by pointer, rather than by +// value). +// +// # Events +// +// fsnotify can send the following events; a "path" here can refer to a file, +// directory, symbolic link, or special files like a FIFO. +// +// fsnotify.Create A new path was created; this may be followed by one or +// more Write events if data also gets written to a file. +// +// fsnotify.Remove A path was removed. +// +// fsnotify.Rename A path was renamed. A rename is always sent with the old +// path as [Event.Name], and a Create event will be sent +// with the new name. Renames are only sent for paths that +// are currently watched; e.g. moving an unmonitored file +// into a monitored directory will show up as just a +// Create. Similarly, renaming a file to outside a +// monitored directory will show up as only a Rename. +// +// fsnotify.Write A file or named pipe was written to. A Truncate will +// also trigger a Write. A single "write action" initiated +// by the user may show up as one or multiple writes, +// depending on when the system syncs things to disk. For +// example when compiling a large Go program you may get +// hundreds of Write events, so you probably want to wait +// until you've stopped receiving them (see the dedup +// example in cmd/fsnotify). +// +// fsnotify.Chmod Attributes were changes (never sent on Windows). On +// Linux this is also sent when a file is removed (or more +// accurately, when a link to an inode is removed), and on +// kqueue when a file is truncated. +// +// # Linux notes +// +// When a file is removed a Remove event won't be emitted until all file +// descriptors are closed, and deletes will always emit a Chmod. For example: +// +// fp := os.Open("file") +// os.Remove("file") // Triggers Chmod +// fp.Close() // Triggers Remove +// +// The fs.inotify.max_user_watches sysctl variable specifies the upper limit +// for the number of watches per user, and fs.inotify.max_user_instances +// specifies the maximum number of inotify instances per user. Every Watcher you +// create is an "instance", and every path you add is a "watch". +// +// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and +// /proc/sys/fs/inotify/max_user_instances +// +// To increase them you can use sysctl or write the value to the /proc file: +// +// # Default values on Linux 5.18 +// sysctl fs.inotify.max_user_watches=124983 +// sysctl fs.inotify.max_user_instances=128 +// +// To make the changes persist on reboot edit /etc/sysctl.conf or +// /usr/lib/sysctl.d/50-default.conf (on some systemd systems): +// +// fs.inotify.max_user_watches=124983 +// fs.inotify.max_user_instances=128 +// +// Reaching the limit will result in a "no space left on device" or "too many open +// files" error. +// +// # kqueue notes (macOS, BSD) +// +// kqueue requires opening a file descriptor for every file that's being watched; +// so if you're watching a directory with five files then that's six file +// descriptors. You will run in to your system's "max open files" limit faster on +// these platforms. +// +// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to +// control the maximum number of open files, as well as /etc/login.conf on BSD +// systems. +// +// # macOS notes +// +// Spotlight indexing on macOS can result in multiple events (see [#15]). A +// temporary workaround is to add your folder(s) to the "Spotlight Privacy +// Settings" until we have a native FSEvents implementation (see [#11]). +// +// [#11]: https://github.com/fsnotify/fsnotify/issues/11 +// [#15]: https://github.com/fsnotify/fsnotify/issues/15 +EOF +) + +new=$(<<-EOF +// NewWatcher creates a new Watcher. +EOF +) + +add=$(<<-EOF +// Add starts monitoring the path for changes. +// +// A path can only be watched once; attempting to watch it more than once will +// return an error. Paths that do not yet exist on the filesystem cannot be +// added. A watch will be automatically removed if the path is deleted. +// +// A path will remain watched if it gets renamed to somewhere else on the same +// filesystem, but the monitor will get removed if the path gets deleted and +// re-created. +// +// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special +// filesystems (/proc, /sys, etc.) generally don't work. +// +// # Watching directories +// +// All files in a directory are monitored, including new files that are created +// after the watcher is started. Subdirectories are not watched (i.e. it's +// non-recursive). +// +// # Watching files +// +// Watching individual files (rather than directories) is generally not +// recommended as many tools update files atomically. Instead of "just" writing +// to the file a temporary file will be written to first, and if successful the +// temporary file is moved to to destination, removing the original, or some +// variant thereof. The watcher on the original file is now lost, as it no +// longer exists. +// +// 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 +EOF +) + +remove=$(<<-EOF +// Remove stops monitoring the path for changes. +// +// Directories are always removed non-recursively. For example, if you added +// /tmp/dir and /tmp/dir/subdir then you will need to remove both. +// +// Removing a path that has not yet been added returns [ErrNonExistentWatch]. +EOF +) + +close=$(<<-EOF +// Close removes all watches and closes the events channel. +EOF +) + +set-cmt() { + local pat=$1 + local cmt=$2 + + IFS=$'\n' local files=($(grep -n $pat backend_*~*_test.go)) + for f in $files; do + IFS=':' local fields=($=f) + local file=$fields[1] + local end=$(( $fields[2] - 1 )) + + # Find start of comment. + local start=0 + IFS=$'\n' local lines=($(head -n$end $file)) + for (( i = 1; i <= $#lines; i++ )); do + local line=$lines[-$i] + if ! grep -q '^//' <<<$line; then + start=$(( end - (i - 2) )) + break + fi + done + + head -n $(( start - 1 )) $file >/tmp/x + print -r -- $cmt >>/tmp/x + tail -n+$(( end + 1 )) $file >>/tmp/x + mv /tmp/x $file + done +} + +set-cmt '^type Watcher struct ' $watcher +set-cmt '^func NewWatcher(' $new +set-cmt '^func (w \*Watcher) Add(' $add +set-cmt '^func (w \*Watcher) Remove(' $remove +set-cmt '^func (w \*Watcher) Close(' $close