Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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] |
Expand All @@ -35,7 +32,7 @@ package main
import (
"log"

"github.com/fsnotify/fsnotify"
"github.com/e2b-dev/fsnotify"
)

func main() {
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion backend_fen.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"sync"
"time"

"github.com/fsnotify/fsnotify/internal"
"github.com/e2b-dev/fsnotify/internal"
"golang.org/x/sys/unix"
)

Expand Down
50 changes: 49 additions & 1 deletion backend_inotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"time"
"unsafe"

"github.com/fsnotify/fsnotify/internal"
"github.com/e2b-dev/fsnotify/internal"
"golang.org/x/sys/unix"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -510,6 +521,43 @@ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deadlock: sendEvent called while holding mutex

High Severity

w.sendEvent(ev) is called inside the WalkDir callback while w.mu is held (acquired at line 422 via handleEvent). On Linux, defaultBufferSize is 0, making Events unbuffered. If the channel blocks waiting for a consumer, and that consumer then calls Add, Remove, or WatchList (all of which acquire w.mu), a deadlock occurs. The existing sendEvent in readEvents (line 411) is safe because it runs after handleEvent returns and the deferred unlock fires; this new call inside the lock is not.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b260f7c. Configure here.

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)
}
return nil
})
if !w.sendError(err) {
return Event{}, false
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion backend_kqueue.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"sync"
"time"

"github.com/fsnotify/fsnotify/internal"
"github.com/e2b-dev/fsnotify/internal"
"golang.org/x/sys/unix"
)

Expand Down
17 changes: 12 additions & 5 deletions backend_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"time"
"unsafe"

"github.com/fsnotify/fsnotify/internal"
"github.com/e2b-dev/fsnotify/internal"
"golang.org/x/sys/windows"
)

Expand Down Expand Up @@ -76,24 +76,31 @@ 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
}
}

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()

Expand Down
91 changes: 91 additions & 0 deletions backend_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
package fsnotify

import (
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
)

func TestRemoveState(t *testing.T) {
Expand Down Expand Up @@ -71,3 +74,91 @@ 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")
}
}

// 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))
}
}
2 changes: 1 addition & 1 deletion cmd/fsnotify/dedup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/fsnotify/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/fsnotify/watch.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 4 additions & 3 deletions fsnotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion fsnotify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"testing"
"time"

"github.com/fsnotify/fsnotify/internal"
"github.com/e2b-dev/fsnotify/internal"
)

func init() {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/fsnotify/fsnotify
module github.com/e2b-dev/fsnotify

go 1.19

Expand Down
4 changes: 2 additions & 2 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading