//go:build linux package fswatch import ( "errors" "fmt" "sync/atomic" "unsafe" "golang.org/x/sys/unix" ) // --------------------------------------------------------------------------- // inotify_linux.go: Linux inotify backend // // Uses the kernel's inotify(7) subsystem to watch directory trees. A single // inotify instance serves all subscriptions for the process lifetime. // // ┌───────────────────────────────────────────────────────────┐ // │ inotifyBackend │ // │ │ // │ ┌───────────┐ poll(2) ┌─────────────────┐ │ // │ │ pipe[0] ├──────────────────────►│ │ │ // │ │ (wakeup) │ │ start() │ │ // │ └───────────┘ │ goroutine │ │ // │ ┌───────────┐ │ (event loop) │ │ // │ │ inotify ├──────────────────────►│ │ │ // │ │ fd │ └────────┬────────┘ │ // │ └───────────┘ │ │ // │ handleEvents() │ // │ │ │ // │ ▼ │ // │ ┌─────────────────────────┐ │ // │ │ subscriptions │ │ // │ │ map[wd] → []sub │ │ // │ │ sub.dirWatch.events │ │ // │ └─────────────────────────┘ │ // └───────────────────────────────────────────────────────────┘ // // Goroutines and threading: // - One long-lived goroutine (start), launched by watcherBase.run(). It // owns the poll(2) loop and runs for the process lifetime. All event // reading and dispatch (handleEvents, handleEvent, handleSubscription) // execute on this goroutine, under b.mu. // - subscribe/closeWatch run on the caller's goroutine under // watcherBase.mu. The event loop acquires b.mu for watch map // access, providing safe interleaving. // // Callback delivery: // dirWatch.notify() posts to the shared process-wide debouncer. After a // coalescing window (50 ms min / 500 ms max), the debouncer invokes all // registered WatchCallbacks on its own dedicated goroutine; never on // the caller's goroutine or the event-loop goroutine. // // WatchDirectory flow: // 1. Walk the target directory (caller goroutine). // 2. For every directory found, call inotify_add_watch to obtain a // watch descriptor (wd). Map wd → inotifySubscription. // // Event dispatch (handleEvents → handleSubscription, on start goroutine): // - IN_CREATE / IN_MOVED_TO → events.create (→ EventUpdate); if the new // entry is a directory (IN_ISDIR), recursively walk and watch it. // - IN_MODIFY → events.update. // - IN_DELETE* / IN_MOVE* → events.remove; drop inotify subscriptions // for the removed path and any descendants. // - IN_Q_OVERFLOW → set ErrOverflow on every active dirWatch. // After processing all buffered events, call dirWatch.notify() on each // touched dirWatch to trigger the debouncer. // // Shutdown: // Write a byte to pipe[1] → poll sees POLLIN on pipe[0] → loop exits → // deferred closeFDs closes inotify fd, pipe fds, and signals endedSignal. // --------------------------------------------------------------------------- const ( inotifyMask = unix.IN_CREATE | unix.IN_DELETE | unix.IN_DELETE_SELF | unix.IN_MODIFY | unix.IN_MOVE_SELF | unix.IN_MOVED_FROM | unix.IN_MOVED_TO | unix.IN_DONT_FOLLOW | unix.IN_ONLYDIR | unix.IN_EXCL_UNLINK inotifyBufferSize = 8192 ) // inotifySubscription. type inotifySubscription struct { path string watchPath string dirWatch *dirWatch wd int } // inotifyBackend. type inotifyBackend struct { watcherBase pipeFDs [2]int // pipeWriteFD shadows pipeFDs[1] as an atomic so shutdown (any goroutine) // can safely race against the start goroutine's deferred closeFDs. // Sentinel -1 once closed. pipeWriteFD atomic.Int32 inotify int subscriptions map[int][]*inotifySubscription // multimap endedSignal chan struct{} // Persistent buffers reused across handleEvents calls. Only accessed // from the start goroutine, so no synchronization needed. readBuf []byte watchersTouched map[*dirWatch]struct{} } func init() { inotifyWatcher.factory = func() watcherImpl { return newInotifyBackend() } } func newInotifyBackend() *inotifyBackend { b := &inotifyBackend{ pipeFDs: [2]int{-1, -1}, inotify: -1, subscriptions: map[int][]*inotifySubscription{}, endedSignal: make(chan struct{}), readBuf: make([]byte, inotifyBufferSize), watchersTouched: make(map[*dirWatch]struct{}), } b.pipeWriteFD.Store(-1) b.watcherBase.init(b) return b } // start mirrors `inotifyBackend::start`. func (b *inotifyBackend) start() error { // Create a pipe so we can wake the poll(2) loop on shutdown. if err := unix.Pipe2(b.pipeFDs[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil { return fmt.Errorf("unable to open pipe: %w", err) } b.pipeWriteFD.Store(int32(b.pipeFDs[1])) defer func() { b.closeFDs() close(b.endedSignal) }() fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) if err != nil { return fmt.Errorf("unable to initialize inotify: %w", err) } b.inotify = fd pollfds := []unix.PollFd{ {Fd: int32(b.pipeFDs[0]), Events: unix.POLLIN}, {Fd: int32(b.inotify), Events: unix.POLLIN}, } b.notifyStarted() for { _, err := unix.Poll(pollfds, 500) if err != nil { if errors.Is(err, unix.EINTR) { continue } return fmt.Errorf("unable to poll: %w", err) } if pollfds[0].Revents != 0 { break } if pollfds[1].Revents != 0 { if err := b.handleEvents(); err != nil { return err } } } return nil } // closeFDs runs in the start goroutine after the poll loop exits. Takes // b.mu so the writes to b.inotify / b.pipeFDs synchronize-against the // reads in closeWatch / subscribe (both of which run under b.mu). func (b *inotifyBackend) closeFDs() { b.mu.Lock() defer b.mu.Unlock() if b.pipeFDs[0] >= 0 { _ = unix.Close(b.pipeFDs[0]) b.pipeFDs[0] = -1 } if fd := b.pipeWriteFD.Swap(-1); fd >= 0 { _ = unix.Close(int(fd)) } b.pipeFDs[1] = -1 if b.inotify >= 0 { _ = unix.Close(b.inotify) b.inotify = -1 } } // shutdown is the equivalent of the destructor's pipe-write+wait. // Called by removeSharedBackend when the last watch drops. Reads // the pipe write fd via atomic so it's safe to race against the start // goroutine's deferred closeFDs. func (b *inotifyBackend) shutdown() { fd := b.pipeWriteFD.Load() if fd < 0 { return } _, _ = unix.Write(int(fd), []byte{'X'}) <-b.endedSignal } // subscribe mirrors `inotifyBackend::subscribe`. Called via the watcherBase // virtual dispatch under b.mu (so it's serialized against handleEvent). func (b *inotifyBackend) subscribe(w *dirWatch) error { if !w.recursive { if _, err := b.watchDir(w, w.dir, w.physicalDir); err != nil { return &dirWatchError{ err: fmt.Errorf("inotify_add_watch on '%s' failed: %w", w.dir, err), dirWatch: w, } } return nil } if err := walkDir(w.physicalDir, true, func(watchPath string, isDir bool) error { if !isDir { return nil } path := w.displayPath(watchPath) if _, err := b.watchDir(w, path, watchPath); err != nil { return &dirWatchError{ err: fmt.Errorf("inotify_add_watch on '%s' failed: %w", path, err), dirWatch: w, } } return nil }); err != nil { _ = b.closeWatch(w) return err } return nil } // watchDir registers an inotify watch on path and records the resulting // subscription. Returns the kernel watch descriptor on success. func (b *inotifyBackend) watchDir(w *dirWatch, path string, watchPath string) (int, error) { wd, err := unix.InotifyAddWatch(b.inotify, watchPath, inotifyMask) if err != nil { return 0, err } sub := &inotifySubscription{path: path, watchPath: watchPath, dirWatch: w, wd: wd} b.subscriptions[wd] = append(b.subscriptions[wd], sub) return wd, nil } // handleEvents mirrors `inotifyBackend::handleEvents`. func (b *inotifyBackend) handleEvents() error { buf := b.readBuf watchersTouched := b.watchersTouched for { n, err := unix.Read(b.inotify, buf) if err != nil { if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) { break } return fmt.Errorf("Error reading from inotify: %w", err) } if n == 0 { break } // Walk the buffer. for offset := 0; offset < n; { ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset])) recordSize := unix.SizeofInotifyEvent + int(ev.Len) var name string if ev.Len > 0 { // Name is NUL-terminated; trim trailing zeros. nameBytes := buf[offset+unix.SizeofInotifyEvent : offset+recordSize] for i, c := range nameBytes { if c == 0 { nameBytes = nameBytes[:i] break } } name = string(nameBytes) } if ev.Mask&unix.IN_Q_OVERFLOW != 0 { b.mu.Lock() for _, subs := range b.subscriptions { for _, sub := range subs { sub.dirWatch.events.setError(ErrOverflow) watchersTouched[sub.dirWatch] = struct{}{} } } b.mu.Unlock() offset += recordSize continue } b.handleEvent(ev, name, watchersTouched) offset += recordSize } } for w := range watchersTouched { w.notify() } clear(watchersTouched) return nil } // handleEvent mirrors `inotifyBackend::handleEvent`. func (b *inotifyBackend) handleEvent(ev *unix.InotifyEvent, name string, touched map[*dirWatch]struct{}) { b.mu.Lock() defer b.mu.Unlock() // b.subscriptions[wd] holds at most one entry per *inotifySubscription // pointer (watchDir always appends a fresh struct), so no dedup is // necessary; the upstream C++ used an unordered_set keyed by // shared_ptr identity but the equivalent Go invariant is structural. for _, s := range b.subscriptions[int(ev.Wd)] { if b.handleSubscription(ev, name, s) { touched[s.dirWatch] = struct{}{} } } } // handleSubscription mirrors `inotifyBackend::handleSubscription`. func (b *inotifyBackend) handleSubscription(ev *unix.InotifyEvent, name string, sub *inotifySubscription) bool { w := sub.dirWatch path := sub.path watchPath := sub.watchPath isDir := ev.Mask&unix.IN_ISDIR != 0 if name != "" { path = path + "/" + name watchPath = watchPath + "/" + name } switch { case ev.Mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0: w.events.create(path) if isDir && w.recursive { _ = walkDir(watchPath, true, func(p string, pIsDir bool) error { if !pIsDir { return nil } _, _ = b.watchDir(w, w.displayPath(p), p) return nil }) } case ev.Mask&unix.IN_MODIFY != 0: w.events.update(path) case ev.Mask&(unix.IN_DELETE|unix.IN_DELETE_SELF|unix.IN_MOVED_FROM|unix.IN_MOVE_SELF) != 0: isSelfEvent := ev.Mask&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF) != 0 // Ignore delete/move self events unless this is the watch root. if isSelfEvent && path != w.dir { return false } // If deleted item is a dir, drop matching subscriptions. // XXX: self events don't have IN_ISDIR set. if isSelfEvent || isDir { for wd, list := range b.subscriptions { kept := list[:0] for _, s := range list { if s.path == path || (len(s.path) > len(path) && s.path[len(path)] == '/' && s.path[:len(path)] == path) { continue } kept = append(kept, s) } if len(kept) == 0 { _, _ = unix.InotifyRmWatch(b.inotify, uint32(wd)) delete(b.subscriptions, wd) } else { b.subscriptions[wd] = kept } } } w.events.remove(path) // If the watched root itself is gone the kernel has already // auto-removed every wd associated with this dirWatch and no // further events will fire. Surface ErrWatchTerminated so the // caller knows to clean up; the delete event above still // flows through the same callback. if isSelfEvent && path == w.dir { w.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) } } return true } // closeWatch mirrors `inotifyBackend::closeWatch`. Iterates every wd that // referenced w and removes the matching subscriptions. If a kernel // InotifyRmWatch fails we keep processing remaining wds and return the // first error encountered; bailing early would leave the internal state // half-cleaned and the caller's dirWatch hanging off other wds. func (b *inotifyBackend) closeWatch(w *dirWatch) error { var firstErr error for wd, list := range b.subscriptions { kept := list[:0] removedAny := false for _, s := range list { if s.dirWatch == w { removedAny = true continue } kept = append(kept, s) } if !removedAny { continue } if len(kept) == 0 { if _, err := unix.InotifyRmWatch(b.inotify, uint32(wd)); err != nil && firstErr == nil { firstErr = &dirWatchError{ err: fmt.Errorf("unable to remove dirWatch: %w", err), dirWatch: w, } } delete(b.subscriptions, wd) } else { b.subscriptions[wd] = kept } } return firstErr }