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