1034 lines
27 KiB
Go
1034 lines
27 KiB
Go
package fswatch
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/microsoft/typescript-go/internal/nativepath"
|
|
)
|
|
|
|
var errNilCallback = errors.New("fswatch: callback must not be nil")
|
|
|
|
// errRootPath is returned by WatchFile when the supplied path is a
|
|
// filesystem root with no parent directory to watch.
|
|
var errRootPath = errors.New("fswatch: cannot watch a root path")
|
|
|
|
// errNotAbsolute is returned by [Watcher.WatchDirectory] and
|
|
// [Watcher.WatchFile] when the supplied path is not absolute.
|
|
var errNotAbsolute = errors.New("fswatch: path must be absolute")
|
|
|
|
// ErrOverflow indicates that the kernel event queue overflowed and
|
|
// some filesystem changes were missed. The watch remains
|
|
// active; further events will continue to be delivered. Callers
|
|
// should treat this as a signal to rescan the watched directory.
|
|
var ErrOverflow = errors.New("fswatch: event overflow; some changes were missed")
|
|
|
|
// ErrWatchTerminated indicates that the watch was terminated due to
|
|
// an unrecoverable error (e.g. the watched directory was deleted or
|
|
// the watch descriptor was revoked). No further events will be
|
|
// delivered. Call Close to release remaining state.
|
|
var ErrWatchTerminated = errors.New("fswatch: watch terminated")
|
|
|
|
// ErrUnavailable indicates that a requested watcher is not
|
|
// available on the current platform.
|
|
var ErrUnavailable = errors.New("fswatch: watcher not available on this platform")
|
|
|
|
// Watcher represents a filesystem watching implementation.
|
|
// Use one of the constructor functions ([Inotify], [FSEvents], [Kqueue],
|
|
// [Windows]) to obtain a value, or [Default] for the platform default.
|
|
//
|
|
// All watchers exist on every platform. Subscribing with a watcher that
|
|
// is not supported on the current OS returns [ErrUnavailable].
|
|
type Watcher interface {
|
|
// Name returns a stable identifier ("inotify", "fsevents", "kqueue",
|
|
// "windows").
|
|
Name() string
|
|
// Available reports whether this watcher works on the current OS.
|
|
Available() bool
|
|
// HasFastRecursiveBackend reports whether this watcher supports efficient
|
|
// recursive watching without requiring a full userspace tree walk. This is
|
|
// true for Windows (ReadDirectoryChangesW subtree mode) and macOS FSEvents
|
|
// (inherently recursive), and false for all other backends.
|
|
HasFastRecursiveBackend() bool
|
|
// WatchDirectory watches dir for changes, calling fn with batched
|
|
// events. By default, only direct children are watched. Use
|
|
// [WithRecursive] to watch the entire directory tree.
|
|
// dir must be an absolute path to an existing directory. If dir is a
|
|
// symlink or reparse point to a directory, the OS subscription follows
|
|
// the target directory but delivered event paths remain rooted at dir.
|
|
// Userspace recursive traversal does not follow symlinked descendant
|
|
// directories.
|
|
// Returns [ErrUnavailable] if the watcher is not supported on
|
|
// the current platform.
|
|
WatchDirectory(dir string, fn WatchCallback, opts ...WatchOption) (Watch, error)
|
|
// WatchDirectories watches multiple directories as a batch. It has the
|
|
// same semantics as calling [Watcher.WatchDirectory] for each request, but
|
|
// lets backends arm the underlying OS watches once for the whole batch.
|
|
// Returned watches are in the same order as requests.
|
|
WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, error)
|
|
// WatchFile watches a single file for changes, calling fn with
|
|
// batched events. path must be an absolute path. The file does not
|
|
// need to exist at subscribe time; its creation will be reported.
|
|
// The parent directory must exist.
|
|
//
|
|
// Multiple WatchFile calls for files in the same directory
|
|
// share a single OS watch on the parent directory.
|
|
//
|
|
// If the parent directory is deleted, [ErrWatchTerminated] is
|
|
// delivered and the watch is dead. Unlike TypeScript's
|
|
// watchFile (which falls back to polling for missing entries),
|
|
// there is no automatic recovery. Callers that need to survive
|
|
// parent directory deletion should handle [ErrWatchTerminated]
|
|
// and re-subscribe when the directory is recreated.
|
|
//
|
|
// Returns [ErrUnavailable] if the watcher is not supported on
|
|
// the current platform.
|
|
WatchFile(path string, fn WatchCallback) (Watch, error)
|
|
unexported()
|
|
}
|
|
|
|
// WatchOption configures a watch.
|
|
type WatchOption interface {
|
|
applyWatchOption(opts *watchOptions)
|
|
}
|
|
|
|
// WatchDirectoryRequest describes one directory subscription in a
|
|
// [Watcher.WatchDirectories] batch.
|
|
type WatchDirectoryRequest struct {
|
|
Dir string
|
|
Callback WatchCallback
|
|
Options []WatchOption
|
|
}
|
|
|
|
type watchOptions struct {
|
|
ignore func(path string) bool
|
|
recursive bool
|
|
}
|
|
|
|
type ignoreOption struct {
|
|
fn func(path string) bool
|
|
}
|
|
|
|
func (o ignoreOption) applyWatchOption(opts *watchOptions) {
|
|
opts.ignore = o.fn
|
|
}
|
|
|
|
// WithIgnore returns a [WatchOption] that filters events before delivery.
|
|
// If the function returns true for a path, events for that path are
|
|
// silently dropped. The filtering is per-subscriber; multiple watches
|
|
// on the same directory may have different ignore functions.
|
|
func WithIgnore(fn func(path string) bool) WatchOption {
|
|
return ignoreOption{fn: fn}
|
|
}
|
|
|
|
type recursiveOption struct{}
|
|
|
|
func (o recursiveOption) applyWatchOption(opts *watchOptions) {
|
|
opts.recursive = true
|
|
}
|
|
|
|
// WithRecursive returns a [WatchOption] that enables recursive watching
|
|
// of the entire directory tree. Without this option,
|
|
// [Watcher.WatchDirectory] watches only direct children of dir.
|
|
//
|
|
// In recursive mode, events for all descendants at any depth are
|
|
// delivered. On inotify/fanotify, a watch descriptor is added for
|
|
// every subdirectory. On kqueue, an fd is opened for every entry.
|
|
// On Windows, bWatchSubtree=TRUE is passed to ReadDirectoryChangesW.
|
|
// On FSEvents, the kernel is inherently recursive.
|
|
func WithRecursive() WatchOption {
|
|
return recursiveOption{}
|
|
}
|
|
|
|
// Watch represents a live watch. Close stops watching
|
|
// and releases resources. It is idempotent.
|
|
type Watch interface {
|
|
Close() error
|
|
unexported()
|
|
}
|
|
|
|
// WatchCallback receives batched filesystem events. Rapid changes
|
|
// are coalesced before delivery.
|
|
//
|
|
// For a given Watch, the callback is never invoked concurrently
|
|
// with itself. It runs on a library goroutine, not the caller's.
|
|
//
|
|
// When err is non-nil, use [errors.Is] to check for [ErrOverflow]
|
|
// (recoverable) or [ErrWatchTerminated] (terminal).
|
|
type WatchCallback func(events []Event, err error)
|
|
|
|
// Package-level watcher instances. Platform init() functions set the factory.
|
|
var (
|
|
inotifyWatcher = &watcher{name: "inotify"}
|
|
fseventsWatcher = &watcher{name: "fsevents"}
|
|
kqueueWatcher = &watcher{name: "kqueue"}
|
|
windowsWatcher = &watcher{name: "windows"}
|
|
fanotifyWatcher = &watcher{name: "fanotify"}
|
|
)
|
|
|
|
// AllWatchers returns a fresh slice listing every watcher backend the package
|
|
// knows about. Use [Watcher.Available] to check which ones work on the current
|
|
// OS.
|
|
func AllWatchers() []Watcher {
|
|
return []Watcher{
|
|
inotifyWatcher,
|
|
fseventsWatcher,
|
|
kqueueWatcher,
|
|
windowsWatcher,
|
|
fanotifyWatcher,
|
|
}
|
|
}
|
|
|
|
// Inotify returns the inotify watcher (Linux).
|
|
func Inotify() Watcher { return inotifyWatcher }
|
|
|
|
// FSEvents returns the FSEvents watcher (macOS).
|
|
func FSEvents() Watcher { return fseventsWatcher }
|
|
|
|
// Kqueue returns the kqueue watcher (macOS, FreeBSD, and other BSDs).
|
|
func Kqueue() Watcher { return kqueueWatcher }
|
|
|
|
// Windows returns the ReadDirectoryChangesW watcher (Windows).
|
|
func Windows() Watcher { return windowsWatcher }
|
|
|
|
// Fanotify returns the fanotify watcher (Linux, kernel ≥ 5.13).
|
|
func Fanotify() Watcher { return fanotifyWatcher }
|
|
|
|
// Default returns the recommended watcher for the current OS.
|
|
func Default() Watcher {
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
if Fanotify().Available() {
|
|
return Fanotify()
|
|
}
|
|
return Inotify()
|
|
case "darwin":
|
|
if FSEvents().Available() {
|
|
return FSEvents()
|
|
}
|
|
return Kqueue()
|
|
case "windows":
|
|
return Windows()
|
|
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
|
return Kqueue()
|
|
default:
|
|
return &watcher{name: "unsupported"}
|
|
}
|
|
}
|
|
|
|
// watcher is the concrete implementation of [Watcher]. Each platform
|
|
// watcher is a package-level *watcher whose factory is set by the
|
|
// platform's init() function.
|
|
type watcher struct {
|
|
name string
|
|
mu sync.Mutex
|
|
impl watcherImpl
|
|
factory func() watcherImpl // nil if not available on this platform
|
|
dirWatches map[string]*dirWatch
|
|
debounce *debounce // lazily created in getOrCreateDirWatch
|
|
sequence func() uint64
|
|
}
|
|
|
|
const recursiveConsolidateThreshold = 10
|
|
|
|
func (w *watcher) Name() string { return w.name }
|
|
func (w *watcher) String() string { return w.name }
|
|
func (w *watcher) Available() bool { return w.factory != nil }
|
|
func (w *watcher) unexported() {}
|
|
|
|
// HasFastRecursiveBackend implements [Watcher.HasFastRecursiveBackend].
|
|
func (w *watcher) HasFastRecursiveBackend() bool {
|
|
switch w.name {
|
|
case "windows", "fsevents":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (w *watcher) canShareRecursiveDirWatches() bool {
|
|
// TODO: Re-enable this for Windows once coalesced recursive watches have
|
|
// more real-world bake time.
|
|
return w.name == "fsevents"
|
|
}
|
|
|
|
func (w *watcher) getImpl() (watcherImpl, error) {
|
|
w.mu.Lock()
|
|
if w.impl != nil {
|
|
impl := w.impl
|
|
w.mu.Unlock()
|
|
return impl, nil
|
|
}
|
|
factory := w.factory
|
|
w.mu.Unlock()
|
|
|
|
if factory == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
|
|
impl := factory()
|
|
if err := impl.run(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
w.mu.Lock()
|
|
if w.impl != nil {
|
|
w.mu.Unlock()
|
|
impl.shutdown()
|
|
return w.impl, nil
|
|
}
|
|
w.impl = impl
|
|
w.mu.Unlock()
|
|
return impl, nil
|
|
}
|
|
|
|
func (w *watcher) keyForDirWatch(dir string, recursive bool) string {
|
|
if recursive {
|
|
return dir + "\x00recursive"
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string) *dirWatch {
|
|
var best *dirWatch
|
|
for _, dw := range w.dirWatches {
|
|
if !dw.recursive || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) {
|
|
continue
|
|
}
|
|
if best == nil || len(dw.dir) > len(best.dir) {
|
|
best = dw
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func (w *watcher) findConsolidationDirLocked(dir string, physicalDir string) string {
|
|
if !w.canShareRecursiveDirWatches() {
|
|
return ""
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
for parent != dir && parent != "." {
|
|
if filepath.Dir(parent) == parent {
|
|
break
|
|
}
|
|
physicalParent := physicalDirFor(parent)
|
|
if !isInDirectoryOrSelf(physicalParent, physicalDir) {
|
|
return ""
|
|
}
|
|
count := 1
|
|
for _, dw := range w.dirWatches {
|
|
if isInDirectoryOrSelf(parent, dw.dir) && isInDirectoryOrSelf(physicalParent, dw.physicalDir) {
|
|
count++
|
|
if count >= recursiveConsolidateThreshold {
|
|
return parent
|
|
}
|
|
}
|
|
}
|
|
next := filepath.Dir(parent)
|
|
if next == parent {
|
|
break
|
|
}
|
|
dir = parent
|
|
parent = next
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool) *dirWatch {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.dirWatches == nil {
|
|
w.dirWatches = make(map[string]*dirWatch)
|
|
}
|
|
if w.debounce == nil {
|
|
w.debounce = newDebounce()
|
|
}
|
|
|
|
if w.canShareRecursiveDirWatches() {
|
|
if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil {
|
|
return dw
|
|
}
|
|
if consolidationDir := w.findConsolidationDirLocked(dir, physicalDir); consolidationDir != "" {
|
|
dir = consolidationDir
|
|
physicalDir = physicalDirFor(dir)
|
|
recursive = true
|
|
if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil {
|
|
return dw
|
|
}
|
|
}
|
|
}
|
|
|
|
key := w.keyForDirWatch(dir, recursive)
|
|
if dw, ok := w.dirWatches[key]; ok {
|
|
return dw
|
|
}
|
|
dw := newDirWatch(dir, physicalDir, w.debounce)
|
|
dw.sequence = w.sequence
|
|
dw.recursive = recursive
|
|
w.dirWatches[key] = dw
|
|
return dw
|
|
}
|
|
|
|
func (w *watcher) removeDirWatch(dw *dirWatch) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
key := w.keyForDirWatch(dw.dir, dw.recursive)
|
|
if existing, ok := w.dirWatches[key]; ok && existing == dw {
|
|
delete(w.dirWatches, key)
|
|
dw.destroyDebounce()
|
|
}
|
|
}
|
|
|
|
func (w *watcher) WatchDirectory(dir string, fn WatchCallback, opts ...WatchOption) (Watch, error) {
|
|
watches, err := w.WatchDirectories([]WatchDirectoryRequest{{
|
|
Dir: dir,
|
|
Callback: fn,
|
|
Options: opts,
|
|
}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return watches[0], nil
|
|
}
|
|
|
|
func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, error) {
|
|
if !w.Available() {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if len(requests) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
type preparedWatch struct {
|
|
dw *dirWatch
|
|
id uint64
|
|
recursive bool
|
|
dir string
|
|
}
|
|
prepared := make([]preparedWatch, 0, len(requests))
|
|
uniqueDirWatches := make([]*dirWatch, 0, len(requests))
|
|
seenDirWatches := make(map[*dirWatch]struct{}, len(requests))
|
|
rollback := func() {
|
|
for i := len(prepared) - 1; i >= 0; i-- {
|
|
p := prepared[i]
|
|
p.dw.unwatch(p.id)
|
|
p.dw.unref(w)
|
|
}
|
|
}
|
|
|
|
for _, request := range requests {
|
|
dir := request.Dir
|
|
fn := request.Callback
|
|
if fn == nil {
|
|
rollback()
|
|
return nil, errNilCallback
|
|
}
|
|
dir = filepath.Clean(dir)
|
|
if !filepath.IsAbs(dir) {
|
|
rollback()
|
|
return nil, errNotAbsolute
|
|
}
|
|
dir = canonicalizePath(dir)
|
|
if w.canShareRecursiveDirWatches() {
|
|
if err := validateWatchDirectory(dir); err != nil {
|
|
rollback()
|
|
return nil, err
|
|
}
|
|
}
|
|
physicalDir := physicalDirFor(dir)
|
|
|
|
var sopts watchOptions
|
|
for _, o := range request.Options {
|
|
o.applyWatchOption(&sopts)
|
|
}
|
|
|
|
dw := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive)
|
|
id, _ := dw.watch(dir, physicalDir, sopts.recursive, fn, sopts.ignore)
|
|
prepared = append(prepared, preparedWatch{dw: dw, id: id, recursive: sopts.recursive, dir: dir})
|
|
if _, ok := seenDirWatches[dw]; !ok {
|
|
seenDirWatches[dw] = struct{}{}
|
|
uniqueDirWatches = append(uniqueDirWatches, dw)
|
|
}
|
|
}
|
|
|
|
impl, err := w.getImpl()
|
|
if err != nil {
|
|
rollback()
|
|
return nil, err
|
|
}
|
|
if err := impl.watchAddMany(uniqueDirWatches); err != nil {
|
|
rollback()
|
|
return nil, err
|
|
}
|
|
|
|
watches := make([]Watch, len(prepared))
|
|
for i, p := range prepared {
|
|
watches[i] = &watch{w: w, dw: p.dw, impl: impl, id: p.id}
|
|
}
|
|
return watches, nil
|
|
}
|
|
|
|
func validateWatchDirectory(dir string) error {
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return syscall.ENOTDIR
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) {
|
|
if fn == nil {
|
|
return nil, errNilCallback
|
|
}
|
|
if !w.Available() {
|
|
return nil, ErrUnavailable
|
|
}
|
|
path = filepath.Clean(path)
|
|
if !filepath.IsAbs(path) {
|
|
return nil, errNotAbsolute
|
|
}
|
|
path = canonicalizePath(path)
|
|
dir := filepath.Dir(path)
|
|
if dir == path {
|
|
return nil, errRootPath
|
|
}
|
|
|
|
return w.WatchDirectory(dir, fileCallback(path, fn))
|
|
}
|
|
|
|
// fileCallback wraps a WatchCallback so it only sees events for the
|
|
// specific target path. Errors are always forwarded (with any matching
|
|
// events delivered alongside) so callers don't lose overflow signals
|
|
// just because their target wasn't in the same batch.
|
|
func fileCallback(target string, fn WatchCallback) WatchCallback {
|
|
return func(events []Event, err error) {
|
|
var filtered []Event
|
|
for _, e := range events {
|
|
if e.Path == target {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
if len(filtered) > 0 || err != nil {
|
|
fn(filtered, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type watch struct {
|
|
mu sync.Mutex
|
|
w *watcher
|
|
dw *dirWatch
|
|
impl watcherImpl
|
|
id uint64
|
|
cancelled bool
|
|
}
|
|
|
|
func (s *watch) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.cancelled {
|
|
return nil
|
|
}
|
|
s.cancelled = true
|
|
last := s.dw.unwatch(s.id)
|
|
if last {
|
|
s.impl.watchRemove(s.dw)
|
|
s.dw.unref(s.w)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *watch) unexported() {}
|
|
|
|
// watcherImpl is the internal interface implemented by each platform watcher.
|
|
type watcherImpl interface {
|
|
start() error
|
|
run() error
|
|
shutdown()
|
|
|
|
watchAdd(w *dirWatch) error
|
|
watchAddMany(watches []*dirWatch) error
|
|
watchRemove(w *dirWatch)
|
|
handleWatcherError(err *dirWatchError)
|
|
|
|
subscribe(w *dirWatch) error
|
|
closeWatch(w *dirWatch) error
|
|
}
|
|
|
|
// watcherBase provides shared watch-tracking and lifecycle logic.
|
|
// Concrete backends embed it and override subscribe/closeWatch/start.
|
|
type watcherBase struct {
|
|
mu sync.Mutex
|
|
subscriptions map[*dirWatch]struct{}
|
|
started chan struct{}
|
|
startErr error
|
|
|
|
self watcherImpl // back-reference for virtual dispatch
|
|
}
|
|
|
|
func (b *watcherBase) init(self watcherImpl) {
|
|
b.self = self
|
|
b.subscriptions = make(map[*dirWatch]struct{})
|
|
b.started = make(chan struct{})
|
|
}
|
|
|
|
func (b *watcherBase) notifyStarted() {
|
|
select {
|
|
case <-b.started:
|
|
// Do nothing; already started.
|
|
default:
|
|
close(b.started)
|
|
}
|
|
}
|
|
|
|
func (b *watcherBase) shutdown() {}
|
|
|
|
func (b *watcherBase) run() error {
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err, ok := r.(error)
|
|
if !ok {
|
|
err = fmt.Errorf("%v", r)
|
|
}
|
|
b.handleStartError(err)
|
|
}
|
|
}()
|
|
if err := b.self.start(); err != nil {
|
|
b.handleStartError(err)
|
|
}
|
|
}()
|
|
<-b.started
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.startErr
|
|
}
|
|
|
|
func (b *watcherBase) handleStartError(err error) {
|
|
b.mu.Lock()
|
|
b.startErr = err
|
|
subs := make([]*dirWatch, 0, len(b.subscriptions))
|
|
for w := range b.subscriptions {
|
|
subs = append(subs, w)
|
|
}
|
|
b.mu.Unlock()
|
|
for _, w := range subs {
|
|
w.notifyError(err)
|
|
}
|
|
b.notifyStarted()
|
|
}
|
|
|
|
func (b *watcherBase) watchAdd(w *dirWatch) error {
|
|
return b.watchAddMany([]*dirWatch{w})
|
|
}
|
|
|
|
func (b *watcherBase) watchAddMany(watches []*dirWatch) error {
|
|
b.mu.Lock()
|
|
toAdd := make([]*dirWatch, 0, len(watches))
|
|
for _, w := range watches {
|
|
if _, ok := b.subscriptions[w]; ok {
|
|
continue
|
|
}
|
|
toAdd = append(toAdd, w)
|
|
}
|
|
if len(toAdd) == 0 {
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
if subscriber, ok := b.self.(interface {
|
|
subscribeMany(watches []*dirWatch) error
|
|
}); ok {
|
|
if err := subscriber.subscribeMany(toAdd); err != nil {
|
|
b.mu.Unlock()
|
|
return err
|
|
}
|
|
for _, w := range toAdd {
|
|
b.subscriptions[w] = struct{}{}
|
|
}
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
added := make([]*dirWatch, 0, len(toAdd))
|
|
for _, w := range toAdd {
|
|
if err := b.self.subscribe(w); err != nil {
|
|
for _, addedWatch := range added {
|
|
delete(b.subscriptions, addedWatch)
|
|
_ = b.self.closeWatch(addedWatch)
|
|
}
|
|
b.mu.Unlock()
|
|
return err
|
|
}
|
|
b.subscriptions[w] = struct{}{}
|
|
added = append(added, w)
|
|
}
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (b *watcherBase) watchRemove(w *dirWatch) {
|
|
b.mu.Lock()
|
|
if _, ok := b.subscriptions[w]; !ok {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
delete(b.subscriptions, w)
|
|
_ = b.self.closeWatch(w)
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *watcherBase) handleWatcherError(werr *dirWatchError) {
|
|
b.watchRemove(werr.dirWatch)
|
|
werr.dirWatch.notifyError(fmt.Errorf("%w: %w", ErrWatchTerminated, werr))
|
|
}
|
|
|
|
// ----- dirWatch: per-directory watch state -------------------------
|
|
|
|
type callback struct {
|
|
id uint64
|
|
dir string
|
|
physicalDir string
|
|
watchDir string
|
|
watchPhysicalDir string
|
|
recursive bool
|
|
fn WatchCallback
|
|
ignore func(path string) bool
|
|
sinceSeq uint64
|
|
terminal error
|
|
delivered bool
|
|
}
|
|
|
|
// dirWatchError associates an error with a specific directory watch.
|
|
type dirWatchError struct {
|
|
err error
|
|
dirWatch *dirWatch
|
|
}
|
|
|
|
func (e *dirWatchError) Error() string { return e.err.Error() }
|
|
func (e *dirWatchError) Unwrap() error { return e.err }
|
|
|
|
// dirWatch holds per-directory state: pending events, registered callbacks,
|
|
// and a reference to the shared debouncer. Each watched directory has one.
|
|
type dirWatch struct {
|
|
// dir is the caller-visible watch root used in delivered event paths.
|
|
dir string
|
|
// physicalDir is the path passed to OS watcher APIs. It differs from dir
|
|
// when dir or an ancestor is a symlink or reparse point to a directory.
|
|
physicalDir string
|
|
recursive bool
|
|
events eventList
|
|
|
|
// state stores per-directory platform-specific bookkeeping (fsevents, windows).
|
|
state any
|
|
// sequence returns a backend event sequence cutoff for new logical callbacks.
|
|
sequence func() uint64
|
|
|
|
mu sync.Mutex
|
|
callbacks []callback
|
|
debounce *debounce
|
|
nextCBID uint64
|
|
}
|
|
|
|
func newDirWatch(dir string, physicalDir string, db *debounce) *dirWatch {
|
|
dw := &dirWatch{dir: dir, physicalDir: physicalDir}
|
|
dw.debounce = db
|
|
dw.debounce.add(dw, func() { dw.triggerCallbacks() })
|
|
return dw
|
|
}
|
|
|
|
// physicalDirFor returns the physical path to watch for dir. If dir, or an
|
|
// ancestor of dir, is a symlink or reparse point, events are subscribed on its
|
|
// realpath while callbacks still use dir.
|
|
func physicalDirFor(dir string) string {
|
|
realpath, err := nativepath.Realpath(dir)
|
|
if err != nil {
|
|
return dir
|
|
}
|
|
if realpath == dir {
|
|
return dir
|
|
}
|
|
return canonicalizePath(filepath.Clean(realpath))
|
|
}
|
|
|
|
// displayPath maps a physical event path back under the caller-visible
|
|
// watch root.
|
|
func (dw *dirWatch) displayPath(watchPath string) string {
|
|
return rebasePath(watchPath, dw.physicalDir, dw.dir)
|
|
}
|
|
|
|
// physicalPath maps a caller-visible path to the physical watched root.
|
|
func (dw *dirWatch) physicalPath(displayPath string) string {
|
|
return rebasePath(displayPath, dw.dir, dw.physicalDir)
|
|
}
|
|
|
|
// rebasePath replaces the from root in path with to, preserving any child
|
|
// suffix. Prefix matches must end at a path separator so sibling paths like
|
|
// "/foo2" are not rebased from "/foo".
|
|
func rebasePath(path string, from string, to string) string {
|
|
if from == to {
|
|
return path
|
|
}
|
|
if path == from {
|
|
return to
|
|
}
|
|
if !strings.HasPrefix(path, from) {
|
|
return path
|
|
}
|
|
suffix := path[len(from):]
|
|
if len(from) > 0 && os.IsPathSeparator(from[len(from)-1]) {
|
|
return joinPathSuffix(to, suffix)
|
|
}
|
|
if len(suffix) == 0 || !os.IsPathSeparator(suffix[0]) {
|
|
return path
|
|
}
|
|
return joinPathSuffix(to, suffix)
|
|
}
|
|
|
|
func joinPathSuffix(root string, suffix string) string {
|
|
if suffix == "" {
|
|
return root
|
|
}
|
|
if os.IsPathSeparator(suffix[0]) {
|
|
if len(root) > 0 && os.IsPathSeparator(root[len(root)-1]) {
|
|
return root + suffix[1:]
|
|
}
|
|
return root + suffix
|
|
}
|
|
if len(root) > 0 && os.IsPathSeparator(root[len(root)-1]) {
|
|
return root + suffix
|
|
}
|
|
return root + string(filepath.Separator) + suffix
|
|
}
|
|
|
|
func (dw *dirWatch) destroyDebounce() {
|
|
dw.mu.Lock()
|
|
db := dw.debounce
|
|
dw.debounce = nil
|
|
dw.mu.Unlock()
|
|
if db != nil {
|
|
db.remove(dw)
|
|
}
|
|
}
|
|
|
|
func (dw *dirWatch) notify() {
|
|
dw.mu.Lock()
|
|
hasPendingCBs := slices.ContainsFunc(dw.callbacks, func(cb callback) bool {
|
|
return !cb.delivered
|
|
})
|
|
hasTerminal := slices.ContainsFunc(dw.callbacks, func(cb callback) bool {
|
|
return cb.terminal != nil && !cb.delivered
|
|
})
|
|
hasEvents := dw.events.size() > 0
|
|
hasError := dw.events.hasError()
|
|
db := dw.debounce
|
|
dw.mu.Unlock()
|
|
|
|
if hasPendingCBs && (hasEvents || hasError || hasTerminal) && db != nil {
|
|
db.trigger()
|
|
}
|
|
}
|
|
|
|
func (dw *dirWatch) notifyError(err error) {
|
|
dw.mu.Lock()
|
|
cbs := slices.Clone(dw.callbacks)
|
|
dw.callbacks = nil
|
|
dw.mu.Unlock()
|
|
for _, cb := range cbs {
|
|
cb.fn(nil, err)
|
|
}
|
|
}
|
|
|
|
func (dw *dirWatch) triggerCallbacks() {
|
|
dw.mu.Lock()
|
|
hasError := dw.events.hasError()
|
|
hasEvents := dw.events.size() > 0
|
|
cbs := make([]callback, 0, len(dw.callbacks))
|
|
hasTerminal := false
|
|
for _, cb := range dw.callbacks {
|
|
if cb.delivered {
|
|
continue
|
|
}
|
|
if cb.terminal != nil {
|
|
hasTerminal = true
|
|
}
|
|
cbs = append(cbs, cb)
|
|
}
|
|
if len(cbs) == 0 {
|
|
if hasEvents || hasError {
|
|
_, _ = dw.events.drain()
|
|
}
|
|
dw.mu.Unlock()
|
|
return
|
|
}
|
|
if !hasEvents && !hasError && !hasTerminal {
|
|
dw.mu.Unlock()
|
|
return
|
|
}
|
|
startSeqs := make([]uint64, len(cbs))
|
|
for i, cb := range cbs {
|
|
startSeqs[i] = cb.sinceSeq
|
|
}
|
|
eventsByCallback, err := dw.events.drainForSequences(startSeqs)
|
|
for _, cb := range cbs {
|
|
if cb.terminal == nil {
|
|
continue
|
|
}
|
|
for i := range dw.callbacks {
|
|
if dw.callbacks[i].id == cb.id {
|
|
dw.callbacks[i].delivered = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
dw.mu.Unlock()
|
|
|
|
for i, cb := range cbs {
|
|
cbEvents := eventsByCallback[i]
|
|
if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir {
|
|
filtered := make([]Event, 0, len(cbEvents))
|
|
for _, e := range cbEvents {
|
|
e = cb.mapEvent(e)
|
|
if cb.ignore != nil && cb.ignore(e.Path) {
|
|
continue
|
|
}
|
|
if cb.dir != dw.dir && !e.includedWatchRoot && e.Path == cb.dir && e.Kind == EventUpdate {
|
|
continue
|
|
}
|
|
if cb.recursive {
|
|
if cb.dir != dw.dir && !isInDirectoryOrSelf(cb.dir, e.Path) {
|
|
continue
|
|
}
|
|
} else if !isDirectChild(cb.dir, e.Path) && !(cb.dir != dw.dir && e.Path == cb.dir) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, e)
|
|
}
|
|
cbEvents = filtered
|
|
}
|
|
cbErr := err
|
|
if cb.terminal != nil {
|
|
cbErr = cb.terminal
|
|
}
|
|
if len(cbEvents) > 0 || cbErr != nil {
|
|
cb.fn(cbEvents, cbErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cb callback) mapEvent(e Event) Event {
|
|
if cb.physicalDir != "" && cb.physicalDir != cb.dir {
|
|
physicalPath := cb.eventPhysicalPath(e.Path)
|
|
if isInDirectoryOrSelf(cb.physicalDir, physicalPath) {
|
|
e.Path = rebasePath(physicalPath, cb.physicalDir, cb.dir)
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (cb callback) eventPhysicalPath(path string) string {
|
|
if cb.watchPhysicalDir != "" && cb.watchDir != "" && cb.watchPhysicalDir != cb.watchDir && isInDirectoryOrSelf(cb.watchDir, path) {
|
|
return rebasePath(path, cb.watchDir, cb.watchPhysicalDir)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (dw *dirWatch) terminateCallbacksForDeletedRoot(path string, seq uint64, err error) bool {
|
|
dw.mu.Lock()
|
|
defer dw.mu.Unlock()
|
|
changed := false
|
|
for i := range dw.callbacks {
|
|
cb := &dw.callbacks[i]
|
|
if cb.delivered || cb.terminal != nil || cb.sinceSeq >= seq {
|
|
continue
|
|
}
|
|
physicalPath := cb.eventPhysicalPath(path)
|
|
if isInDirectoryOrSelf(path, cb.dir) || (cb.physicalDir != cb.dir && isInDirectoryOrSelf(physicalPath, cb.physicalDir)) {
|
|
cb.terminal = err
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func isInDirectoryOrSelf(dir, path string) bool {
|
|
if dir == "" {
|
|
return false
|
|
}
|
|
if path == dir {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(path, dir) {
|
|
return false
|
|
}
|
|
rest := path[len(dir):]
|
|
if len(rest) == 0 {
|
|
return false
|
|
}
|
|
if os.IsPathSeparator(dir[len(dir)-1]) {
|
|
return true
|
|
}
|
|
return os.IsPathSeparator(rest[0])
|
|
}
|
|
|
|
// isDirectChild reports whether path is an immediate child of dir.
|
|
// Both paths must be absolute. Returns false for path == dir.
|
|
func isDirectChild(dir, path string) bool {
|
|
if !strings.HasPrefix(path, dir) {
|
|
return false
|
|
}
|
|
rest := path[len(dir):]
|
|
if len(rest) == 0 {
|
|
return false
|
|
}
|
|
if rest[0] != '/' && rest[0] != filepath.Separator {
|
|
return false
|
|
}
|
|
rest = rest[1:]
|
|
return len(rest) > 0 && !strings.ContainsRune(rest, '/') && !strings.ContainsRune(rest, filepath.Separator)
|
|
}
|
|
|
|
func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool) (uint64, bool) {
|
|
dw.mu.Lock()
|
|
defer dw.mu.Unlock()
|
|
dw.nextCBID++
|
|
id := dw.nextCBID
|
|
sinceSeq := dw.events.sequence()
|
|
if dw.sequence != nil {
|
|
sinceSeq = dw.sequence()
|
|
}
|
|
dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq})
|
|
return id, true
|
|
}
|
|
|
|
func (dw *dirWatch) unwatch(id uint64) bool {
|
|
dw.mu.Lock()
|
|
defer dw.mu.Unlock()
|
|
for i, cb := range dw.callbacks {
|
|
if cb.id == id {
|
|
dw.callbacks = append(dw.callbacks[:i], dw.callbacks[i+1:]...)
|
|
return len(dw.callbacks) == 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (dw *dirWatch) unref(w *watcher) {
|
|
dw.mu.Lock()
|
|
empty := len(dw.callbacks) == 0
|
|
dw.mu.Unlock()
|
|
if empty {
|
|
w.removeDirWatch(dw)
|
|
}
|
|
}
|