From cec3ac29d0d1364ef047f6136e24f20df9f254aa Mon Sep 17 00:00:00 2001 From: Jakub Dobry Date: Mon, 2 Dec 2024 20:16:22 +0100 Subject: [PATCH 1/6] Enable recursive folder watching (#1) --- README.md | 18 +++++++----------- backend_fen.go | 2 +- backend_inotify.go | 2 +- backend_kqueue.go | 2 +- backend_windows.go | 2 +- cmd/fsnotify/dedup.go | 2 +- cmd/fsnotify/file.go | 2 +- cmd/fsnotify/watch.go | 2 +- fsnotify.go | 7 ++++--- fsnotify_test.go | 2 +- go.mod | 2 +- helpers_test.go | 4 ++-- 12 files changed, 22 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1f4eb583..f3fcc5b8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,16 @@ -fsnotify is a Go library to provide cross-platform filesystem notifications on -Windows, Linux, macOS, BSD, and illumos. - -Go 1.17 or newer is required; the full documentation is at -https://pkg.go.dev/github.com/fsnotify/fsnotify +Fork of the original [fsnotify](https://github.com/fsnotify/fsnotify) library that enables recursive watching. +Because of the current implementation limitations, the only supported platforms are updated in the table below. --- Platform support: | Backend | OS | Status | -| :-------------------- | :--------- | :------------------------------------------------------------------------ | +| :-------------------- | :--------- |:--------------------------------------------------------------------------| | inotify | Linux | Supported | -| kqueue | BSD, macOS | Supported | +| kqueue | BSD, macOS | [Not Yet](https://github.com/fsnotify/fsnotify/issues/18) | | ReadDirectoryChangesW | Windows | Supported | -| FEN | illumos | Supported | +| FEN | illumos | [Not Yet](https://github.com/fsnotify/fsnotify/issues/18) | | fanotify | Linux 5.9+ | [Not yet](https://github.com/fsnotify/fsnotify/issues/114) | | FSEvents | macOS | [Needs support in x/sys/unix][fsevents] | | USN Journals | Windows | [Needs support in x/sys/windows][usn] | @@ -35,7 +32,7 @@ package main import ( "log" - "github.com/fsnotify/fsnotify" + "github.com/e2b-dev/fsnotify" ) func main() { @@ -92,8 +89,7 @@ FAQ No, not unless you are watching the location it was moved to. ### Are subdirectories watched? -No, you must add watches for any directory you want to watch (a recursive -watcher is on the roadmap: [#18]). +Yes. The provided path has to end with "..." to watch subdirectories. [#18]: https://github.com/fsnotify/fsnotify/issues/18 diff --git a/backend_fen.go b/backend_fen.go index e43c6d08..c927c290 100644 --- a/backend_fen.go +++ b/backend_fen.go @@ -15,7 +15,7 @@ import ( "sync" "time" - "github.com/fsnotify/fsnotify/internal" + "github.com/e2b-dev/fsnotify/internal" "golang.org/x/sys/unix" ) diff --git a/backend_inotify.go b/backend_inotify.go index 4bba8773..8ad84f83 100644 --- a/backend_inotify.go +++ b/backend_inotify.go @@ -14,7 +14,7 @@ import ( "time" "unsafe" - "github.com/fsnotify/fsnotify/internal" + "github.com/e2b-dev/fsnotify/internal" "golang.org/x/sys/unix" ) diff --git a/backend_kqueue.go b/backend_kqueue.go index 4fc6dc5b..c26324b9 100644 --- a/backend_kqueue.go +++ b/backend_kqueue.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/fsnotify/fsnotify/internal" + "github.com/e2b-dev/fsnotify/internal" "golang.org/x/sys/unix" ) diff --git a/backend_windows.go b/backend_windows.go index 3433642d..e9004ab9 100644 --- a/backend_windows.go +++ b/backend_windows.go @@ -18,7 +18,7 @@ import ( "time" "unsafe" - "github.com/fsnotify/fsnotify/internal" + "github.com/e2b-dev/fsnotify/internal" "golang.org/x/sys/windows" ) diff --git a/cmd/fsnotify/dedup.go b/cmd/fsnotify/dedup.go index 67269d44..9ed2a4f3 100644 --- a/cmd/fsnotify/dedup.go +++ b/cmd/fsnotify/dedup.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/fsnotify/fsnotify" + "github.com/e2b-dev/fsnotify" ) // Depending on the system, a single "write" can generate many Write events; for diff --git a/cmd/fsnotify/file.go b/cmd/fsnotify/file.go index 357e6937..0ac39de7 100644 --- a/cmd/fsnotify/file.go +++ b/cmd/fsnotify/file.go @@ -4,7 +4,7 @@ import ( "os" "path/filepath" - "github.com/fsnotify/fsnotify" + "github.com/e2b-dev/fsnotify" ) // Watch one or more files, but instead of watching the file directly it watches diff --git a/cmd/fsnotify/watch.go b/cmd/fsnotify/watch.go index 046a1331..5862add6 100644 --- a/cmd/fsnotify/watch.go +++ b/cmd/fsnotify/watch.go @@ -1,6 +1,6 @@ package main -import "github.com/fsnotify/fsnotify" +import "github.com/e2b-dev/fsnotify" // This is the most basic example: it prints events to the terminal as we // receive them. diff --git a/fsnotify.go b/fsnotify.go index c962a0fa..5975b290 100644 --- a/fsnotify.go +++ b/fsnotify.go @@ -468,12 +468,13 @@ func withOps(op Op) addOpt { return func(opt *withOpts) { opt.op = op } } -// "Internal" option for recursive watches on inotify. -func withCreate() addOpt { +// WithCreate "Internal" option for recursive watches on inotify. +func WithCreate() addOpt { return func(opt *withOpts) { opt.sendCreate = true } } -var enableRecurse = false +// Enable recursive folder watching +var enableRecurse = true // Check if this path is recursive (ends with "/..." or "\..."), and return the // path with the /... stripped. diff --git a/fsnotify_test.go b/fsnotify_test.go index 11d88b46..7bdff059 100644 --- a/fsnotify_test.go +++ b/fsnotify_test.go @@ -16,7 +16,7 @@ import ( "testing" "time" - "github.com/fsnotify/fsnotify/internal" + "github.com/e2b-dev/fsnotify/internal" ) func init() { diff --git a/go.mod b/go.mod index 44bf2798..869384c2 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/fsnotify/fsnotify +module github.com/e2b-dev/fsnotify go 1.19 diff --git a/helpers_test.go b/helpers_test.go index b22edca5..2f1d204a 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/fsnotify/fsnotify/internal" - "github.com/fsnotify/fsnotify/internal/ztest" + "github.com/e2b-dev/fsnotify/internal" + "github.com/e2b-dev/fsnotify/internal/ztest" ) // We wait a little bit after most commands; gives the system some time to sync From 8d0c74fca169eb78f6b8e6546cd4578e00ee6542 Mon Sep 17 00:00:00 2001 From: Jakub Dobry Date: Mon, 16 Dec 2024 15:47:36 +0100 Subject: [PATCH 2/6] Scan folder recursively on watch addition (#2) --- backend_inotify.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend_inotify.go b/backend_inotify.go index 8ad84f83..9fa22902 100644 --- a/backend_inotify.go +++ b/backend_inotify.go @@ -510,6 +510,36 @@ func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offs w.watches.wd[k] = ww } } + } else { + // Register a newly created dir tree + // This is for "mkdir -p one/two/three". + // Usually all those directories will be created before we can set up + // watchers on the subdirectories, so only "one" would be sent + // as a Create event and not "one/two" and "one/two/three" + // (inotifywait -r has the same problem). + err = filepath.WalkDir(ev.Name, func(curDir string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if ev.Name != curDir { + // Send artificial create event. + // We don't know what has really happened. + + // Send the previous event first to maintain proper ordering, then create a new event for the current directory + // The function expects to return only one event, so we need to send the previous event first and then create a new one + w.sendEvent(ev) + ev = w.newEvent(curDir, unix.IN_CREATE, 0) + } + + if d.IsDir() { + return w.register(curDir, watch.flags, flagRecurse) + } + return nil + }) + if !w.sendError(err) { + return Event{}, false + } } } } From 2a5ac7b31054f36fe137bdfbefd0335dca0c2b38 Mon Sep 17 00:00:00 2001 From: Jakub Novak Date: Thu, 31 Jul 2025 15:14:04 +0200 Subject: [PATCH 3/6] Skip the folder in recursive mode if the user doesn't have permissions --- backend_inotify.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend_inotify.go b/backend_inotify.go index 9fa22902..a4975603 100644 --- a/backend_inotify.go +++ b/backend_inotify.go @@ -248,6 +248,17 @@ func (w *inotify) AddWith(path string, opts ...addOpt) error { w.sendEvent(Event{Name: root, Op: Create}) } + // If there's a dir for which we don't have read permissions, + // skip it silently. If it's the root directory itself, return an error. + _, infoErr := d.Info() + if infoErr != nil && errors.Is(infoErr, os.ErrPermission) { + if root == path { + return fmt.Errorf("fsnotify: not a readable directory: %q", path) + } + + return filepath.SkipDir // Skip this directory silently. + } + wf := flagRecurse if root == path { wf |= flagByUser @@ -532,6 +543,13 @@ func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offs ev = w.newEvent(curDir, unix.IN_CREATE, 0) } + // If there's a dir for which we don't have read permissions, + // skip it silently. If it's the root directory itself, return an error. + _, infoErr := d.Info() + if infoErr != nil && errors.Is(infoErr, os.ErrPermission) { + return filepath.SkipDir // Skip this directory silently. + } + if d.IsDir() { return w.register(curDir, watch.flags, flagRecurse) } From c6f96512c9d235d222c1c4fc20fc6eb9f1e70858 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 23 Oct 2026 17:11:41 +0000 Subject: [PATCH 4/6] windows: test that sendError keeps Close's reply channel Close queues a reply channel on done and waits for the reader to send on it after its cleanup. sendError takes that channel when the watcher is closing and drops it, so the reader never sees the shutdown request and Close blocks forever. These tests pin the channel handoff and the end-to-end Close; they fail before the fix. --- backend_windows_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/backend_windows_test.go b/backend_windows_test.go index 6364f43a..93e968c7 100644 --- a/backend_windows_test.go +++ b/backend_windows_test.go @@ -3,9 +3,11 @@ package fsnotify import ( + "errors" "fmt" "strings" "testing" + "time" ) func TestRemoveState(t *testing.T) { @@ -71,3 +73,60 @@ func TestWindowsRemWatch(t *testing.T) { t.Fatal("Should be fail with closed handle\n") } } + +// sendError on a closing watcher must leave Close's reply channel queued for +// the reader. If it is dropped, the reader never runs the shutdown cleanup and +// Close waits on the reply forever. +func TestSendErrorKeepsCloseReply(t *testing.T) { + w := &readDirChangesW{ + Errors: make(chan error), // nobody reads errors any more + done: make(chan chan<- error, 1), + } + reply := make(chan error) + w.done <- reply + + if w.sendError(errors.New("overflow")) { + t.Fatal("sendError reported the error as delivered") + } + select { + case got := <-w.done: + if got != reply { + t.Fatal("sendError replaced Close's reply channel") + } + default: + t.Fatal("sendError dropped Close's reply channel") + } +} + +// Close must return when the error consumer has gone away and the backend +// then reports an error: the reader keeps the reply and completes shutdown. +func TestCloseAfterUnreadError(t *testing.T) { + w, err := NewWatcher() + if err != nil { + t.Fatal(err) + } + b := w.b.(*readDirChangesW) + + // Close queues its reply and wakes the reader; an error raised before the + // reader takes it meets an Errors channel nobody reads. + b.mu.Lock() + b.closed = true + b.mu.Unlock() + reply := make(chan error) + b.done <- reply + if b.sendError(errors.New("overflow")) { + t.Fatal("sendError reported the error as delivered") + } + if err := b.wakeupReader(); err != nil { + t.Fatal(err) + } + + select { + case err := <-reply: + if err != nil { + t.Fatalf("shutdown: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("the reader never completed shutdown after an undelivered error") + } +} From 73a141ee4ffb12b86ddd7644dc97ee0a22b5086f Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 23 Oct 2026 17:11:41 +0000 Subject: [PATCH 5/6] windows: keep Close's reply channel when an error cannot be delivered sendError received the reply channel that Close queued on done and discarded it, unlike sendEvent, which puts it back. When nobody reads Errors any more and the backend then reports an error (an overflow, a failed CancelIo or CloseHandle), the reader handled the wakeup without the shutdown request, skipped the cleanup of its watches and the completion port, and Close waited on the reply forever. sendError now puts the channel back, as sendEvent does; TestSendErrorKeepsCloseReply and TestCloseAfterUnreadError fail without this change on windows-latest and pass with it. --- backend_windows.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend_windows.go b/backend_windows.go index e9004ab9..869158c9 100644 --- a/backend_windows.go +++ b/backend_windows.go @@ -76,12 +76,16 @@ func (w *readDirChangesW) sendEvent(name, renamedFrom string, mask uint64) bool } // Returns true if the error was sent, or false if watcher is closed. +// +// Close's reply channel is put back, as sendEvent does: the reader takes it +// on its next wakeup and runs the cleanup, and Close blocks until it does. func (w *readDirChangesW) sendError(err error) bool { if err == nil { return true } select { - case <-w.done: + case ch := <-w.done: + w.done <- ch return false case w.Errors <- err: return true From 17a6ca9fec2be51a501029b163fce259b653e334 Mon Sep 17 00:00:00 2001 From: Tomas Srnka Date: Fri, 23 Oct 2026 17:42:10 +0000 Subject: [PATCH 6/6] windows: check and set closed under one lock in Close Close read closed under the lock, released it, and set it under a second acquisition, so two concurrent calls could both proceed and queue two reply channels. done holds one, so the second Close blocked sending, and with an undeliverable error between them the reader, now keeping the first reply, could not drain the second: both calls and the reader hung. The check and the set now happen under one lock, so a second Close returns at once. TestConcurrentClose runs eight concurrent Close calls under a timeout and checks that no reply is left on done; the interleaving is a race, so it guards rather than reproduces. --- backend_windows.go | 9 ++++++--- backend_windows_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/backend_windows.go b/backend_windows.go index 869158c9..8fc84207 100644 --- a/backend_windows.go +++ b/backend_windows.go @@ -93,11 +93,14 @@ func (w *readDirChangesW) sendError(err error) bool { } func (w *readDirChangesW) Close() error { - if w.isClosed() { + // Check and set under one lock: a second concurrent Close must not queue + // a second reply, which the one-slot done channel and the single reader + // cannot both serve. + w.mu.Lock() + if w.closed { + w.mu.Unlock() return nil } - - w.mu.Lock() w.closed = true w.mu.Unlock() diff --git a/backend_windows_test.go b/backend_windows_test.go index 93e968c7..362ab57a 100644 --- a/backend_windows_test.go +++ b/backend_windows_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "sync" "testing" "time" ) @@ -130,3 +131,34 @@ func TestCloseAfterUnreadError(t *testing.T) { t.Fatal("the reader never completed shutdown after an undelivered error") } } + +// Concurrent Close calls must all return: only the first queues a reply. +func TestConcurrentClose(t *testing.T) { + w, err := NewWatcher() + if err != nil { + t.Fatal(err) + } + if err := w.Add(t.TempDir()); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = w.Close() + }() + } + closed := make(chan struct{}) + go func() { wg.Wait(); close(closed) }() + + select { + case <-closed: + case <-time.After(10 * time.Second): + t.Fatal("concurrent Close calls did not all return") + } + if b := w.b.(*readDirChangesW); len(b.done) != 0 { + t.Fatalf("%d reply channels left on done", len(b.done)) + } +}