diff --git a/.golangci.yml b/.golangci.yml index e3a35b86d7c..28a868275c8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -96,7 +96,7 @@ linters: - path: internal/fourslash/tests/gen/ linters: - misspell - - path: 'internal/(repo|testutil|testrunner|vfs|pprof|execute/tsctests|bundled)|cmd/tsgo' + - path: 'internal/(repo|testutil|testrunner|vfs|pprof|execute/tsctests|bundled|fswatch)|cmd/tsgo' text: should likely be used instead - path: '(.+)_test\.go$' text: should likely be used instead diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 1502a9aedd1..a4d1f92970d 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -1,7 +1,11 @@ package execute import ( + "errors" + "fmt" + "io" "reflect" + "strings" "sync" "time" @@ -12,6 +16,7 @@ import ( "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/execute/incremental" "github.com/microsoft/typescript-go/internal/execute/tsc" + "github.com/microsoft/typescript-go/internal/fswatch" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs/cachedvfs" @@ -71,6 +76,10 @@ type Watcher struct { sourceFileCache *collections.SyncMap[tspath.Path, *cachedSourceFile] fileWatcher *vfswatch.FileWatcher + watches []fswatch.Watch // targeted directory/file watches + watchTerminated chan struct{} // closed when a watch terminates + doCycleCh chan struct{} // buffered signal to run DoCycle off the callback goroutine + debugLog io.Writer // nil = silent; set via TS_WATCH_DEBUG } var _ tsc.Watcher = (*Watcher)(nil) @@ -92,16 +101,13 @@ func createWatcher( reportWatchStatus: tsc.CreateWatchStatusReporter(sys, configParseResult.Locale(), configParseResult.CompilerOptions(), testing), testing: testing, sourceFileCache: &collections.SyncMap[tspath.Path, *cachedSourceFile]{}, + watchTerminated: make(chan struct{}), + doCycleCh: make(chan struct{}, 1), } if configParseResult.ConfigFile != nil { w.configFileName = configParseResult.ConfigFile.SourceFile.FileName() } - w.fileWatcher = vfswatch.NewFileWatcher( - sys.FS(), - w.config.ParsedConfig.WatchOptions.WatchInterval(), - testing != nil, - w.DoCycle, - ) + w.fileWatcher = vfswatch.NewFileWatcher(sys.FS()) return w } @@ -116,7 +122,7 @@ func (w *Watcher) start() { } if w.sys.GetEnvironmentVariable("TS_WATCH_DEBUG") != "" { - w.fileWatcher.SetDebugLog(w.sys.Writer()) + w.debugLog = w.sys.Writer() } w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) @@ -124,7 +130,145 @@ func (w *Watcher) start() { w.mu.Unlock() if w.testing == nil { - w.fileWatcher.Run(w.sys.Now) + if err := w.subscribe(); err != nil { + fmt.Fprintf(w.sys.Writer(), "Error: Failed to start file watcher: %v\n", err) + return + } + // Process DoCycle signals on a dedicated goroutine so fswatch + // callbacks return immediately and don't block event delivery. + go func() { + for range w.doCycleCh { + w.DoCycle() + } + }() + // Block until a watch terminates (e.g. watched directory deleted), + // then clean up. + <-w.watchTerminated + w.closeWatches() + close(w.doCycleCh) + } +} + +func (w *Watcher) subscribe() error { + watcher := fswatch.Default() + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] using %s backend\n", watcher.Name()) + } + + // Watch wildcard directories from tsconfig (recursive or non-recursive + // based on the include patterns). This detects new/deleted source files. + if w.config.ConfigFile != nil { + for dir, recursive := range w.config.WildcardDirectories() { + realDir := w.sys.FS().Realpath(dir) + opts := []fswatch.WatchOption{fswatch.WithIgnore(shouldIgnoreWatchPath)} + if recursive { + opts = append(opts, fswatch.WithRecursive()) + } + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] watching directory %s (recursive=%v)\n", realDir, recursive) + } + watch, err := watcher.WatchDirectory(realDir, w.onWatchEvents, opts...) + if err != nil { + w.closeWatches() + return fmt.Errorf("watching %s: %w", realDir, err) + } + w.watches = append(w.watches, watch) + } + } + + // Watch config files (tsconfig.json and extended configs) for changes. + for _, path := range w.configFilePaths { + realPath := w.sys.FS().Realpath(path) + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] watching file %s\n", realPath) + } + watch, err := watcher.WatchFile(realPath, w.onWatchEvents) + if err != nil { + w.closeWatches() + return fmt.Errorf("watching %s: %w", realPath, err) + } + w.watches = append(w.watches, watch) + } + + if len(w.watches) == 0 { + // No config file — watch the current directory non-recursively. + dir := w.sys.FS().Realpath(w.sys.GetCurrentDirectory()) + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] no tsconfig, watching %s\n", dir) + } + watch, err := watcher.WatchDirectory(dir, w.onWatchEvents, + fswatch.WithIgnore(shouldIgnoreWatchPath), + ) + if err != nil { + return err + } + w.watches = append(w.watches, watch) + } + + return nil +} + +func (w *Watcher) closeWatches() { + for _, watch := range w.watches { + watch.Close() + } + w.watches = nil +} + +func shouldIgnoreWatchPath(path string) bool { + p := tspath.NormalizeSlashes(path) + return strings.HasSuffix(p, "/.git") || + strings.Contains(p, "/.git/") || + strings.Contains(p, "/node_modules/.") || + strings.Contains(p, "/.#") +} + +func (w *Watcher) onWatchEvents(events []fswatch.Event, err error) { + if err != nil { + if errors.Is(err, fswatch.ErrOverflow) { + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] event overflow, triggering rebuild\n") + } + w.signalDoCycle() + return + } + if errors.Is(err, fswatch.ErrWatchTerminated) { + fmt.Fprintf(w.sys.Writer(), "Warning: File watcher terminated: %v\n", err) + close(w.watchTerminated) + return + } + fmt.Fprintf(w.sys.Writer(), "Warning: File watch error: %v\n", err) + return + } + + if len(events) > 0 { + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] %d event(s): ", len(events)) + for i, e := range events { + if i > 0 { + fmt.Fprint(w.debugLog, ", ") + } + if i >= 5 { + fmt.Fprintf(w.debugLog, "... and %d more", len(events)-i) + break + } + fmt.Fprintf(w.debugLog, "%s %s", e.Kind, e.Path) + } + fmt.Fprintln(w.debugLog) + } + w.signalDoCycle() + } +} + +// signalDoCycle sends a non-blocking signal to the DoCycle goroutine. +// If a signal is already pending, additional signals are coalesced. +func (w *Watcher) signalDoCycle() { + select { + case w.doCycleCh <- struct{}{}: + // Signal sent; the DoCycle goroutine will pick it up. + default: + // A signal is already pending; this event will be covered + // by the next DoCycle's filesystem scan. } } @@ -135,6 +279,9 @@ func (w *Watcher) DoCycle() { return } if !w.fileWatcher.WatchStateUninitialized() && !w.configModified && !w.fileWatcher.HasChangesFromWatchState() { + if w.debugLog != nil { + fmt.Fprintf(w.debugLog, "[watch] DoCycle: no tracked files changed, skipping rebuild\n") + } if w.testing != nil { w.testing.OnProgram(w.program) } @@ -177,7 +324,6 @@ func (w *Watcher) doBuild() { result := w.compileAndEmit() cached.DisableAndClearCache() w.fileWatcher.UpdateWatchState(tfs.SeenFiles.ToSlice(), wildcardDirs) - w.fileWatcher.SetPollInterval(w.config.ParsedConfig.WatchOptions.WatchInterval()) w.configModified = false programFiles := w.program.GetProgram().FilesByPath() diff --git a/internal/fswatch/CHANGES.md b/internal/fswatch/CHANGES.md new file mode 100644 index 00000000000..347d9005ade --- /dev/null +++ b/internal/fswatch/CHANGES.md @@ -0,0 +1,243 @@ +# Changes from upstream `@parcel/watcher` + +This Go port started from the C++ +[`@parcel/watcher`](https://github.com/parcel-bundler/watcher) (v2.5.6, +`8926bb8`) and has diverged significantly. This document covers API differences, +simplifications, new features, and bugfixes. + +## API differences + +### Method naming + +| C++ / JS | Go | +| ---------------------- | ---------------------------------- | +| `subscribe(dir, fn)` | `WatchDirectory(dir, fn, opts...)` | +| — | `WatchFile(path, fn)` | +| `unsubscribe(dir, fn)` | `w.Close()` | + +### Recursion default + +C++ `subscribe` is always recursive. Go's `WatchDirectory` is **non-recursive by +default**, watching only direct children. Pass `WithRecursive()` to watch the +entire tree. This matches TypeScript's `watchDirectory(path, cb, recursive?)` +where recursive is opt-in. + +### Event kinds + +C++ has three event kinds: create, update, delete. Go has two: **`EventUpdate`** +and **`EventDelete`**. File creation is reported as `EventUpdate`. `tsc --watch` +doesn't distinguish between a file being created and a file being modified; both +mean "something changed, rebuild." This also sidesteps a C++ FSEvents bug where +pre-existing files are misclassified as "created" because the internal tree +starts empty at subscribe time. + +### Watch options + +Go adds functional options not present in the C++ API: + +- **`WithRecursive()`**: opt in to recursive directory tree watching. +- **`WithIgnore(func(path string) bool)`**: filter events per-subscriber before + delivery. Return true to drop. + +### File watching + +`WatchFile(path, fn)` watches a single file by watching its parent directory +non-recursively and filtering events to the target path. Multiple file watches +in the same directory share one OS watch. Not available in the C++ API. + +### Error delivery + +C++ delivers errors via a separate error callback or return value. Go delivers +errors through the same `WatchCallback(events, err)` with sentinel errors: + +- `ErrOverflow`: recoverable, the watch stays active. +- `ErrWatchTerminated`: terminal, call `Close()` to clean up. + +`ErrUnavailable` is returned directly from `WatchDirectory`/`WatchFile` (not +through the callback) when the watcher is not supported on the current platform. + +## Simplifications + +### No in-memory directory tree + +C++ maintains an in-memory `DirTree` for every subscription on every backend, +storing path, type, and mtime for every watched file. The tree serves two +purposes: mtime-based event dedup (suppressing events when the mtime hasn't +changed) and create-vs-update classification (if a path is in the tree it's an +update, otherwise it's a create). + +Go removes the tree entirely on inotify, fanotify, Windows, and FSEvents. With +mtime tracking removed and only two event kinds (update and delete), the tree +became write-only on those backends: populated during setup and event handling +but never read from. Event classification relies on kernel flags instead of stat +calls, eliminating O(events) syscalls from the hot path. kqueue needs a +path-to-fd mapping (kqueue identifies events by fd, not path), but uses a flat +map holding only path and isDir. + +C++ also maintains a separate lazily-populated `DirTree` for FSEvents, used for +create/update classification. Because the tree starts empty at subscribe time, +pre-existing files aren't in it, and the first modification of any pre-existing +file is misclassified as "create" instead of "update." Go's FSEvents backend +classifies events using only the kernel-provided flags. Pure +create/remove/modify cases need zero syscalls; only the ambiguous-flags case +(multiple flags set) does one `Lstat` to check existence. + +### No attribute events + +C++ watches `IN_ATTRIB` (inotify), `FAN_ATTRIB` (fanotify), and +`FILE_NOTIFY_CHANGE_ATTRIBUTES` (Windows). Go removes all three from the watch +masks. `chmod`, `chown`, and other metadata-only changes don't trigger events. +kqueue still receives `NOTE_ATTRIB` (needed for truncate on some BSDs), but the +events are delivered as `EventUpdate` without special handling. + +### Simpler event coalescing + +With only two event kinds (update, delete), the `eventList` coalescing logic is +simpler: + +- `create + delete` within one batch cancels out (the entry is skipped). +- `delete + create` becomes update (the rapid delete+recreate pattern). +- `update + delete` yields delete. +- `delete + update` yields delete (a bare `update` does not resurrect a deleted + entry; only an explicit `create` does). + +### Per-backend debouncer + +Upstream uses one process-wide `Debounce::getShared()` singleton that batches +events for every `Watcher` in the process. This is a fine choice for +parcel-watcher's setting: Node consumers serialize through the libuv event loop +anyway, so spawning multiple debounce threads wouldn't buy any downstream +parallelism. + +Go can handle concurrent work cheaply, so the Go port creates one debouncer per +backend (inotify, fanotify, kqueue, fsevents, windows) instead of one per +process. Each backend's debouncer is created lazily on first subscribe and +serves only that backend's `dirWatch`es, so a slow user callback on one backend +can't starve event delivery on any of the others. In practice most callers will +only ever use one backend (`Default()`), so this mainly matters for processes +that mix backends, but the cost of the split is essentially nothing. + +## New backends + +**fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It +uses FID-based event reporting, avoiding the inotify per-user watch limit +entirely. Written from scratch rather than ported from the upstream +[PR #180](https://github.com/parcel-bundler/watcher/pull/180), which has several +bugs (see below). The backend runtime-probes `FAN_RENAME` (Linux 5.17+) and +falls back to `FAN_MOVED_FROM`/`FAN_MOVED_TO`. + +## Pure Go, no cgo + +The C++ library requires a C++ compiler and platform-specific build +configuration. The Go port is pure Go on all platforms: + +- **macOS FSEvents**: CoreFoundation/CoreServices calls via + `//go:cgo_import_dynamic` and hand-written assembly trampolines (amd64 and + arm64), following the pattern from Go's `crypto/x509/internal/macos`. The + FSEvents C callback runs on a libdispatch (GCD) thread, not a Go goroutine. An + assembly shim, staying entirely in C calling convention, retains the CFArray + of paths, allocates a per-callback payload on the C heap, copies the flags + array into it, and writes the payload pointer to the stream's event pipe, + waking a dedicated Go event-loop goroutine that classifies the events and + frees the payload. The shim then returns immediately, so the dispatch thread + never enters Go ABI and does not wait for Go-side event classification. Each + FSEventStream has its own serial GCD dispatch queue and event pipe, so + callbacks for different streams run concurrently without contention: a stuck + callback for one stream cannot back up callbacks for any other stream behind + it. Teardown invalidates the stream and uses a `dispatch_sync_f` barrier on + the stream's serial queue before closing the pipe, releasing the queue, and + unpinning the callback state. +- **Windows**: direct `x/sys/windows` syscalls. +- **Linux/BSD**: direct `x/sys/unix` syscalls. + +Cross-compilation works without cgo: +`CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build ./...` + +## Bugfixes from upstream C++ + +### 1. Windows: dropped create event when GetFileAttributesEx fails + +`ReadDirectoryChangesW` reports `FILE_ACTION_ADDED` for files that may vanish +before processing. C++ guards the event inside the attribute lookup success +check, silently dropping it. Go always emits the event. + +### 2. Windows: race between subscribe and ReadDirectoryChangesW + +C++ queues an APC that eventually arms the watch. A filesystem operation between +`subscribe()` returning and the APC firing is missed. Go arms the first +`ReadDirectoryChangesW` synchronously before returning. + +### 3. kqueue: TOCTOU race and early-return in compareDir + +C++ emits a create event before confirming the file can be opened. If it +vanishes, a phantom create is queued. Additionally, `watchDir` failure returns +from the entire `compareDir`, skipping delete detection for other files. + +### 4. Event coalescing: create+delete+create yields wrong result + +C++ clears `isDeleted` without clearing `isCreated`, so a create+delete+create +sequence produces a spurious "create" instead of the intended "update." + +### 5. Event drain race: getEvents + clear are separate locks + +C++ calls `getEvents()` then `clear()`, each independently locking. Events +inserted between the two calls are silently lost. Go uses an atomic `drain()` +that snapshots and clears under a single lock. + +### 6. inotify: IN_Q_OVERFLOW silently skipped + +C++ skips overflow events without notifying subscribers. Go delivers +`ErrOverflow` to all active watches. + +### 7. inotify: descendant watches not cleaned on directory deletion + +C++ only removes exact-match watches when a directory is deleted. Watches for +descendant paths remain and may receive stale events if watch descriptors are +reused. + +### 8. kqueue: mtime guard suppresses NOTE_WRITE on coarse-mtime filesystems + +C++ guards all `NOTE_WRITE | NOTE_ATTRIB | NOTE_EXTEND` events behind an mtime +check. On OpenBSD FFS (1-second mtime granularity), rapid writes share the same +mtime and are suppressed. + +### 9. Windows: readTree follows symlinked directories + +C++ checks `FILE_ATTRIBUTE_DIRECTORY` without excluding +`FILE_ATTRIBUTE_REPARSE_POINT`, causing symlinks and junctions to be traversed. + +### 10. kqueue: delete/create coalescing race and fd leak + +When a file is deleted and recreated, kqueue may deliver `NOTE_WRITE` on the +parent before `NOTE_DELETE` on the file. C++ processes these in order, missing +the create. Separately, deleted fds are erased from the map but never closed. + +### 11. kqueue: tryRewatchLocked race for directories + +On OpenBSD, `RemoveAll(dir)` can deliver `NOTE_DELETE` for a directory while +`rmdir` is still in progress. `tryRewatchLocked` sees the directory still exists +via `Lstat` and emits a spurious "update" instead of "delete." Go skips +`tryRewatchLocked` for directories entirely. + +### 12. FSEvents: empty tree misclassifies updates as creates + +C++ maintains a lazily-populated `DirTree` for FSEvents. Pre-existing files +aren't in the tree at subscribe time, so the first modification is classified as +"create" instead of "update." + +## Bugfixes from upstream fanotify PR + +The upstream [PR #180](https://github.com/parcel-bundler/watcher/pull/180) adds +a fanotify backend to the C++ library. Go's fanotify backend was written from +scratch and avoids the following issues in the C++ PR: + +- **FAN_Q_OVERFLOW silently skipped.** C++ skips the event; Go delivers + `ErrOverflow`. +- **Descendant watches not cleaned.** Same exact-match-only bug as inotify. +- **Unchecked lstat/stat return values.** C++ feeds uninitialized stat data to + `tree->add()` on rapid create+delete. Go guards all stat calls. +- **No merged-event disambiguation.** C++ processes `FAN_CREATE` before + `FAN_DELETE` in an if/else chain, so a merged create+delete always emits a + spurious create. Go stats the path to determine temporal order. +- **No runtime FAN_RENAME probing.** C++ uses compile-time `#ifdef`; Go probes + at runtime and falls back gracefully. diff --git a/internal/fswatch/LICENSE b/internal/fswatch/LICENSE new file mode 100644 index 00000000000..7fb9bc953e9 --- /dev/null +++ b/internal/fswatch/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-present Devon Govett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/fswatch/README.md b/internal/fswatch/README.md new file mode 100644 index 00000000000..0df6583fafa --- /dev/null +++ b/internal/fswatch/README.md @@ -0,0 +1,98 @@ +# fswatch + +A filesystem watcher for Go. Pure Go, no cgo. + +A Go port of the C++ +[`@parcel/watcher`](https://github.com/parcel-bundler/watcher), with substantial +modifications. See [`CHANGES.md`](CHANGES.md) for the list of differences and +bugfixes. + +| GOOS | Watcher | +| ------------------------------------------- | ------------------------------------------ | +| `linux` | fanotify (default, kernel ≥ 5.13), inotify | +| `darwin` | FSEvents (default), kqueue | +| `windows` | `ReadDirectoryChangesW` | +| `freebsd`, `openbsd`, `netbsd`, `dragonfly` | kqueue | + +## Usage + +```go +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + + "github.com/microsoft/typescript-go/internal/fswatch" +) + +func main() { + dir, _ := os.Getwd() + + sub, err := fswatch.Default().WatchDirectory(dir, func(events []fswatch.Event, err error) { + if err != nil { + log.Println("watch error:", err) + return + } + for _, e := range events { + fmt.Printf("%s %s\n", e.Kind, e.Path) + } + }) + if err != nil { + log.Fatal(err) + } + defer sub.Close() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c +} +``` + +### Picking a watcher + +`Default()` picks the best watcher for the current OS. To use a specific one: + +```go +sub, err := fswatch.Inotify().WatchDirectory(dir, callback, fswatch.WithRecursive()) +``` + +All watchers exist on every platform. Use `Available()` to check support at +runtime, or just call `WatchDirectory`; it returns `ErrUnavailable` if the +watcher isn't supported. + +### Error handling + +Errors are delivered through the callback. Use `errors.Is` to distinguish them: + +- **`ErrOverflow`**: some events were lost (kernel queue overflow). The watch is + still active; rescan the directory to catch up. +- **`ErrWatchTerminated`**: the watch is dead (e.g. directory deleted). No + further events will arrive. Call `Close` to clean up. + +```go +if errors.Is(err, fswatch.ErrOverflow) { + rescanDir(dir) + return +} +if errors.Is(err, fswatch.ErrWatchTerminated) { + log.Println("watch terminated:", err) + sub.Close() + return +} +``` + +### Behavior notes + +- Events arriving in quick succession are **batched** before delivery. +- Event order within a batch is **not guaranteed**. +- The callback runs on a library goroutine, not the caller's. Each watch's + callback is serialized (never concurrent with itself). +- Paths in events are absolute. **Resolve symlinks before subscribing**; + backends report canonical paths: + + ```go + realDir, err := filepath.EvalSymlinks(dir) + ``` diff --git a/internal/fswatch/canonicalize_darwin.go b/internal/fswatch/canonicalize_darwin.go new file mode 100644 index 00000000000..9a81ffdfb5b --- /dev/null +++ b/internal/fswatch/canonicalize_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +// canonicalizePath returns the path in the form the library uses for +// internal bookkeeping and event delivery. On macOS, paths from FSEvents +// arrive using whatever Unicode normalization form is stored on disk; +// usually NFC, but sometimes NFD (e.g. files created on legacy HFS+ +// volumes or copied from systems that use NFD). APFS resolves either form +// to the same inode, but raw string comparisons against caller-supplied +// paths (typically NFC) silently break. Normalizing every path the +// library ingests to NFC keeps watch keys, dirWatch lookups, WatchFile +// filters, and event paths all in one consistent form. +func canonicalizePath(p string) string { return normalizeNFC(p) } diff --git a/internal/fswatch/canonicalize_other.go b/internal/fswatch/canonicalize_other.go new file mode 100644 index 00000000000..5ed784c3947 --- /dev/null +++ b/internal/fswatch/canonicalize_other.go @@ -0,0 +1,8 @@ +//go:build !(darwin && (amd64 || arm64)) + +package fswatch + +// canonicalizePath is a no-op on platforms whose watchers report paths +// using the same bytes the caller provided. See canonicalize_darwin.go +// for the rationale on macOS. +func canonicalizePath(p string) string { return p } diff --git a/internal/fswatch/cgmanifest.json b/internal/fswatch/cgmanifest.json new file mode 100644 index 00000000000..f43e38803b5 --- /dev/null +++ b/internal/fswatch/cgmanifest.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/component-detection-manifest.json", + "version": 1, + "registrations": [ + { + "component": { + "type": "git", + "git": { + "repositoryUrl": "https://github.com/parcel-bundler/watcher", + "commitHash": "8926bb8b281733bbfcaf69bb4e62ab7a1431c42a", + "tag": "v2.5.6" + } + } + } + ] +} diff --git a/internal/fswatch/debounce.go b/internal/fswatch/debounce.go new file mode 100644 index 00000000000..71428f2571a --- /dev/null +++ b/internal/fswatch/debounce.go @@ -0,0 +1,155 @@ +package fswatch + +import ( + "sync" + "time" +) + +const ( + defaultMinWaitTime = 50 * time.Millisecond + defaultMaxWaitTime = 500 * time.Millisecond +) + +var ( + minWaitTime = defaultMinWaitTime + maxWaitTime = defaultMaxWaitTime +) + +// debounce batches filesystem events for one backend. Each *watcher +// owns one debounce instance, created lazily on first subscribe and +// living for the process lifetime. The background goroutine costs +// nothing when idle. +// +// Per-backend (rather than process-wide) isolation means a slow user +// callback on one backend cannot starve event delivery on the others. +// +// Internally uses a resettable latch: the loop blocks until trigger() +// is called, then coalesces for minWaitTime before firing callbacks. +type debounce struct { + mu sync.Mutex + callbacks map[any]func() + lastTime time.Time + + // Latch state: waitCh is the persistent gate (closed = signalled), + // triggerCh is replaced on each trigger for timed waits. + latchMu sync.Mutex + waitCh chan struct{} + triggerCh chan struct{} + notified bool +} + +func newDebounce() *debounce { + d := &debounce{ + callbacks: make(map[any]func()), + } + go d.loop() + return d +} + +// add registers a callback under key. +func (d *debounce) add(key any, cb func()) { + d.mu.Lock() + defer d.mu.Unlock() + d.callbacks[key] = cb +} + +// remove deregisters the callback for key. +func (d *debounce) remove(key any) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.callbacks, key) +} + +// trigger wakes the debounce loop. +func (d *debounce) trigger() { + d.latchMu.Lock() + defer d.latchMu.Unlock() + if !d.notified { + d.notified = true + close(d.waitChLocked()) + } + close(d.triggerChLocked()) + d.triggerCh = make(chan struct{}) +} + +func (d *debounce) loop() { + for { + d.latchWait() + d.notifyIfReady() + } +} + +func (d *debounce) notifyIfReady() { + d.mu.Lock() + now := time.Now() + gap := now.Sub(d.lastTime) + if gap > maxWaitTime { + d.lastTime = now + d.mu.Unlock() + d.fireCallbacks() + return + } + d.mu.Unlock() + d.coalesceWait() +} + +func (d *debounce) coalesceWait() { + d.latchMu.Lock() + ch := d.triggerChLocked() + d.latchMu.Unlock() + select { + case <-ch: + // Do nothing; new event triggered, fire on the next tick. + case <-time.After(minWaitTime): + d.fireCallbacks() + } +} + +// fireCallbacks snapshots and invokes all registered callbacks. +func (d *debounce) fireCallbacks() { + d.mu.Lock() + d.lastTime = time.Now() + cbs := make([]func(), 0, len(d.callbacks)) + for _, cb := range d.callbacks { + cbs = append(cbs, cb) + } + d.mu.Unlock() + + d.latchReset() + + for _, cb := range cbs { + cb() + } +} + +// ----- latch helpers (replace signal_) ------------------------------------ + +func (d *debounce) waitChLocked() chan struct{} { + if d.waitCh == nil { + d.waitCh = make(chan struct{}) + } + return d.waitCh +} + +func (d *debounce) triggerChLocked() chan struct{} { + if d.triggerCh == nil { + d.triggerCh = make(chan struct{}) + } + return d.triggerCh +} + +func (d *debounce) latchWait() { + d.latchMu.Lock() + ch := d.waitChLocked() + d.latchMu.Unlock() + <-ch +} + +func (d *debounce) latchReset() { + d.latchMu.Lock() + defer d.latchMu.Unlock() + if d.notified { + d.notified = false + d.waitCh = make(chan struct{}) + } +} diff --git a/internal/fswatch/event.go b/internal/fswatch/event.go new file mode 100644 index 00000000000..34507aa4c68 --- /dev/null +++ b/internal/fswatch/event.go @@ -0,0 +1,159 @@ +package fswatch + +import "sync" + +// EventKind classifies a filesystem change. +type EventKind int + +const ( + EventUpdate EventKind = iota + 1 + EventDelete +) + +func (k EventKind) String() string { + switch k { + case EventUpdate: + return "update" + case EventDelete: + return "delete" + default: + return "unknown" + } +} + +// Event describes a single filesystem change. +type Event struct { + Kind EventKind + Path string +} + +// eventEntry tracks coalescing state during a debounce batch. +// The two booleans are independent: a file can be created then deleted +// in the same batch, which cancels out (filtered by getEvents). +type eventEntry struct { + isCreated bool + isDeleted bool +} + +// eventList coalesces filesystem events by path within a debounce window. +// - create after delete → update (rapid delete+recreate) +// - getEvents skips entries that were both created and deleted +type eventList struct { + mu sync.Mutex + entries map[string]*eventEntry + err error +} + +// create records a new-file event for path. Both create and update +// produce EventUpdate externally; isCreated is tracked only for +// coalescing (create+delete within a batch cancels out). +func (el *eventList) create(path string) { + el.mu.Lock() + defer el.mu.Unlock() + entry := el.getOrCreate(path) + if entry.isDeleted { + // Rapid delete+recreate: clear both flags so the entry + // emits EventUpdate (the default for non-deleted entries). + // https://github.com/parcel-bundler/watcher/issues/72 + entry.isDeleted = false + entry.isCreated = false + } else { + entry.isCreated = true + } +} + +// update records an update event for path. +func (el *eventList) update(path string) { + el.mu.Lock() + defer el.mu.Unlock() + el.getOrCreate(path) +} + +// remove records a delete event for path. +func (el *eventList) remove(path string) { + el.mu.Lock() + defer el.mu.Unlock() + entry := el.getOrCreate(path) + entry.isDeleted = true +} + +// size returns the number of tracked entries (including ones that may +// cancel out in getEvents). +func (el *eventList) size() int { + el.mu.Lock() + defer el.mu.Unlock() + return len(el.entries) +} + +// snapshotLocked returns the current set of pending events with +// create+delete pairs filtered out. Caller must hold el.mu. +func (el *eventList) snapshotLocked() []Event { + out := make([]Event, 0, len(el.entries)) + for path, e := range el.entries { + if e.isCreated && e.isDeleted { + continue + } + kind := EventUpdate + if e.isDeleted { + kind = EventDelete + } + out = append(out, Event{Kind: kind, Path: path}) + } + return out +} + +// getEvents returns a snapshot of events, skipping entries that were both +// created and deleted. Order is not guaranteed. +func (el *eventList) getEvents() []Event { + el.mu.Lock() + defer el.mu.Unlock() + return el.snapshotLocked() +} + +// drain atomically snapshots all pending events and the stored error, +// then clears the list. This prevents events added between a separate +// getEvents+clear from being silently dropped. +func (el *eventList) drain() ([]Event, error) { + el.mu.Lock() + defer el.mu.Unlock() + out := el.snapshotLocked() + err := el.err + el.entries = nil + el.err = nil + return out, err +} + +// setError stores the first error encountered (later errors are ignored). +func (el *eventList) setError(err error) { + el.mu.Lock() + defer el.mu.Unlock() + if el.err == nil { + el.err = err + } +} + +// hasError reports whether an error has been recorded. +func (el *eventList) hasError() bool { + el.mu.Lock() + defer el.mu.Unlock() + return el.err != nil +} + +// getError returns the stored error (or nil if none). +func (el *eventList) getError() error { + el.mu.Lock() + defer el.mu.Unlock() + return el.err +} + +func (el *eventList) getOrCreate(path string) *eventEntry { + if el.entries == nil { + el.entries = make(map[string]*eventEntry) + } + if e, ok := el.entries[path]; ok { + return e + } + e := &eventEntry{} + el.entries[path] = e + return e +} diff --git a/internal/fswatch/eventlist_test.go b/internal/fswatch/eventlist_test.go new file mode 100644 index 00000000000..1346364473e --- /dev/null +++ b/internal/fswatch/eventlist_test.go @@ -0,0 +1,125 @@ +// Unit tests for eventList coalescing and drain semantics. + +package fswatch + +import ( + "errors" + "testing" +) + +// clear is only used by tests; live code drains via drain() so the +// snapshot and the reset happen atomically. +func (el *eventList) clear() { + el.mu.Lock() + defer el.mu.Unlock() + el.entries = nil + el.err = nil +} + +func TestEventListCreateThenDelete(t *testing.T) { + t.Parallel() + var el eventList + el.create("a") + el.remove("a") + if el.size() != 1 { + t.Fatalf("size after create+remove want 1, got %d", el.size()) + } + if got := el.getEvents(); len(got) != 0 { + t.Fatalf("getEvents should drop create+delete, got %v", got) + } +} + +func TestEventListDeleteThenCreate(t *testing.T) { + t.Parallel() + var el eventList + el.remove("a") + el.create("a") + got := el.getEvents() + if len(got) != 1 { + t.Fatalf("expected 1 event, got %d", len(got)) + } + // "Assume update event when rapidly removed and created". + if got[0].Kind != EventUpdate { + t.Fatalf("expected update, got %v", got[0].Kind) + } +} + +func TestEventListCreateDeleteCreate(t *testing.T) { + t.Parallel() + var el eventList + el.create("a") + el.remove("a") + el.create("a") + got := el.getEvents() + if len(got) != 1 { + t.Fatalf("expected 1 event, got %d", len(got)) + } + if got[0].Kind != EventUpdate { + t.Fatalf("create+delete+create should coalesce to update, got %v", got[0].Kind) + } +} + +func TestEventListErrorIsLatchedAndCleared(t *testing.T) { + t.Parallel() + var el eventList + if el.hasError() { + t.Fatal("fresh eventList should have no error") + } + if got := el.getError(); got != nil { + t.Fatalf("fresh getError want nil, got %v", got) + } + el.setError(errors.New("first")) + el.setError(errors.New("second")) // only first wins + if !el.hasError() { + t.Fatal("hasError should be true after setError") + } + if got := el.getError(); got == nil || got.Error() != "first" { + t.Fatalf("getError want first, got %v", got) + } + el.clear() + if el.hasError() { + t.Fatal("clear should drop the error") + } + if got := el.getError(); got != nil { + t.Fatalf("post-clear getError want nil, got %v", got) + } +} + +func TestEventListDrainIsAtomic(t *testing.T) { + t.Parallel() + var el eventList + el.create("a") + el.update("b") + el.setError(errors.New("oops")) + + events, err := el.drain() + if err == nil { + t.Fatal("drain should return the error") + } + if len(events) != 2 { + t.Fatalf("drain should return 2 events, got %d", len(events)) + } + + events2, err2 := el.drain() + if err2 != nil { + t.Fatalf("second drain should have no error, got %v", err2) + } + if len(events2) != 0 { + t.Fatalf("second drain should be empty, got %d", len(events2)) + } +} + +func TestEventListDrainReturnsErrorWithEvents(t *testing.T) { + t.Parallel() + var el eventList + el.create("file.txt") + el.setError(errors.New("overflow")) + + events, err := el.drain() + if err == nil { + t.Fatal("expected error from drain") + } + if len(events) != 1 { + t.Fatalf("expected 1 event alongside error, got %d", len(events)) + } +} diff --git a/internal/fswatch/fanotify_linux.go b/internal/fswatch/fanotify_linux.go new file mode 100644 index 00000000000..ef9d00f3a06 --- /dev/null +++ b/internal/fswatch/fanotify_linux.go @@ -0,0 +1,756 @@ +//go:build linux + +package fswatch + +import ( + "encoding/binary" + "errors" + "fmt" + "sync/atomic" + "unsafe" + + "golang.org/x/sys/unix" +) + +// --------------------------------------------------------------------------- +// fanotify_linux.go: Linux fanotify backend +// +// Uses Linux's fanotify(7) API (kernel ≥ 5.13 without CAP_SYS_ADMIN) to +// watch directory trees. Unlike inotify, fanotify uses FID-based event +// reporting (FAN_REPORT_FID | FAN_REPORT_DFID_NAME): each event carries the +// parent directory's file handle and the child entry name, so watch +// dispatch is keyed by (fsid, handle_type, handle_bytes) instead of a wd +// integer. This avoids the inotify per-user watch limit (fs.inotify. +// max_user_watches) entirely. +// +// ┌──────────────────────────────────────────────────────────────┐ +// │ fanotifyBackend │ +// │ │ +// │ ┌───────────┐ poll(2) ┌──────────────────┐ │ +// │ │ pipe[0] ├──────────────────────►│ │ │ +// │ │ (wakeup) │ │ start() │ │ +// │ └───────────┘ │ goroutine │ │ +// │ ┌───────────┐ │ (event loop) │ │ +// │ │ fanotify ├──────────────────────►│ │ │ +// │ │ fd │ └────────┬─────────┘ │ +// │ └───────────┘ │ │ +// │ handleEvents() │ +// │ │ │ +// │ parseFanotifyDfidNames │ +// │ (extract handleKey + name) │ +// │ │ │ +// │ ▼ │ +// │ ┌─────────────────────────┐ │ +// │ │ subscriptions │ │ +// │ │ map[handleKey] → []sub │ │ +// │ │ sub.dirWatch.events │ │ +// │ └─────────────────────────┘ │ +// │ │ +// │ handleKey = (fsid, handle_type, handle_bytes) │ +// │ obtained via statfs(2) + name_to_handle_at(2) per dir │ +// └──────────────────────────────────────────────────────────────┘ +// +// 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, handleParsedEvent, +// handleSubscription, handleRenameEvent) 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 (caller goroutine): +// 1. Walk the target directory. +// 2. On the first subscribe, probe FAN_RENAME support (Linux 5.17+) by +// attempting a fanotify_mark with FAN_RENAME. If the kernel returns +// EINVAL or EOPNOTSUPP, fall back to FAN_MOVED_FROM | FAN_MOVED_TO +// (two separate events instead of one paired event for renames). +// 3. For every directory found: +// a. fanotify_mark(FAN_MARK_ADD | FAN_MARK_ONLYDIR) to watch it. +// b. name_to_handle_at(2) to obtain the directory's file handle. +// c. statfs(2) to obtain the filesystem ID (fsid). +// d. Map (fsid, handle_type, handle_bytes) → fanotifySubscription. +// +// Event format: +// Each event has a FanotifyEventMetadata header followed by variable-length +// info records. parseFanotifyDfidNames extracts DFID_NAME records +// (FAN_EVENT_INFO_TYPE_DFID_NAME, OLD_DFID_NAME, NEW_DFID_NAME) containing +// the parent directory's file handle and child entry name. The file handle +// is matched against the watch map to find the watched directory. +// +// Event dispatch (on start goroutine): +// - FAN_CREATE / FAN_MOVED_TO → events.create (→ EventUpdate); if the new +// entry is a directory (FAN_ONDIR), recursively walk and mark it. +// - FAN_MODIFY → events.update (→ EventUpdate). +// - FAN_DELETE* / FAN_MOVE* → events.remove (→ EventDelete); drop +// subscriptions for the removed path and any descendants. +// - FAN_RENAME (5.17+) → single paired event with OLD_DFID_NAME + +// NEW_DFID_NAME info records; handleRenameEvent deletes the old path and +// creates the new path in one pass. +// - FAN_Q_OVERFLOW → set ErrOverflow on every active dirWatch. +// +// Merged events: fanotify can merge consecutive events on the same object +// into one event with multiple mask bits. When both create and delete bits +// are set, handleSubscription stats the path to determine which happened +// last (exists → delete-then-create = update; gone → create-then-delete = +// events cancel out). +// +// 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 fanotify fd, pipe fds, and signals endedSignal. +// --------------------------------------------------------------------------- + +const ( + fanotifyInitFlags uint = unix.FAN_CLASS_NOTIF | unix.FAN_CLOEXEC | unix.FAN_NONBLOCK | + unix.FAN_REPORT_FID | unix.FAN_REPORT_DFID_NAME + + fanotifyMarkMaskBase uint64 = unix.FAN_CREATE | unix.FAN_DELETE | unix.FAN_MODIFY | + unix.FAN_DELETE_SELF | unix.FAN_MOVE_SELF | + unix.FAN_ONDIR | unix.FAN_EVENT_ON_CHILD + + // Used when FAN_RENAME is available (Linux 5.17+). + fanotifyMarkMaskRename uint64 = fanotifyMarkMaskBase | unix.FAN_RENAME + + // Fallback when FAN_RENAME is not available. + fanotifyMarkMaskMovedFromTo uint64 = fanotifyMarkMaskBase | unix.FAN_MOVED_FROM | unix.FAN_MOVED_TO + + fanotifyMarkAddFlags uint = unix.FAN_MARK_ADD | unix.FAN_MARK_ONLYDIR | unix.FAN_MARK_DONT_FOLLOW + + fanotifyBufferSize = 8192 +) + +// fanotifyHandleKey uniquely identifies a filesystem object by its fsid and +// file handle. Used as a map key for watch dispatch. +type fanotifyHandleKey struct { + fsid [2]int32 + handleType int32 + handle string // raw handle bytes as string for map comparability +} + +func makeFanotifyHandleKey(fsid [2]int32, handleType int32, handleBytes []byte) fanotifyHandleKey { + return fanotifyHandleKey{ + fsid: fsid, + handleType: handleType, + handle: string(handleBytes), + } +} + +// fanotifySubscription mirrors inotifySubscription for the fanotify backend. +type fanotifySubscription struct { + path string + dirWatch *dirWatch + key fanotifyHandleKey +} + +// fanotifyDfidName holds parsed directory FID + name from an info record. +type fanotifyDfidName struct { + key fanotifyHandleKey + name string // child entry name, or "" for self-events on directories +} + +// fanotifyBackend is the fanotify-based watcher backend for Linux. +type fanotifyBackend struct { + watcherBase + + pipeFDs [2]int + pipeWriteFD atomic.Int32 + fanotifyFD int + markMask uint64 // fanotifyMarkMaskRename or fanotifyMarkMaskMovedFromTo; 0 until first subscribe + noRename bool // when true, skip FAN_RENAME probe (for testing fallback path) + + subscriptions map[fanotifyHandleKey][]*fanotifySubscription + 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() { + if fanotifyAvailable() { + fanotifyWatcher.factory = func() watcherImpl { return newFanotifyBackend(false) } + } +} + +// fanotifyAvailable probes whether fanotify_init succeeds with the flags +// this backend needs. +func fanotifyAvailable() bool { + fd, err := unix.FanotifyInit(fanotifyInitFlags, unix.O_RDONLY|unix.O_CLOEXEC) + if err != nil { + return false + } + _ = unix.Close(fd) + return true +} + +// newFanotifyBackend creates a fanotify backend. If noRename is true, the +// backend skips the FAN_RENAME probe and forces the FAN_MOVED_FROM/FAN_MOVED_TO +// fallback path; this is only used by the fanotify-no-rename test watcher to +// exercise the fallback path on kernels that natively support FAN_RENAME. +func newFanotifyBackend(noRename bool) *fanotifyBackend { + b := &fanotifyBackend{ + pipeFDs: [2]int{-1, -1}, + fanotifyFD: -1, + noRename: noRename, + subscriptions: map[fanotifyHandleKey][]*fanotifySubscription{}, + endedSignal: make(chan struct{}), + readBuf: make([]byte, fanotifyBufferSize), + watchersTouched: make(map[*dirWatch]struct{}), + } + b.pipeWriteFD.Store(-1) + b.watcherBase.init(b) + return b +} + +func (b *fanotifyBackend) start() error { + 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.FanotifyInit(fanotifyInitFlags, unix.O_RDONLY|unix.O_CLOEXEC) + if err != nil { + return fmt.Errorf("unable to initialize fanotify: %w", err) + } + b.fanotifyFD = fd + + pollfds := []unix.PollFd{ + {Fd: int32(b.pipeFDs[0]), Events: unix.POLLIN}, + {Fd: int32(b.fanotifyFD), 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 +} + +func (b *fanotifyBackend) 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.fanotifyFD >= 0 { + _ = unix.Close(b.fanotifyFD) + b.fanotifyFD = -1 + } +} + +func (b *fanotifyBackend) shutdown() { + fd := b.pipeWriteFD.Load() + if fd < 0 { + return + } + _, _ = unix.Write(int(fd), []byte{'X'}) + <-b.endedSignal +} + +func (b *fanotifyBackend) subscribe(w *dirWatch) error { + // Probe FAN_RENAME on the first subscribe using the actual watch + // directory. FAN_RENAME (Linux 5.17+) yields a single paired event + // for renames; when unavailable we fall back to FAN_MOVED_FROM/ + // FAN_MOVED_TO which produces two separate events but is otherwise + // equivalent. The kernel rejects unknown mask bits with EINVAL. + if b.markMask == 0 { + if b.noRename { + b.markMask = fanotifyMarkMaskMovedFromTo + } else { + b.markMask = fanotifyMarkMaskRename + err := unix.FanotifyMark(b.fanotifyFD, fanotifyMarkAddFlags, fanotifyMarkMaskRename, unix.AT_FDCWD, w.dir) + switch { + case err == nil: + // B5: pair the probe Add with a matching Remove. If + // Remove fails (rare; only EINTR or kernel resource + // pressure realistically) we leave the probe mark + // attached for the life of the process, but since + // markDir below will Add the real mask with the same + // flags the kernel just merges them. The probe is the + // only failure path we explicitly retry. + for { + rmErr := unix.FanotifyMark(b.fanotifyFD, unix.FAN_MARK_REMOVE|unix.FAN_MARK_ONLYDIR, fanotifyMarkMaskRename, unix.AT_FDCWD, w.dir) + if rmErr == nil || !errors.Is(rmErr, unix.EINTR) { + break + } + } + case errors.Is(err, unix.EINVAL), errors.Is(err, unix.EOPNOTSUPP): + b.markMask = fanotifyMarkMaskMovedFromTo + } + } + } + if !w.recursive { + if err := b.markDir(w, w.dir); err != nil { + return &dirWatchError{ + err: fmt.Errorf("fanotify_mark on '%s' failed: %w", w.dir, err), + dirWatch: w, + } + } + return nil + } + if err := walkDir(w.dir, true, func(path string, isDir bool) error { + if !isDir { + return nil + } + if err := b.markDir(w, path); err != nil { + return &dirWatchError{ + err: fmt.Errorf("fanotify_mark on '%s' failed: %w", path, err), + dirWatch: w, + } + } + return nil + }); err != nil { + _ = b.closeWatch(w) + return err + } + return nil +} + +func (b *fanotifyBackend) markDir(w *dirWatch, path string) error { + if err := unix.FanotifyMark(b.fanotifyFD, fanotifyMarkAddFlags, b.markMask, unix.AT_FDCWD, path); err != nil { + return err + } + handle, _, err := unix.NameToHandleAt(unix.AT_FDCWD, path, 0) + if err != nil { + // Unmark since we can't track this directory without a handle. + _ = unix.FanotifyMark(b.fanotifyFD, unix.FAN_MARK_REMOVE|unix.FAN_MARK_ONLYDIR, b.markMask, unix.AT_FDCWD, path) + return fmt.Errorf("name_to_handle_at: %w", err) + } + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + _ = unix.FanotifyMark(b.fanotifyFD, unix.FAN_MARK_REMOVE|unix.FAN_MARK_ONLYDIR, b.markMask, unix.AT_FDCWD, path) + return fmt.Errorf("statfs: %w", err) + } + key := makeFanotifyHandleKey(st.Fsid.Val, handle.Type(), handle.Bytes()) + sub := &fanotifySubscription{path: path, dirWatch: w, key: key} + b.subscriptions[key] = append(b.subscriptions[key], sub) + return nil +} + +// handleEvents reads and dispatches fanotify events from the fd. +func (b *fanotifyBackend) handleEvents() error { + buf := b.readBuf + watchersTouched := b.watchersTouched + + for { + n, err := unix.Read(b.fanotifyFD, buf) + if err != nil { + if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) { + break + } + return fmt.Errorf("Error reading from fanotify: %w", err) + } + if n == 0 { + break + } + + metaSize := int(unsafe.Sizeof(unix.FanotifyEventMetadata{})) + data := buf[:n] + for len(data) >= metaSize { + meta := (*unix.FanotifyEventMetadata)(unsafe.Pointer(&data[0])) + if meta.Vers != unix.FANOTIFY_METADATA_VERSION { + return fmt.Errorf("unsupported fanotify metadata version: %d", meta.Vers) + } + eventLen := int(meta.Event_len) + if eventLen < int(meta.Metadata_len) || eventLen > len(data) { + break + } + + // FID mode: fd should be FAN_NOFD, but close if somehow set. + if meta.Fd >= 0 { + _ = unix.Close(int(meta.Fd)) + } + + if meta.Mask&unix.FAN_Q_OVERFLOW != 0 { + b.handleOverflow(watchersTouched) + data = data[eventLen:] + continue + } + + infoData := data[meta.Metadata_len:eventLen] + primary, renameTo := parseFanotifyDfidNames(infoData) + if meta.Mask&unix.FAN_RENAME != 0 { + if primary != nil || renameTo != nil { + b.handleRenameEvent(meta.Mask, primary, renameTo, watchersTouched) + } + } else if primary != nil { + b.handleParsedEvent(meta.Mask, primary, watchersTouched) + } + data = data[eventLen:] + } + } + + for w := range watchersTouched { + w.notify() + } + clear(watchersTouched) + return nil +} + +func (b *fanotifyBackend) handleOverflow(touched map[*dirWatch]struct{}) { + b.mu.Lock() + defer b.mu.Unlock() + seen := map[*dirWatch]struct{}{} + for _, subs := range b.subscriptions { + for _, s := range subs { + if _, ok := seen[s.dirWatch]; ok { + continue + } + seen[s.dirWatch] = struct{}{} + s.dirWatch.events.setError(ErrOverflow) + touched[s.dirWatch] = struct{}{} + } + } +} + +func (b *fanotifyBackend) handleRenameEvent(mask uint64, dfidOld *fanotifyDfidName, dfidNew *fanotifyDfidName, touched map[*dirWatch]struct{}) { + b.mu.Lock() + defer b.mu.Unlock() + + isDir := mask&unix.FAN_ONDIR != 0 + + // Remove from old location. + if dfidOld != nil && dfidOld.name != "" && dfidOld.name != "." { + for _, s := range b.subscriptions[dfidOld.key] { + oldPath := s.path + "/" + dfidOld.name + // If the renamed item is a dir, drop its subscriptions and + // all descendant subscriptions. The kernel marks themselves + // leak when the destination is outside our watched tree: + // fanotify has no path-independent unmark and we don't + // keep fds open for marked directories. + if isDir { + b.dropSubsForPathAndDescendantsLocked(oldPath) + } + s.dirWatch.events.remove(oldPath) + touched[s.dirWatch] = struct{}{} + } + } + + // Create at new location. + if dfidNew != nil && dfidNew.name != "" && dfidNew.name != "." { + for _, s := range b.subscriptions[dfidNew.key] { + newPath := s.path + "/" + dfidNew.name + s.dirWatch.events.create(newPath) + if isDir && s.dirWatch.recursive { + _ = walkDir(newPath, true, func(p string, pIsDir bool) error { + if !pIsDir { + return nil + } + _ = b.markDir(s.dirWatch, p) + return nil + }) + } + touched[s.dirWatch] = struct{}{} + } + } +} + +func (b *fanotifyBackend) handleParsedEvent(mask uint64, dfid *fanotifyDfidName, touched map[*dirWatch]struct{}) { + b.mu.Lock() + defer b.mu.Unlock() + + // b.subscriptions[key] holds at most one entry per *fanotifySubscription + // pointer (markDir always appends a fresh struct), so no dedup is + // necessary. + for _, s := range b.subscriptions[dfid.key] { + if b.handleSubscription(mask, dfid, s) { + touched[s.dirWatch] = struct{}{} + } + } +} + +func (b *fanotifyBackend) handleSubscription(mask uint64, dfid *fanotifyDfidName, sub *fanotifySubscription) bool { + w := sub.dirWatch + + // Compute full path. Self-events (name empty or ".") use the + // watch path directly. + isSelfEvent := dfid.name == "" || dfid.name == "." + path := sub.path + if !isSelfEvent { + path = sub.path + "/" + dfid.name + } + + isDir := mask&unix.FAN_ONDIR != 0 + touched := false + + hasDelete := mask&(unix.FAN_DELETE|unix.FAN_MOVED_FROM) != 0 + hasCreate := mask&(unix.FAN_CREATE|unix.FAN_MOVED_TO) != 0 + + // Fanotify can merge consecutive events on the same object into a + // single event with multiple mask bits. When both create and delete + // bits are set, we can't tell the temporal order from the mask alone. + // Stat the path: if it exists, the last op was create (delete→create + // = "update"); if gone, the last op was delete (create→delete = + // cancel out). + if hasCreate && hasDelete && !isSelfEvent { + var st unix.Stat_t + if unix.Lstat(path, &st) != nil { + // File was created then deleted: record both so they cancel. + w.events.create(path) + w.events.remove(path) + return true + } + // File exists: was deleted then recreated. Fall through to the + // normal delete-first processing which produces "update". + } + + // Process delete/move-from FIRST so that a merged DELETE+CREATE + // coalesces to "update" via the eventList's rapid-recreate logic. + if mask&(unix.FAN_DELETE|unix.FAN_DELETE_SELF|unix.FAN_MOVED_FROM|unix.FAN_MOVE_SELF) != 0 { + isSelfMask := mask&(unix.FAN_DELETE_SELF|unix.FAN_MOVE_SELF) != 0 + // Ignore delete/move self events unless this is the watch root. + if !(isSelfMask && path != w.dir) { + // If the deleted/moved item is a dir, drop subscriptions + // for both the path itself and every descendant; otherwise + // later events for the (now-moved) inodes would be reported + // against stale paths. For FAN_MOVED_FROM that takes the + // inode out of our watched tree the kernel mark on the + // inode itself unfortunately leaks: fanotify has no + // path-independent way to unmark and the destination is + // outside everything we can resolve. + // Self events may not have FAN_ONDIR set (like inotify). + if isSelfMask || isDir { + b.dropSubsForPathAndDescendantsLocked(path) + } else { + b.dropSubsForPathLocked(path) + } + w.events.remove(path) + touched = true + // Root-of-watch deletion: the kernel has dropped the mark. + // Surface ErrWatchTerminated alongside the delete so callers + // know to clean up; no more events will arrive for w. + if isSelfMask && path == w.dir { + w.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) + } + } + } + + if hasCreate { + w.events.create(path) + if isDir && w.recursive { + _ = walkDir(path, true, func(p string, pIsDir bool) error { + if !pIsDir { + return nil + } + _ = b.markDir(w, p) + return nil + }) + } + touched = true + } + + if mask&unix.FAN_MODIFY != 0 { + w.events.update(path) + touched = true + } + + return touched +} + +// parseFanotifyDfidNames extracts DFID_NAME info records from the event's +// info record area. Returns a primary record (DFID_NAME or OLD_DFID_NAME) +// and an optional second record (NEW_DFID_NAME, for FAN_RENAME events). +func parseFanotifyDfidNames(data []byte) (primary *fanotifyDfidName, rename *fanotifyDfidName) { + const ( + infoHdrSize = 4 // fanotify_event_info_header + fsidSize = 8 // __kernel_fsid_t + fhHdrSize = 8 // file_handle header (handle_bytes + handle_type) + minBodySize = fsidSize + fhHdrSize + ) + for offset := 0; offset+infoHdrSize <= len(data); { + infoType := data[offset] + infoLen := int(binary.NativeEndian.Uint16(data[offset+2 : offset+4])) + if infoLen < infoHdrSize || offset+infoLen > len(data) { + break + } + + switch infoType { + case unix.FAN_EVENT_INFO_TYPE_DFID_NAME, + unix.FAN_EVENT_INFO_TYPE_OLD_DFID_NAME: + + if parsed := parseFanotifyFidRecord(data[offset:offset+infoLen], true); parsed != nil { + primary = parsed + } + + case unix.FAN_EVENT_INFO_TYPE_NEW_DFID_NAME: + if parsed := parseFanotifyFidRecord(data[offset:offset+infoLen], true); parsed != nil { + rename = parsed + } + + case unix.FAN_EVENT_INFO_TYPE_DFID: + // DFID without name: the handle identifies the directory itself. + // Use as fallback if we haven't found a DFID_NAME record. + if primary == nil { + if parsed := parseFanotifyFidRecord(data[offset:offset+infoLen], false); parsed != nil { + primary = parsed + } + } + } + + if primary != nil && rename != nil { + return primary, rename + } + + offset += infoLen + } + return primary, rename +} + +// parseFanotifyFidRecord parses a single fanotify_event_info_fid record. +func parseFanotifyFidRecord(data []byte, hasName bool) *fanotifyDfidName { + const ( + infoHdrSize = 4 + fsidSize = 8 + fhHdrSize = 8 + minSize = infoHdrSize + fsidSize + fhHdrSize + ) + if len(data) < minSize { + return nil + } + body := data[infoHdrSize:] + + var fsid [2]int32 + fsid[0] = int32(binary.NativeEndian.Uint32(body[0:4])) + fsid[1] = int32(binary.NativeEndian.Uint32(body[4:8])) + + handleBytes := int(binary.NativeEndian.Uint32(body[8:12])) + handleType := int32(binary.NativeEndian.Uint32(body[12:16])) + + handleStart := fsidSize + fhHdrSize + if handleStart+handleBytes > len(body) { + return nil + } + handleData := body[handleStart : handleStart+handleBytes] + key := makeFanotifyHandleKey(fsid, handleType, handleData) + + var name string + if hasName { + nameStart := handleStart + handleBytes + if nameStart < len(body) { + nameData := body[nameStart:] + for i, c := range nameData { + if c == 0 { + nameData = nameData[:i] + break + } + } + name = string(nameData) + } + } + + return &fanotifyDfidName{key: key, name: name} +} + +// dropSubsForPathLocked removes every subscription whose s.path equals +// path, regardless of which fanotify handle key it lives under. Must be +// called with b.mu held. +func (b *fanotifyBackend) dropSubsForPathLocked(path string) { + for key, list := range b.subscriptions { + kept := list[:0] + for _, s := range list { + if s.path == path { + continue + } + kept = append(kept, s) + } + if len(kept) == 0 { + delete(b.subscriptions, key) + } else { + b.subscriptions[key] = kept + } + } +} + +// dropSubsForPathAndDescendantsLocked removes every subscription whose +// s.path equals path or lives strictly under path. The kernel mark on +// the moved-out inode itself remains active (fanotify provides no +// path-independent unmark) but dropping the bookkeeping prevents later +// events from being reported against the no-longer-valid path. +// Must be called with b.mu held. +func (b *fanotifyBackend) dropSubsForPathAndDescendantsLocked(path string) { + for key, 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 { + delete(b.subscriptions, key) + } else { + b.subscriptions[key] = kept + } + } +} + +func (b *fanotifyBackend) closeWatch(w *dirWatch) error { + for key, list := range b.subscriptions { + kept := list[:0] + removedAny := false + var removedPath string + for _, s := range list { + if s.dirWatch == w { + removedAny = true + removedPath = s.path + continue + } + kept = append(kept, s) + } + if !removedAny { + continue + } + if len(kept) == 0 { + // Try to unmark. Skip the call entirely when markMask is + // still 0 (closeWatch racing with a shutdown that happened + // before subscribe ever set markMask); fanotify_mark with + // mask=0 is undocumented. Ignore ENOENT (directory may have + // been deleted) and EBADF (fanotify fd may already be + // closed during shutdown). + if b.markMask != 0 { + _ = unix.FanotifyMark(b.fanotifyFD, + unix.FAN_MARK_REMOVE, b.markMask, unix.AT_FDCWD, removedPath) + } + delete(b.subscriptions, key) + } else { + b.subscriptions[key] = kept + } + } + return nil +} diff --git a/internal/fswatch/fanotify_linux_test.go b/internal/fswatch/fanotify_linux_test.go new file mode 100644 index 00000000000..9ca06f5b5bd --- /dev/null +++ b/internal/fswatch/fanotify_linux_test.go @@ -0,0 +1,121 @@ +//go:build linux + +package fswatch + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +// fanotifyNoRenameWatcher exposes a fanotify backend that skips the +// FAN_RENAME probe and forces the FAN_MOVED_FROM/FAN_MOVED_TO fallback +// path. It runs under runForEachWatcher (via additionalTestWatchers) +// so the broad test matrix exercises both kernel paths on systems where +// FAN_RENAME would otherwise be selected automatically. +var fanotifyNoRenameWatcher = &watcher{name: "fanotify-no-rename"} + +func init() { + if fanotifyAvailable() { + fanotifyNoRenameWatcher.factory = func() watcherImpl { return newFanotifyBackend(true) } + additionalTestWatchers = append(additionalTestWatchers, fanotifyNoRenameWatcher) + } +} + +func TestLinuxFanotifyShutdownBeforeStart(t *testing.T) { + t.Parallel() + newFanotifyBackend(false).shutdown() +} + +func TestLinuxFanotifyBackendSelection(t *testing.T) { + t.Parallel() + if !fanotifyAvailable() { + t.Skip("fanotify not available") + } + impl, err := fanotifyWatcher.getImpl() + if err != nil { + t.Fatal(err) + } + if _, ok := impl.(*fanotifyBackend); !ok { + t.Fatalf("fanotify watcher = %T, want *fanotifyBackend", impl) + } +} + +func TestLinuxFanotifySubscribeCleansUpAfterMarkFailure(t *testing.T) { + t.Parallel() + dir := newTmpDir(t) + w := newDirectWatcher(t, dir) + b := newFanotifyBackend(false) + + err := b.subscribe(w) + var werr *dirWatchError + if !errors.As(err, &werr) { + t.Fatalf("subscribe error = %v, want *dirWatchError", err) + } + if werr.dirWatch != w { + t.Fatalf("dirWatchError dirWatch = %p, want %p", werr.dirWatch, w) + } + if len(b.subscriptions) != 0 { + t.Fatalf("subscriptions not cleaned up: %d remaining", len(b.subscriptions)) + } +} + +func TestLinuxFanotifyParseDfidNameRoundTrip(t *testing.T) { + t.Parallel() + dir := newTmpDir(t) + handle, _, err := unix.NameToHandleAt(unix.AT_FDCWD, dir, 0) + if err != nil { + t.Skipf("NameToHandleAt not supported: %v", err) + } + var st unix.Statfs_t + if err = unix.Statfs(dir, &st); err != nil { + t.Fatal(err) + } + key := makeFanotifyHandleKey(st.Fsid.Val, handle.Type(), handle.Bytes()) + if key.handle == "" { + t.Fatal("empty handle bytes") + } + handle2, _, err := unix.NameToHandleAt(unix.AT_FDCWD, dir, 0) + if err != nil { + t.Fatal(err) + } + key2 := makeFanotifyHandleKey(st.Fsid.Val, handle2.Type(), handle2.Bytes()) + if key != key2 { + t.Fatalf("handle keys differ for same path:\n 1: %+v\n 2: %+v", key, key2) + } +} + +func TestFanotifyCrossWatcherSameFs(t *testing.T) { + t.Parallel() + if !fanotifyAvailable() { + t.Skip("fanotify not available") + } + + t.Run("Modify", func(t *testing.T) { + t.Parallel() + dirA, dirB := newTmpDir(t), newTmpDir(t) + pathA := filepath.Join(dirA, "child") + pathB := filepath.Join(dirB, "child") + for _, p := range []string{pathA, pathB} { + if err := os.WriteFile(p, []byte("initial"), 0o644); err != nil { + t.Fatal(err) + } + } + rA, _ := subscribeFor(t, dirA, Fanotify()) + rB, _ := subscribeFor(t, dirB, Fanotify()) + + if err := os.WriteFile(pathA, []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + gotA := rA.gather(rA.deadline(), 200*time.Millisecond) + assertEventSet(t, gotA, []wantEvent{{EventUpdate, pathA}}) + + if gotB := rB.drainQuiet(200 * time.Millisecond); len(gotB) != 0 { + t.Fatalf("watcher B got phantom events: %v", toWantEvents(gotB)) + } + }) +} diff --git a/internal/fswatch/fsevents_darwin.go b/internal/fswatch/fsevents_darwin.go new file mode 100644 index 00000000000..a0bf2bea9fb --- /dev/null +++ b/internal/fswatch/fsevents_darwin.go @@ -0,0 +1,434 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "errors" + "fmt" + "os" + "runtime" + "sync/atomic" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +// --------------------------------------------------------------------------- +// fsevents_darwin.go: macOS FSEvents backend (event processing) +// +// Uses Apple's FSEvents API to receive file-level notifications for watched +// directory trees. FSEvents is a high-level, path-based API that watches +// recursively without requiring an fd per file (unlike kqueue). Events are +// coalesced by the kernel and delivered in batches. +// +// This file contains the event classification and stream lifecycle logic. +// The low-level FFI plumbing (cgo-free CoreFoundation/CoreServices calls, +// assembly trampolines, pipe-based callback synchronization) lives in +// fsevents_darwin_ffi.go and the companion .s files. +// +// ┌───────────────────────────────────────────────────────────┐ +// │ fsEventsBackend │ +// │ (no event loop; start() just signals readiness) │ +// │ │ +// │ subscribe() per directory: │ +// │ │ │ +// │ ▼ │ +// │ ┌─────────────────────────────────────────────────────┐ │ +// │ │ fseventsState │ │ +// │ │ │ │ +// │ │ FSEventStream ──► per-stream GCD dispatch queue │ │ +// │ │ (UseCFTypes | FileEvents = 0x11) │ │ +// │ │ │ │ +// │ │ callback fires on GCD thread: │ │ +// │ │ ┌─────────────────────────────────────────┐ │ │ +// │ │ │ asm: retain/copy callback payload │ │ │ +// │ │ │ asm: write(eventPipe) ──────────────► │ │ │ +// │ │ │ eventLoop() │ │ │ +// │ │ │ goroutine │ │ │ +// │ │ │ │ │ │ │ +// │ │ │ fsEventsCallback() │ │ │ +// │ │ │ asm: return to FSEvents │ │ │ +// │ │ └─────────────────────────────────────────┘ │ │ +// │ └─────────────────────────────────────────────────────┘ │ +// └───────────────────────────────────────────────────────────┘ +// +// Goroutines and threading: +// - FSEvents delivers the raw C callback on a GCD dispatch queue thread +// (an OS thread managed by libdispatch, not a Go goroutine). +// - The assembly callback (fsEventsCallbackASM, in the .s files) runs on +// that GCD thread in the C calling convention. It never enters Go ABI. +// It retains/copies the callback payload and passes it to Go through +// eventPipe. +// - One Go goroutine per stream (eventLoop, in fsevents_darwin_ffi.go) +// blocks on eventFile.Read(), integrated with Go's netpoll so it parks +// without consuming an OS thread. When woken by the asm callback, it +// calls fsEventsCallback() to classify events and post them to the +// dirWatch's eventList. +// - subscribe/closeWatch and stream lifecycle (startStream/stopStream) +// run on the caller's goroutine under watcherBase.mu. Stream teardown +// uses atomic.Swap on the stream pointer so that only one of +// (Close, callback's deleted-root path) performs cleanup. +// +// 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 GCD thread, the eventLoop goroutine, or the caller's goroutine. +// On all backends, events matching a WithIgnore function are filtered +// per-subscriber before delivery. +// +// WatchDirectory flow (caller goroutine): +// subscribe → startStream: create an FSEventStream with +// kFSEventStreamEventIdSinceNow and start it on its own serial GCD +// queue. No directory walk or tree is needed; FSEvents watches +// recursively via the kernel, and event classification uses only the +// flags. +// +// Event classification (fsEventsCallback, on eventLoop goroutine): +// Each batch delivers arrays of paths, flags, and event IDs. The flags +// bitmask may combine multiple states (created + modified + renamed). +// Pure removes emit EventDelete with no syscalls. Renames and +// remove+create combos do one Lstat to check existence (the kernel +// reports some deletions as renames). Everything else emits EventUpdate +// with no syscalls. +// +// Overflow: +// flagMustScanSubDirs → ErrOverflow with detail (user/kernel/too-many). +// +// Root deletion: +// Detected in the callback; cb.closed is set so future callbacks are +// no-ops. Stream teardown is deferred to Close. +// --------------------------------------------------------------------------- + +// ----- FSEvents flag bits (from FSEvents.h) ------------------------------ + +const ( + flagMustScanSubDirs = 0x00000001 + flagUserDropped = 0x00000002 + flagKernelDropped = 0x00000004 + flagHistoryDone = 0x00000010 + + flagItemCreated = 0x00000100 + flagItemRemoved = 0x00000200 + flagItemInodeMetaMod = 0x00000400 + flagItemRenamed = 0x00000800 + flagItemModified = 0x00001000 + flagItemFinderInfoMod = 0x00002000 + flagItemChangeOwner = 0x00004000 + flagItemXattrMod = 0x00008000 + flagItemIsFile = 0x00010000 + flagItemIsDir = 0x00020000 + flagItemIsSymlink = 0x00040000 + flagItemIsHardlink = 0x00100000 + flagItemIsLastHardlink = 0x00200000 + flagItemCloned = 0x00400000 + + // kFSEventStreamCreateFlagUseCFTypes (0x1) | + // kFSEventStreamCreateFlagFileEvents (0x10) is hardcoded in the + // arch-specific assembly trampolines (fsevents_darwin_ffi_{arm64,amd64}.s). + + cfStringEncodingUTF8 = 0x08000100 + + // kFSEventStreamEventIdSinceNow == ((FSEventStreamEventId)0xFFFFFFFFFFFFFFFFULL) + eventIDSinceNow = uint64(0xFFFFFFFFFFFFFFFF) +) + +const ignoredFlags = flagItemIsHardlink | flagItemIsLastHardlink | + flagItemIsSymlink | flagItemIsDir | flagItemIsFile | flagItemCloned + +// fsEventStreamContext mirrors the C struct of the same name. +// +// typedef struct { +// CFIndex version; // signed long, 8 bytes on 64-bit +// void *info; // pointer +// void *retain; // pointer +// void *release; // pointer +// void *copyDescription; // pointer +// } FSEventStreamContext; +type fsEventStreamContext struct { + version int + info uintptr + retain uintptr + release uintptr + copyDescription uintptr +} + +// fseventsState. +// +// stream is claimed by Close with an atomic Swap. Root deletion is detected in +// the callback, but teardown is deferred to Close because the assembly callback +// is still waiting on the done pipe while fsEventsCallback runs. +type fseventsState struct { + stream atomic.Uintptr + cb *streamCallback + pinner runtime.Pinner +} + +// ----- the watcherImpl ------------------------------------------------------- + +// fsEventsBackend. +type fsEventsBackend struct { + watcherBase +} + +func init() { + fseventsWatcher.factory = func() watcherImpl { return newFSEventsBackend() } +} + +func newFSEventsBackend() *fsEventsBackend { + b := &fsEventsBackend{} + b.watcherBase.init(b) + return b +} + +func (b *fsEventsBackend) start() error { + b.notifyStarted() + return nil +} + +// checkWatcher mirrors the helper of the same name. +func checkWatcher(w *dirWatch) error { + info, err := os.Stat(w.dir) + if err != nil { + return &dirWatchError{err: err, dirWatch: w} + } + if !info.IsDir() { + return &dirWatchError{err: syscall.ENOTDIR, dirWatch: w} + } + return nil +} + +var ( + errMissingFSEventsState = errors.New("fsevents: missing state") + errStreamCreateNull = errors.New("FSEventStreamCreate returned NULL") + errStreamStartFailed = errors.New("error starting FSEvents stream") +) + +var ( + errFSEventsUserDropped = fmt.Errorf("events were dropped by the FSEvents client: %w", ErrOverflow) + errFSEventsKernelDropped = fmt.Errorf("events were dropped by the kernel: %w", ErrOverflow) + errFSEventsTooMany = fmt.Errorf("too many events: %w", ErrOverflow) +) + +// startStream creates and starts an FSEventStream on its per-stream +// serial dispatch queue. +func (b *fsEventsBackend) startStream(w *dirWatch, since uint64) error { + if err := checkWatcher(w); err != nil { + return err + } + + state, _ := w.state.(*fseventsState) + if state == nil { + return errMissingFSEventsState + } + + dirCStr := append([]byte(w.dir), 0) + cfDir := cfStringCreate(0, unsafe.Pointer(&dirCStr[0]), cfStringEncodingUTF8) + defer cfRelease(cfDir) + + pathsToWatch := cfArrayCreate(0, unsafe.Pointer(&cfDir), 1, 0) + defer cfRelease(pathsToWatch) + + cb, err := newStreamCallback(w) + if err != nil { + return &dirWatchError{err: err, dirWatch: w} + } + state.pinner.Pin(cb) + state.cb = cb + + ctx := fsEventStreamContext{info: uintptr(unsafe.Pointer(cb))} + + stream := fsEventStreamCreate( + 0, + fsEventsCallbackAsmAddr, + unsafe.Pointer(&ctx), + pathsToWatch, + since, + 0.001, + ) + if stream == 0 { + cb.close() + state.cb = nil + state.pinner.Unpin() + return &dirWatchError{err: errStreamCreateNull, dirWatch: w} + } + + fsEventStreamSetDispatchQueue(stream, cb.queue) + if fsEventStreamStart(stream) == 0 { + fsEventStreamInvalidate(stream) + fsEventStreamRelease(stream) + cb.close() + state.cb = nil + state.pinner.Unpin() + return &dirWatchError{err: errStreamStartFailed, dirWatch: w} + } + fsEventStreamFlushSync(stream) + state.stream.Store(stream) + return nil +} + +// teardownStream performs the full FSEventStream cleanup. Stop and Invalidate +// prevent new callbacks, waitDispatchQueue waits for callbacks already queued +// on the stream's serial dispatch queue, and cb.close joins the Go event loop +// after it drains payloads already written to the pipe. +func teardownStream(stream uintptr, cb *streamCallback) { + fsEventStreamStop(stream) + if cb != nil { + fsEventStreamInvalidate(stream) + cb.waitDispatchQueue() + cb.close() + } else { + fsEventStreamInvalidate(stream) + } + fsEventStreamRelease(stream) +} + +// stopStream tears down a stream if WatchDirectory successfully started one. +// The atomic Swap gates teardown so concurrent or repeated calls are safe: +// only the goroutine that observes a non-zero stream performs the cleanup. +func (b *fsEventsBackend) stopStream(state *fseventsState) { + if state == nil { + return + } + stream := state.stream.Swap(0) + if stream == 0 { + return + } + cb := state.cb + teardownStream(stream, cb) + state.cb = nil + state.pinner.Unpin() +} + +// subscribe mirrors `fsEventsBackend::subscribe`. +func (b *fsEventsBackend) subscribe(w *dirWatch) error { + state := &fseventsState{} + w.state = state + return b.startStream(w, eventIDSinceNow) +} + +// closeWatch mirrors `fsEventsBackend::closeWatch`. +func (b *fsEventsBackend) closeWatch(w *dirWatch) error { + state, _ := w.state.(*fseventsState) + w.state = nil + if state == nil { + return nil + } + b.stopStream(state) + return nil +} + +// fsEventsCallback processes a batch of FSEvents. The payload contains callback +// data retained/copied by the assembly before it returned control to Go. +// +// Called by streamCallback.eventLoop on a per-stream Go goroutine (not the +// dispatch queue thread). The C callback assembly signals the event loop via a +// pipe; see fsevents_darwin_ffi.go. +func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { + defer payload.close() + + if cb.closed.Load() { + return + } + + const ( + flagSize = unsafe.Sizeof(uint32(0)) + ) + + if payload == nil || payload.paths == 0 || payload.flags == 0 { + return + } + + numEvents := payload.numEvents + paths := payload.paths + flags := payload.flags + + w := cb.dirWatch + deletedRoot := false + + for i := range numEvents { + flag := *(*uint32)(unsafe.Add(nil, flags+i*flagSize)) + pathRef := cfArrayGetValueAtIndex(paths, int(i)) + path := cfStringToNFC(pathRef) + if path == "" { + continue + } + + isRemoved := flag&flagItemRemoved != 0 + isRenamed := flag&flagItemRenamed != 0 + isCreated := flag&flagItemCreated != 0 + isDone := flag&flagHistoryDone != 0 + + if flag&flagMustScanSubDirs != 0 { + switch { + case flag&flagUserDropped != 0: + w.events.setError(errFSEventsUserDropped) + case flag&flagKernelDropped != 0: + w.events.setError(errFSEventsKernelDropped) + default: + w.events.setError(errFSEventsTooMany) + } + } + + if isDone { + w.notify() + break + } + + if flag&^uint32(ignoredFlags) == 0 { + continue + } + + // Skip events for the watched directory itself unless it's been + // removed. fseventsd reports a change on the watched dir when a + // child is added or removed; subscribers observe changes *within* + // the directory, not the dir's own metadata churn. + // (A removal of the dir is still propagated because Watcher + // relies on it to tear down the stream.) + if path == w.dir && !isRemoved && !isRenamed { + continue + } + + switch { + case isRemoved && !isCreated: + // Pure remove, or remove+rename: file is gone. + w.events.remove(path) + if path == w.dir { + deletedRoot = true + } + case isRenamed || (isRemoved && isCreated): + // Ambiguous: rename could mean moved away (delete) or + // moved in (update); remove+create could mean replaced. + // Stat to check existence. + var st unix.Stat_t + if unix.Lstat(path, &st) != nil { + w.events.remove(path) + if path == w.dir { + deletedRoot = true + } + } else { + w.events.update(path) + } + default: + // Create, modify, or any other flag combo. + w.events.update(path) + } + } + + if deletedRoot { + // Surface ErrWatchTerminated alongside the delete event so the + // caller knows no further events will arrive. Stream teardown is + // still deferred to Close. + w.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) + } + + w.notify() + + if deletedRoot { + // The watched root was deleted. Mark the callback as closed so + // future callbacks are no-ops. Stream teardown and pipe cleanup + // are deferred to Close. + cb.closed.Store(true) + } +} diff --git a/internal/fswatch/fsevents_darwin_ffi.go b/internal/fswatch/fsevents_darwin_ffi.go new file mode 100644 index 00000000000..cd580789602 --- /dev/null +++ b/internal/fswatch/fsevents_darwin_ffi.go @@ -0,0 +1,543 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "io" + "math" + "os" + "runtime" + "sync/atomic" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +// --------------------------------------------------------------------------- +// fsevents_darwin_ffi.go: cgo-free macOS CoreFoundation / CoreServices FFI +// +// Provides Go access to Apple's FSEvents, CoreFoundation, and libdispatch +// frameworks entirely without cgo, following the pattern established by +// crypto/x509/internal/macos in the Go standard library. +// +// Imported symbols include CoreFoundation helpers (CFRelease, +// CFStringCreateWithCString, CFArrayCreate), libdispatch +// (dispatch_queue_create), and CoreServices FSEvents functions +// (FSEventStreamCreate, SetDispatchQueue, Start, Stop, Invalidate, +// Release). +// +// Each framework symbol has three parts: +// 1. //go:cgo_import_dynamic: tells the linker to import the C symbol +// from a shared library (CoreFoundation.framework, CoreServices.framework, +// or libSystem.B.dylib). +// 2. A TEXT trampoline in the .s file: a minimal assembly stub that JMPs +// to the imported symbol. For simple functions this is a bare JMP; for +// FSEventStreamCreate the trampoline also moves the float64 latency +// argument from an integer register to a float register. +// 3. A GLOBL/DATA pair that exports the trampoline's ABI0 address as a Go +// uintptr variable (·fse_X_trampoline_addr), which the Go wrapper +// passes to runtime's syscall_syscall6. +// +// ┌──────────────────────────────────────────────────────────┐ +// │ Go wrapper: cfRelease(ref) │ +// │ syscall_syscall6(trampoline_addr, ref, ...) │ +// │ │ │ +// │ ▼ │ +// │ ┌──────────────────────────────────┐ │ +// │ │ .s trampoline (ABI0) │ │ +// │ │ fse_CFRelease_trampoline<>: │ │ +// │ │ JMP fse_CFRelease(SB) │ │ +// │ └─────────────┬────────────────────┘ │ +// │ │ │ +// │ ▼ │ +// │ ┌──────────────────────────────────┐ │ +// │ │ //go:cgo_import_dynamic │ │ +// │ │ CFRelease from CoreFoundation │ │ +// │ └──────────────────────────────────┘ │ +// └──────────────────────────────────────────────────────────┘ +// +// FSEvents callback synchronization (per-stream): +// +// GCD dispatch queue thread Go goroutine (eventLoop) +// ───────────────────────── ──────────────────────── +// FSEvents fires C callback +// on a libdispatch OS thread +// │ +// ┌──────▼──────────────────┐ +// │ asm: retain CFArray │ +// │ paths, copy flags, │ +// │ allocate payload │ +// └──────┬──────────────────┘ +// │ +// write(eventPipeWrite, payload*) ─► read(eventFile) unblocks +// │ │ +// asm: return to FSEvents fsEventsCallback(cb, payload) +// classifies events, +// frees payload, +// posts to dirWatch.events +// +// The assembly callback never enters Go ABI; it stays entirely in C +// context. One pipe per stream hands retained/copied callback payloads from +// the C dispatch queue thread to a dedicated Go event-loop goroutine. +// The Go side uses os.File.Read (integrated with netpoll/kqueue on macOS) +// so the goroutine parks efficiently without blocking an OS thread. +// +// streamCallback memory layout (must match assembly offsets): +// +// offset 0: eventPipeWrite fd Read by asm to call write() +// --------------------------------------------------------------------------- + +// Framework linker flags for the external linker. +// Note: //go:cgo_ldflag is only valid in cgo-generated code. The +// //go:cgo_import_dynamic directives below are sufficient: the Go +// linker records the framework paths in the Mach-O LC_LOAD_DYLIB +// commands automatically. + +// Implemented in the runtime package (runtime/sys_darwin.go). +// These are the same linknames that golang.org/x/sys/unix uses. +// +//go:linkname syscall_syscall6 syscall.syscall6 +func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) + +// --------------------------------------------------------------------------- +// CoreFoundation imports, trampoline addresses, and Go wrappers. +// +// Each function groups its //go:cgo_import_dynamic directive, its +// trampoline address variable (populated by GLOBL/DATA in the .s files), +// and its Go wrapper together. +// --------------------------------------------------------------------------- + +//go:cgo_import_dynamic fse_CFRelease CFRelease "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFRelease_trampoline_addr uintptr + +func cfRelease(ref uintptr) { + _, _, _ = syscall_syscall6(fse_CFRelease_trampoline_addr, ref, 0, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_CFStringCreateWithCString CFStringCreateWithCString "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringCreateWithCString_trampoline_addr uintptr + +func cfStringCreate(allocator uintptr, cstr unsafe.Pointer, encoding uint32) uintptr { + ret, _, _ := syscall_syscall6(fse_CFStringCreateWithCString_trampoline_addr, allocator, uintptr(cstr), uintptr(encoding), 0, 0, 0) + runtime.KeepAlive(cstr) + return ret +} + +//go:cgo_import_dynamic fse_CFArrayCreate CFArrayCreate "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFArrayCreate_trampoline_addr uintptr + +func cfArrayCreate(allocator uintptr, values unsafe.Pointer, count int, callbacks uintptr) uintptr { + ret, _, _ := syscall_syscall6(fse_CFArrayCreate_trampoline_addr, allocator, uintptr(values), uintptr(count), callbacks, 0, 0) + runtime.KeepAlive(values) + return ret +} + +//go:cgo_import_dynamic fse_CFArrayGetValueAtIndex CFArrayGetValueAtIndex "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFArrayGetValueAtIndex_trampoline_addr uintptr + +func cfArrayGetValueAtIndex(array uintptr, index int) uintptr { + ret, _, _ := syscall_syscall6(fse_CFArrayGetValueAtIndex_trampoline_addr, array, uintptr(index), 0, 0, 0, 0) + return ret +} + +// ----- NFC normalization helpers ----- +// +// FSEvents reports paths using whatever bytes are stored on disk. APFS is +// normalization-insensitive for lookups (a file created as NFD opens fine +// under the NFC form, and vice versa) but it stores and reports the original +// bytes. The library normalizes every path that crosses the darwin boundary +// to Unicode NFC so that: +// - WatchDirectory("/.../caf\u00e9") and WatchDirectory("/.../cafe\u0301") +// coalesce to a single dir watch; +// - WatchFile filters by exact-string compare in NFC always match; +// - subscribers can compare event paths against their own NFC strings. +// +// All-ASCII inputs are bit-identical in NFC and NFD, so the hot path skips +// the FFI entirely. The rare non-ASCII case round-trips through CoreFoundation +// (UTF-8 → CFString → CFMutableString → CFStringNormalize → UTF-8) with no Go +// Unicode tables, no extra dependency. + +const ( + cfStringNormalizationFormC = 2 // kCFStringNormalizationFormC +) + +//go:cgo_import_dynamic fse_CFStringCreateMutableCopy CFStringCreateMutableCopy "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringCreateMutableCopy_trampoline_addr uintptr + +func cfStringCreateMutableCopy(allocator uintptr, maxLength int, str uintptr) uintptr { + ret, _, _ := syscall_syscall6(fse_CFStringCreateMutableCopy_trampoline_addr, allocator, uintptr(maxLength), str, 0, 0, 0) + return ret +} + +//go:cgo_import_dynamic fse_CFStringNormalize CFStringNormalize "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringNormalize_trampoline_addr uintptr + +func cfStringNormalize(mutStr uintptr, form uintptr) { + _, _, _ = syscall_syscall6(fse_CFStringNormalize_trampoline_addr, mutStr, form, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_CFStringGetLength CFStringGetLength "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringGetLength_trampoline_addr uintptr + +func cfStringGetLength(str uintptr) int { + ret, _, _ := syscall_syscall6(fse_CFStringGetLength_trampoline_addr, str, 0, 0, 0, 0, 0) + return int(ret) +} + +//go:cgo_import_dynamic fse_CFStringGetMaximumSizeForEncoding CFStringGetMaximumSizeForEncoding "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringGetMaximumSizeForEncoding_trampoline_addr uintptr + +func cfStringGetMaximumSizeForEncoding(length int, encoding uint32) int { + ret, _, _ := syscall_syscall6(fse_CFStringGetMaximumSizeForEncoding_trampoline_addr, uintptr(length), uintptr(encoding), 0, 0, 0, 0) + return int(ret) +} + +//go:cgo_import_dynamic fse_CFStringGetCString CFStringGetCString "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" + +var fse_CFStringGetCString_trampoline_addr uintptr + +func cfStringGetCString(str uintptr, buf unsafe.Pointer, bufSize int, encoding uint32) bool { + ret, _, _ := syscall_syscall6(fse_CFStringGetCString_trampoline_addr, str, uintptr(buf), uintptr(bufSize), uintptr(encoding), 0, 0) + runtime.KeepAlive(buf) + return ret != 0 +} + +// isASCII reports whether every byte in s is below 0x80. Pure-ASCII paths +// are identical in every Unicode normalization form, so we can skip the +// CoreFoundation round-trip entirely, which is the overwhelming common case. +func isASCII(s string) bool { + for i := range len(s) { + if s[i] >= 0x80 { + return false + } + } + return true +} + +// cfStringToNFC returns the CFString at src as a NFC-normalized Go string. +// If normalization fails, it falls back to the unnormalized UTF-8 contents. +// Returns "" only if both the normalized and unnormalized conversions fail +// (e.g. src is not a CFString, or allocation fails). +func cfStringToNFC(src uintptr) string { + if src == 0 { + return "" + } + if s := cfStringNormalizedToGo(src); s != "" { + return s + } + return cfStringToGo(src) +} + +// cfStringNormalizedToGo returns the CFString at src as a NFC-normalized Go +// string, or "" on any failure. +func cfStringNormalizedToGo(src uintptr) string { + mut := cfStringCreateMutableCopy(0, 0, src) + if mut == 0 { + return "" + } + defer cfRelease(mut) + + cfStringNormalize(mut, cfStringNormalizationFormC) + return cfStringToGo(mut) +} + +// cfStringToGo extracts the UTF-8 contents of the CFString at src as a Go +// string, or "" on failure. +func cfStringToGo(src uintptr) string { + length := cfStringGetLength(src) + bufSize := cfStringGetMaximumSizeForEncoding(length, cfStringEncodingUTF8) + 1 + buf := make([]byte, bufSize) + if !cfStringGetCString(src, unsafe.Pointer(&buf[0]), bufSize, cfStringEncodingUTF8) { + return "" + } + // CFStringGetCString writes a NUL terminator; trim it. + n := 0 + for n < len(buf) && buf[n] != 0 { + n++ + } + return string(buf[:n]) +} + +// normalizeNFC returns s in Unicode NFC (canonical composed) form. ASCII +// inputs are returned unchanged. Non-ASCII inputs go through CoreFoundation; +// if any step fails (e.g. invalid UTF-8 from a corrupt path), the original +// string is returned so the caller still sees *something* rather than nothing. +func normalizeNFC(s string) string { + if isASCII(s) { + return s + } + + cstr := append([]byte(s), 0) + src := cfStringCreate(0, unsafe.Pointer(&cstr[0]), cfStringEncodingUTF8) + runtime.KeepAlive(cstr) + if src == 0 { + return s + } + defer cfRelease(src) + + normalized := cfStringToNFC(src) + if normalized == "" { + return s + } + return normalized +} + +// --------------------------------------------------------------------------- +// libdispatch imports. +// --------------------------------------------------------------------------- + +//go:cgo_import_dynamic fse_dispatch_queue_create dispatch_queue_create "/usr/lib/libSystem.B.dylib" + +var fse_dispatch_queue_create_trampoline_addr uintptr + +func dispatchQueueCreate(label unsafe.Pointer) uintptr { + ret, _, _ := syscall_syscall6(fse_dispatch_queue_create_trampoline_addr, uintptr(label), 0, 0, 0, 0, 0) + runtime.KeepAlive(label) + return ret +} + +//go:cgo_import_dynamic fse_dispatch_release dispatch_release "/usr/lib/libSystem.B.dylib" + +var fse_dispatch_release_trampoline_addr uintptr + +func dispatchRelease(obj uintptr) { + _, _, _ = syscall_syscall6(fse_dispatch_release_trampoline_addr, obj, 0, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_dispatch_sync_f dispatch_sync_f "/usr/lib/libSystem.B.dylib" + +var ( + fse_dispatch_sync_f_trampoline_addr uintptr + fse_dispatch_noop_addr uintptr +) + +func dispatchSync(queue, context, work uintptr) { + _, _, _ = syscall_syscall6(fse_dispatch_sync_f_trampoline_addr, queue, context, work, 0, 0, 0) +} + +// --------------------------------------------------------------------------- +// CoreServices / FSEvents imports, trampoline addresses, and Go wrappers. +// --------------------------------------------------------------------------- + +//go:cgo_import_dynamic fse_FSEventStreamCreate FSEventStreamCreate "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamCreate_trampoline_addr uintptr // arch-specific trampoline + +func fsEventStreamCreate(allocator, callback uintptr, ctx unsafe.Pointer, paths uintptr, since uint64, latency float64) uintptr { + // syscall_syscall6 only carries 6 integer args. The arch-specific + // trampoline moves the latency bits from an integer register to the + // float register and hardcodes flags = + // kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagFileEvents (0x11). + ret, _, _ := syscall_syscall6( + fse_FSEventStreamCreate_trampoline_addr, + allocator, + callback, + uintptr(ctx), + paths, + uintptr(since), + uintptr(math.Float64bits(latency)), + ) + runtime.KeepAlive(ctx) + return ret +} + +//go:cgo_import_dynamic fse_FSEventStreamSetDispatchQueue FSEventStreamSetDispatchQueue "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamSetDispatchQueue_trampoline_addr uintptr + +func fsEventStreamSetDispatchQueue(stream, queue uintptr) { + _, _, _ = syscall_syscall6(fse_FSEventStreamSetDispatchQueue_trampoline_addr, stream, queue, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_FSEventStreamStart FSEventStreamStart "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamStart_trampoline_addr uintptr + +func fsEventStreamStart(stream uintptr) uint8 { + r1, _, _ := syscall_syscall6(fse_FSEventStreamStart_trampoline_addr, stream, 0, 0, 0, 0, 0) + return uint8(r1) +} + +//go:cgo_import_dynamic fse_FSEventStreamFlushSync FSEventStreamFlushSync "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamFlushSync_trampoline_addr uintptr + +func fsEventStreamFlushSync(stream uintptr) { + _, _, _ = syscall_syscall6(fse_FSEventStreamFlushSync_trampoline_addr, stream, 0, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_FSEventStreamStop FSEventStreamStop "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamStop_trampoline_addr uintptr + +func fsEventStreamStop(stream uintptr) { + _, _, _ = syscall_syscall6(fse_FSEventStreamStop_trampoline_addr, stream, 0, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_FSEventStreamInvalidate FSEventStreamInvalidate "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamInvalidate_trampoline_addr uintptr + +func fsEventStreamInvalidate(stream uintptr) { + _, _, _ = syscall_syscall6(fse_FSEventStreamInvalidate_trampoline_addr, stream, 0, 0, 0, 0, 0) +} + +//go:cgo_import_dynamic fse_FSEventStreamRelease FSEventStreamRelease "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices" + +var fse_FSEventStreamRelease_trampoline_addr uintptr + +func fsEventStreamRelease(stream uintptr) { + _, _, _ = syscall_syscall6(fse_FSEventStreamRelease_trampoline_addr, stream, 0, 0, 0, 0, 0) +} + +// --------------------------------------------------------------------------- +// Direct callback assembly imports. +// --------------------------------------------------------------------------- + +// These symbols are called directly by fsEventsCallbackASM and have no Go +// wrappers. +//go:cgo_import_dynamic fse_CFRetain CFRetain "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation" +//go:cgo_import_dynamic fse_write write "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic fse___error __error "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic fse_malloc malloc "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic fse_memcpy memcpy "/usr/lib/libSystem.B.dylib" + +// --------------------------------------------------------------------------- +// libSystem imports, trampoline addresses, and Go wrappers. +// --------------------------------------------------------------------------- + +//go:cgo_import_dynamic fse_free free "/usr/lib/libSystem.B.dylib" + +var fse_free_trampoline_addr uintptr + +func libcFree(ptr uintptr) { + if ptr != 0 { + _, _, _ = syscall_syscall6(fse_free_trampoline_addr, ptr, 0, 0, 0, 0, 0) + } +} + +// --------------------------------------------------------------------------- +// Callback address. +// --------------------------------------------------------------------------- + +// fsEventsCallbackAsmAddr is the address of the arch-specific callback +// function defined in fsevents_darwin_ffi_{amd64,arm64}.s. +var fsEventsCallbackAsmAddr uintptr + +// --------------------------------------------------------------------------- +// Per-stream callback infrastructure +// --------------------------------------------------------------------------- + +// streamCallback is the per-stream buffer shared between the C callback +// assembly and the Go event loop goroutine. The assembly receives a pointer +// to this struct as the FSEventStreamContext.info parameter and uses offset +// addressing to access the pipe fd. +// +// The struct layout must match the assembly (fsevents_darwin_ffi_{amd64,arm64}.s): +// +// offset 0: eventPipeWrite fd +type streamCallback struct { + eventPipeWrite uintptr + + // Go-only fields (not accessed by assembly, offset doesn't matter). + eventFile *os.File + queue uintptr // per-stream serial dispatch queue + done chan struct{} + dirWatch *dirWatch + closed atomic.Bool +} + +type fsEventsCallbackPayload struct { + numEvents uintptr + paths uintptr + flags uintptr +} + +func (p *fsEventsCallbackPayload) close() { + if p == nil { + return + } + if p.paths != 0 { + cfRelease(p.paths) + } + libcFree(p.flags) + libcFree(uintptr(unsafe.Pointer(p))) +} + +// newStreamCallback allocates a pinned streamCallback with its own pipe and +// per-stream serial dispatch queue, and starts a goroutine to process +// callbacks. The per-stream serial queue serializes this stream's callbacks +// and prevents cross-stream head-of-line blocking that a process-wide serial +// queue would cause. +func newStreamCallback(w *dirWatch) (*streamCallback, error) { + var eventPipe [2]int + if err := unix.Pipe(eventPipe[:]); err != nil { + return nil, err + } + unix.CloseOnExec(eventPipe[0]) + unix.CloseOnExec(eventPipe[1]) + + label := []byte("typescript.fswatch.fsevents.stream\x00") + queue := dispatchQueueCreate(unsafe.Pointer(&label[0])) + runtime.KeepAlive(label) + if queue == 0 { + unix.Close(eventPipe[0]) + unix.Close(eventPipe[1]) + return nil, errStreamCreateNull + } + + cb := &streamCallback{ + eventPipeWrite: uintptr(eventPipe[1]), + eventFile: os.NewFile(uintptr(eventPipe[0]), "fsevents-event"), + queue: queue, + done: make(chan struct{}), + dirWatch: w, + } + go cb.eventLoop() + return cb, nil +} + +func (cb *streamCallback) waitDispatchQueue() { + if cb.queue != 0 { + dispatchSync(cb.queue, 0, fse_dispatch_noop_addr) + } +} + +// close shuts down the event loop goroutine and releases resources. +func (cb *streamCallback) close() { + unix.Close(int(cb.eventPipeWrite)) + <-cb.done + cb.eventFile.Close() + if cb.queue != 0 { + dispatchRelease(cb.queue) + cb.queue = 0 + } +} + +// eventLoop runs on a dedicated goroutine for this stream. It reads signals +// from the callback assembly (via eventPipe) and processes each retained/copied +// payload. +// The eventFile.Read() call integrates with Go's netpoll (kqueue on macOS), +// so the goroutine parks without blocking an OS thread while idle. +func (cb *streamCallback) eventLoop() { + defer close(cb.done) + var payload *fsEventsCallbackPayload + buf := unsafe.Slice((*byte)(unsafe.Pointer(&payload)), unsafe.Sizeof(payload)) + for { + payload = nil + if _, err := io.ReadFull(cb.eventFile, buf); err != nil { + return // pipe closed or error → shutdown + } + + fsEventsCallback(cb, payload) + } +} diff --git a/internal/fswatch/fsevents_darwin_ffi.s b/internal/fswatch/fsevents_darwin_ffi.s new file mode 100644 index 00000000000..29b5c22fa3c --- /dev/null +++ b/internal/fswatch/fsevents_darwin_ffi.s @@ -0,0 +1,155 @@ +//go:build darwin && (amd64 || arm64) + +#include "textflag.h" + +// fsevents_darwin_ffi.s: shared (amd64+arm64) assembly trampolines +// +// Provides JMP trampolines for CoreFoundation, libdispatch, and CoreServices +// functions imported via //go:cgo_import_dynamic. Each trampoline is paired +// with a GLOBL/DATA address that makes the ABI0 entry point available as a +// Go uintptr, following the pattern used by golang.org/x/sys/unix/ +// zsyscall_darwin_*.s. +// +// Arch-specific trampolines (FSEventStreamCreate and the FSEvents callback) +// live in fsevents_darwin_ffi_{amd64,arm64}.s. + +// Each TEXT trampoline JMPs to the corresponding cgo_import_dynamic symbol. +// JMP is a Go pseudo-instruction that works on all architectures. +// +// Trampoline TEXT symbols are file-scoped (`<>` suffix); there is no +// Go-side declaration for them. Only the `_addr` variables (declared +// `·name(SB)` for package-scope) are visible from Go. Following the +// pattern used by golang.org/x/sys/unix/zsyscall_darwin_*.s. +// +// Each trampoline is paired with its GLOBL/DATA address, which makes the +// ABI0 address of the trampoline available as a Go uintptr. + +// ----- CoreFoundation ----- + +TEXT fse_CFRelease_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFRelease(SB) + +GLOBL ·fse_CFRelease_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFRelease_trampoline_addr(SB)/8, $fse_CFRelease_trampoline<>(SB) + +TEXT fse_CFStringCreateWithCString_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringCreateWithCString(SB) + +GLOBL ·fse_CFStringCreateWithCString_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringCreateWithCString_trampoline_addr(SB)/8, $fse_CFStringCreateWithCString_trampoline<>(SB) + +TEXT fse_CFArrayCreate_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFArrayCreate(SB) + +GLOBL ·fse_CFArrayCreate_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFArrayCreate_trampoline_addr(SB)/8, $fse_CFArrayCreate_trampoline<>(SB) + +TEXT fse_CFArrayGetValueAtIndex_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFArrayGetValueAtIndex(SB) + +GLOBL ·fse_CFArrayGetValueAtIndex_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFArrayGetValueAtIndex_trampoline_addr(SB)/8, $fse_CFArrayGetValueAtIndex_trampoline<>(SB) + +TEXT fse_CFStringCreateMutableCopy_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringCreateMutableCopy(SB) + +GLOBL ·fse_CFStringCreateMutableCopy_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringCreateMutableCopy_trampoline_addr(SB)/8, $fse_CFStringCreateMutableCopy_trampoline<>(SB) + +TEXT fse_CFStringNormalize_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringNormalize(SB) + +GLOBL ·fse_CFStringNormalize_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringNormalize_trampoline_addr(SB)/8, $fse_CFStringNormalize_trampoline<>(SB) + +TEXT fse_CFStringGetLength_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringGetLength(SB) + +GLOBL ·fse_CFStringGetLength_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringGetLength_trampoline_addr(SB)/8, $fse_CFStringGetLength_trampoline<>(SB) + +TEXT fse_CFStringGetMaximumSizeForEncoding_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringGetMaximumSizeForEncoding(SB) + +GLOBL ·fse_CFStringGetMaximumSizeForEncoding_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringGetMaximumSizeForEncoding_trampoline_addr(SB)/8, $fse_CFStringGetMaximumSizeForEncoding_trampoline<>(SB) + +TEXT fse_CFStringGetCString_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_CFStringGetCString(SB) + +GLOBL ·fse_CFStringGetCString_trampoline_addr(SB), RODATA, $8 +DATA ·fse_CFStringGetCString_trampoline_addr(SB)/8, $fse_CFStringGetCString_trampoline<>(SB) + +// ----- libdispatch ----- + +TEXT fse_dispatch_queue_create_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_dispatch_queue_create(SB) + +GLOBL ·fse_dispatch_queue_create_trampoline_addr(SB), RODATA, $8 +DATA ·fse_dispatch_queue_create_trampoline_addr(SB)/8, $fse_dispatch_queue_create_trampoline<>(SB) + +TEXT fse_dispatch_release_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_dispatch_release(SB) + +GLOBL ·fse_dispatch_release_trampoline_addr(SB), RODATA, $8 +DATA ·fse_dispatch_release_trampoline_addr(SB)/8, $fse_dispatch_release_trampoline<>(SB) + +TEXT fse_dispatch_sync_f_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_dispatch_sync_f(SB) + +GLOBL ·fse_dispatch_sync_f_trampoline_addr(SB), RODATA, $8 +DATA ·fse_dispatch_sync_f_trampoline_addr(SB)/8, $fse_dispatch_sync_f_trampoline<>(SB) + +TEXT fse_dispatch_noop<>(SB), NOSPLIT|NOFRAME, $0 + RET + +GLOBL ·fse_dispatch_noop_addr(SB), RODATA, $8 +DATA ·fse_dispatch_noop_addr(SB)/8, $fse_dispatch_noop<>(SB) + +// ----- CoreServices / FSEvents ----- +// (FSEventStreamCreate is arch-specific; see fsevents_darwin_ffi_{arm64,amd64}.s) + +TEXT fse_FSEventStreamSetDispatchQueue_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_FSEventStreamSetDispatchQueue(SB) + +GLOBL ·fse_FSEventStreamSetDispatchQueue_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamSetDispatchQueue_trampoline_addr(SB)/8, $fse_FSEventStreamSetDispatchQueue_trampoline<>(SB) + +TEXT fse_FSEventStreamStart_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_FSEventStreamStart(SB) + +GLOBL ·fse_FSEventStreamStart_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamStart_trampoline_addr(SB)/8, $fse_FSEventStreamStart_trampoline<>(SB) + +TEXT fse_FSEventStreamFlushSync_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_FSEventStreamFlushSync(SB) + +GLOBL ·fse_FSEventStreamFlushSync_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamFlushSync_trampoline_addr(SB)/8, $fse_FSEventStreamFlushSync_trampoline<>(SB) + +TEXT fse_FSEventStreamStop_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_FSEventStreamStop(SB) + +GLOBL ·fse_FSEventStreamStop_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamStop_trampoline_addr(SB)/8, $fse_FSEventStreamStop_trampoline<>(SB) + +TEXT fse_FSEventStreamInvalidate_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_FSEventStreamInvalidate(SB) + +GLOBL ·fse_FSEventStreamInvalidate_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamInvalidate_trampoline_addr(SB)/8, $fse_FSEventStreamInvalidate_trampoline<>(SB) + +TEXT fse_FSEventStreamRelease_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_FSEventStreamRelease(SB) + +GLOBL ·fse_FSEventStreamRelease_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamRelease_trampoline_addr(SB)/8, $fse_FSEventStreamRelease_trampoline<>(SB) + +// ----- libSystem ----- +// free is also called from Go via libcFree, so it has a trampoline address. + +TEXT fse_free_trampoline<>(SB), NOSPLIT, $0-0 + JMP fse_free(SB) + +GLOBL ·fse_free_trampoline_addr(SB), RODATA, $8 +DATA ·fse_free_trampoline_addr(SB)/8, $fse_free_trampoline<>(SB) diff --git a/internal/fswatch/fsevents_darwin_ffi_amd64.s b/internal/fswatch/fsevents_darwin_ffi_amd64.s new file mode 100644 index 00000000000..3c12c352015 --- /dev/null +++ b/internal/fswatch/fsevents_darwin_ffi_amd64.s @@ -0,0 +1,159 @@ +//go:build darwin && amd64 + +#include "textflag.h" + +// fsevents_darwin_ffi_amd64.s: amd64 assembly for the FSEvents backend +// +// Contains two functions: +// +// 1. FSEventStreamCreate trampoline: shuffles the float64 latency arg +// from R9 (integer register, where syscall6 puts it) into X0 (xmm0, +// where the System V AMD64 ABI expects the first float argument), +// and hardcodes the flags argument to 0x11 +// (kFSEventStreamCreateFlagUseCFTypes | +// kFSEventStreamCreateFlagFileEvents). +// +// 2. fsEventsCallbackASM: the C-convention callback invoked by FSEvents +// on a GCD dispatch queue thread. Retains/copies callback data into a +// payload, writes the payload pointer to eventPipe to wake the Go event-loop +// goroutine, then returns. Never enters Go ABI; stays entirely in System V +// AMD64 calling convention. + +// --------------------------------------------------------------------------- +// FSEventStreamCreate trampoline: shuffles the float64 latency argument. +// +// The runtime's syscall6 trampoline loads 6 args into registers: +// DI=allocator SI=callback DX=ctx CX=paths +// R8=sinceWhen R9=latency(bits) +// +// The C function expects latency in X0 (xmm0) and flags in R9. +// flags is always 0x11 (kFSEventStreamCreateFlagUseCFTypes | +// kFSEventStreamCreateFlagFileEvents), so we hardcode it. +// --------------------------------------------------------------------------- + +TEXT fse_FSEventStreamCreate_trampoline<>(SB), NOSPLIT, $0-0 + MOVQ R9, X0 + MOVQ $0x11, R9 + JMP fse_FSEventStreamCreate(SB) + +GLOBL ·fse_FSEventStreamCreate_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamCreate_trampoline_addr(SB)/8, $fse_FSEventStreamCreate_trampoline<>(SB) + +// --------------------------------------------------------------------------- +// FSEvents callback: called from a GCD dispatch queue with C convention. +// DI=streamRef SI=info DX=numEvents CX=paths R8=flags R9=ids +// +// `info` is a pointer to a streamCallback struct (see fsevents_darwin_ffi.go): +// offset 0: eventPipeWrite fd (8 bytes) +// +// Stays entirely in C context (no cgocallback). Saves args to the per-stream +// heap-allocated payload, writes its pointer to the stream's eventPipe to wake +// its Go event loop goroutine, then returns immediately. +// +// NOFRAME: this function is entered from C, not Go. We manage the frame +// ourselves following the System V AMD64 ABI. +// +// Frame layout (88 bytes, 16-byte aligned): +// On entry from C, RSP ≡ 8 mod 16 (return address pushed by CALL). +// SUB $88 → RSP ≡ 8-88 = 0 mod 16, aligned for CALL into libc. +// RSP+ 0: payload pointer bytes written to eventPipe +// RSP+ 8: saved info pointer +// RSP+16: saved numEvents +// RSP+24: saved original flags pointer +// RSP+32: retained CFArray paths +// RSP+40: copied flags pointer +// RSP+80: saved RBP ← BP points here (C frame chain) +// RSP+88: return address (pushed by C's CALL) +// --------------------------------------------------------------------------- + +TEXT fsEventsCallbackASM<>(SB), NOSPLIT|NOFRAME, $0 + SUBQ $88, SP + MOVQ BP, 80(SP) + LEAQ 80(SP), BP + + MOVQ SI, 8(SP) // info + MOVQ DX, 16(SP) // numEvents + MOVQ R8, 24(SP) // original flags + + // Retain the CFArray paths because FSEvents owns the callback argument. + MOVQ CX, DI + XORL AX, AX + CALL fse_CFRetain(SB) + TESTQ AX, AX + JEQ done + MOVQ AX, 32(SP) + + // Copy the flags array into C heap memory owned by the Go event loop. + MOVQ 16(SP), DI + SHLQ $2, DI + XORL AX, AX + CALL fse_malloc(SB) + TESTQ AX, AX + JEQ releasePaths + MOVQ AX, 40(SP) + + MOVQ AX, DI + MOVQ 24(SP), SI + MOVQ 16(SP), DX + SHLQ $2, DX + XORL AX, AX + CALL fse_memcpy(SB) + + // Allocate and populate fsEventsCallbackPayload. + MOVQ $24, DI + XORL AX, AX + CALL fse_malloc(SB) + TESTQ AX, AX + JEQ freeFlags + MOVQ AX, 0(SP) + + MOVQ 16(SP), CX + MOVQ CX, (0*8)(AX) + MOVQ 32(SP), CX + MOVQ CX, (1*8)(AX) + MOVQ 40(SP), CX + MOVQ CX, (2*8)(AX) + + // write(info->eventPipeWrite, &payload, sizeof(payload)). +writeAgain: + MOVQ 8(SP), AX // reload info + MOVQ (0*8)(AX), DI // eventPipeWrite + LEAQ 0(SP), SI // buf (payload pointer) + MOVQ $8, DX // count + XORL AX, AX // no float args + CALL fse_write(SB) + CMPQ AX, $8 + JEQ done + CMPQ AX, $-1 + JNE freePayload + XORL AX, AX // no float args + CALL fse___error(SB) + MOVL (AX), AX + CMPL AX, $4 // EINTR + JEQ writeAgain + JMP freePayload + +freePayload: + MOVQ 0(SP), DI + XORL AX, AX + CALL fse_free(SB) + +freeFlags: + MOVQ 40(SP), DI + XORL AX, AX + CALL fse_free(SB) + +releasePaths: + MOVQ 32(SP), DI + XORL AX, AX + CALL fse_CFRelease(SB) + + // Return 0. +done: + XORL AX, AX + MOVQ 80(SP), BP + ADDQ $88, SP + RET + +GLOBL ·fsEventsCallbackAsmAddr(SB), RODATA, $8 +DATA ·fsEventsCallbackAsmAddr(SB)/8, $fsEventsCallbackASM<>(SB) diff --git a/internal/fswatch/fsevents_darwin_ffi_arm64.s b/internal/fswatch/fsevents_darwin_ffi_arm64.s new file mode 100644 index 00000000000..97aee55bed8 --- /dev/null +++ b/internal/fswatch/fsevents_darwin_ffi_arm64.s @@ -0,0 +1,144 @@ +//go:build darwin && arm64 + +#include "textflag.h" + +// fsevents_darwin_ffi_arm64.s: arm64 assembly for the FSEvents backend +// +// Contains two functions: +// +// 1. FSEventStreamCreate trampoline: moves the float64 latency bits +// from R5 (integer register, where syscall6 puts it) into F0 (the +// AAPCS64 first float argument register), and hardcodes flags to +// 0x11 (kFSEventStreamCreateFlagUseCFTypes | +// kFSEventStreamCreateFlagFileEvents). +// +// 2. fsEventsCallbackASM: the C-convention callback invoked by FSEvents +// on a GCD dispatch queue thread. Retains/copies callback data into a +// payload, writes the payload pointer to eventPipe to wake the Go event-loop +// goroutine, then returns. Never enters Go ABI. Uses only caller-saved +// registers (R0-R17) to avoid +// clobbering AAPCS64 callee-saved R19-R28 and platform-reserved R18. +// See TestCallbackASMTouchesOnlySafeRegisters for the static check. + +// --------------------------------------------------------------------------- +// FSEventStreamCreate trampoline: shuffles the float64 latency argument. +// +// The runtime's syscall6 trampoline loads 6 args into R0-R5: +// R0=allocator R1=callback R2=ctx R3=paths +// R4=sinceWhen R5=latency(bits) +// +// The C function expects latency in F0 (float register) and flags in R5. +// flags is always 0x11 (kFSEventStreamCreateFlagUseCFTypes | +// kFSEventStreamCreateFlagFileEvents), so we hardcode it. +// --------------------------------------------------------------------------- + +TEXT fse_FSEventStreamCreate_trampoline<>(SB), NOSPLIT, $0-0 + FMOVD R5, F0 + MOVD $0x11, R5 + JMP fse_FSEventStreamCreate(SB) + +GLOBL ·fse_FSEventStreamCreate_trampoline_addr(SB), RODATA, $8 +DATA ·fse_FSEventStreamCreate_trampoline_addr(SB)/8, $fse_FSEventStreamCreate_trampoline<>(SB) + +// --------------------------------------------------------------------------- +// FSEvents callback: called from a GCD dispatch queue with C convention. +// R0=streamRef R1=info R2=numEvents R3=paths R4=flags R5=ids +// +// `info` (R1) is a pointer to a streamCallback struct: +// offset 0: eventPipeWrite fd +// +// Because all memory accesses use offset addressing from R1 (a caller-saved +// register), there are no global symbol loads and no REGTMP/R27 hazard. +// +// Frame layout (80 bytes, 16-byte aligned): +// RSP+ 0: saved R29 (FP) ← R29 points here (C frame chain) +// RSP+ 8: saved R30 (LR) +// RSP+16: payload pointer bytes written to eventPipe +// RSP+24: saved info pointer +// RSP+32: saved numEvents +// RSP+40: saved original flags pointer +// RSP+48: retained CFArray paths +// RSP+56: copied flags pointer +// --------------------------------------------------------------------------- + +TEXT fsEventsCallbackASM<>(SB), NOSPLIT|NOFRAME, $0 + SUB $80, RSP + MOVD R29, (RSP) + MOVD R30, 8(RSP) + MOVD RSP, R29 + + MOVD R1, 24(RSP) // info + MOVD R2, 32(RSP) // numEvents + MOVD R4, 40(RSP) // original flags + + // Retain the CFArray paths because FSEvents owns the callback argument. + MOVD R3, R0 + BL fse_CFRetain(SB) + CBZ R0, done + MOVD R0, 48(RSP) + + // Copy the flags array into C heap memory owned by the Go event loop. + MOVD 32(RSP), R0 + LSL $2, R0, R0 + BL fse_malloc(SB) + CBZ R0, releasePaths + MOVD R0, 56(RSP) + + MOVD R0, R0 + MOVD 40(RSP), R1 + MOVD 32(RSP), R2 + LSL $2, R2, R2 + BL fse_memcpy(SB) + + // Allocate and populate fsEventsCallbackPayload. + MOVD $24, R0 + BL fse_malloc(SB) + CBZ R0, freeFlags + MOVD R0, 16(RSP) + + MOVD 32(RSP), R6 + MOVD R6, (0*8)(R0) + MOVD 48(RSP), R6 + MOVD R6, (1*8)(R0) + MOVD 56(RSP), R6 + MOVD R6, (2*8)(R0) + + // write(info->eventPipeWrite, &payload, sizeof(payload)). +writeAgain: + MOVD 24(RSP), R6 // reload info + MOVD (0*8)(R6), R0 // eventPipeWrite + ADD $16, RSP, R1 // buf (payload pointer) + MOVD $8, R2 // count + BL fse_write(SB) + CMP $8, R0 + BEQ done + ADD $1, R0, R6 + CBNZ R6, freePayload + BL fse___error(SB) + MOVW (R0), R0 + CMPW $4, R0 // EINTR + BEQ writeAgain + B freePayload + +freePayload: + MOVD 16(RSP), R0 + BL fse_free(SB) + +freeFlags: + MOVD 56(RSP), R0 + BL fse_free(SB) + +releasePaths: + MOVD 48(RSP), R0 + BL fse_CFRelease(SB) + + // Return 0. +done: + MOVD $0, R0 + MOVD (RSP), R29 + MOVD 8(RSP), R30 + ADD $80, RSP + RET + +GLOBL ·fsEventsCallbackAsmAddr(SB), RODATA, $8 +DATA ·fsEventsCallbackAsmAddr(SB)/8, $fsEventsCallbackASM<>(SB) diff --git a/internal/fswatch/fsevents_darwin_ffi_arm64_test.go b/internal/fswatch/fsevents_darwin_ffi_arm64_test.go new file mode 100644 index 00000000000..8f5afe35f29 --- /dev/null +++ b/internal/fswatch/fsevents_darwin_ffi_arm64_test.go @@ -0,0 +1,151 @@ +//go:build darwin && arm64 + +package fswatch + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// TestCallbackASMTouchesOnlySafeRegisters verifies that fsEventsCallbackASM +// (the C callback entered from CFRunLoop) only touches registers that are +// safe to clobber under AAPCS, i.e. registers the C caller doesn't expect +// to find unchanged after the call. +// +// We are entered from C (FSEvents -> CFRunLoopRun -> ... -> our callback) +// and never transition into Go ABI; we must therefore obey the standard +// arm64 AAPCS contract: +// +// Callee-saved (must be preserved across our call): +// R19-R28 (general) F8-F15 (float) R29 (FP) R30 (LR) +// Caller-saved (free to clobber): +// R0-R8 (args/return) F0-F7 (args/return) +// R9-R15 (scratch) F16-F31 (scratch) +// R16, R17 (IP0/IP1: linker trampoline scratch; caller-saved) +// Special / restricted: +// R18: platform register; reserved by darwin (do not touch) +// RSP: stack pointer (we manage) +// ZR: zero register (read-only constant) +// PC: program counter (read-only, appears in PC-relative addresses) +// +// We *do* touch R29 and R30, but only because we save the caller's value +// to the stack on entry and restore it on exit (R29 to set up our frame +// chain pointer; R30 because each of our BLs clobbers LR). Treating them +// as allowed in this test is correct so long as the prologue/epilogue +// continue to save/restore them; a bare reference without that bookkeeping +// would still be a bug, but a much more obvious one to spot in review. +// +// The motivating failure was a silent R27 (REGTMP) clobber from +// `MOVD ·sym(SB), Rn` pseudo-instruction expansion (cmd/internal/obj/ +// arm64/a.out.go: REGTMP = REG_R27); FSEvents holds a CFAllocator pointer +// in R27 across our callback and uses it for CFRelease afterwards, so the +// clobber surfaces as a SIGSEGV inside objc_release deep in CFRunLoopRun. +// The crash is layout-sensitive and not reliably caught by the +// race-detector test suite alone, hence this static check. +// +// A whitelist (rather than a blacklist of "known dangerous" registers) +// guards against any future Go toolchain change that introduces a new +// kind of pseudo-instruction expansion using a register we hadn't +// previously thought to forbid: any unfamiliar register name in the +// disassembly will fail the test. +// +// If the asm is ever rewritten to use the save-and-restore strategy +// (mirroring runtime/cgo/abi_arm64.h's SAVE_R19_TO_R28 / RESTORE_R19_TO_R28), +// the safe set here will need to be extended to include R19-R28 (and the +// test should be supplemented with a check that the prologue/epilogue +// actually save and restore them). +func TestCallbackASMTouchesOnlySafeRegisters(t *testing.T) { + t.Parallel() + // `go test` (without -c) strips the test binary, so we can't disassemble + // it for symbol-level inspection. Build a fresh, unstripped copy. + bin := filepath.Join(t.TempDir(), "callback-disasm.test") + if out, err := exec.Command("go", "test", "-c", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("go test -c failed: %v\n%s", err, out) + } + + out, err := exec.Command("go", "tool", "objdump", "-s", "fsEventsCallbackASM", bin).CombinedOutput() + if err != nil { + t.Fatalf("go tool objdump failed: %v\n%s", err, out) + } + if len(out) == 0 { + t.Fatalf("go tool objdump produced no output; symbol fsEventsCallbackASM not found in %s", bin) + } + + // Set of registers safe to touch when called from C. + safe := map[string]bool{ + "RSP": true, "ZR": true, "ZRW": true, "PC": true, + // Frame/link registers: managed by our prologue/epilogue. + "R29": true, "R30": true, + } + // Caller-saved general-purpose: R0-R17. + for i := range 18 { + safe[fmt.Sprintf("R%d", i)] = true + } + // Caller-saved float: F0-F7 and F16-F31. + for i := range 8 { + safe[fmt.Sprintf("F%d", i)] = true + } + for i := 16; i <= 31; i++ { + safe[fmt.Sprintf("F%d", i)] = true + } + + // Match register tokens of the form Rnn / Fnn / RSP / ZR / PC. + regToken := regexp.MustCompile(`\b([RF]\d+|RSP|RZR|ZR|PC)\b`) + + // Each disassembly line looks like: + // fsevents_darwin_ffi_arm64.s:106\t0x10012b1e0\t\td10083ff\t\tSUB $32, RSP, RSP\t + // We only want to inspect the instruction text (the last tab-delimited + // non-empty field). The header line ("TEXT _fsEventsCallbackASM(SB) ...") + // is skipped. + type violation struct{ reg, line string } + var violations []violation + seen := map[string]bool{} + + for raw := range strings.SplitSeq(string(out), "\n") { + line := strings.TrimRight(raw, " \t") + if line == "" || strings.HasPrefix(line, "TEXT ") { + continue + } + fields := strings.Split(line, "\t") + // Find the rightmost non-empty field: the instruction text. + var inst string + for i := len(fields) - 1; i >= 0; i-- { + if f := strings.TrimSpace(fields[i]); f != "" { + inst = f + break + } + } + if inst == "" { + continue + } + for _, m := range regToken.FindAllString(inst, -1) { + if safe[m] { + continue + } + if seen[m] { + continue + } + seen[m] = true + violations = append(violations, violation{reg: m, line: line}) + } + } + + if len(violations) > 0 { + sort.Slice(violations, func(i, j int) bool { return violations[i].reg < violations[j].reg }) + var b bytes.Buffer + fmt.Fprintf(&b, "fsEventsCallbackASM touches register(s) the C caller (CFRunLoop/FSEvents) "+ + "expects preserved or that are platform-reserved on darwin/arm64. The C ABI "+ + "requires R19-R28, F8-F15 to be preserved across the call, and R18 to be left "+ + "untouched. See the REGTMP hazard note in fsevents_darwin_ffi_arm64.s.\n") + for _, v := range violations { + fmt.Fprintf(&b, " %s first appears in: %s\n", v.reg, v.line) + } + t.Fatal(b.String()) + } +} diff --git a/internal/fswatch/fsevents_darwin_nfd_test.go b/internal/fswatch/fsevents_darwin_nfd_test.go new file mode 100644 index 00000000000..820f4c69d6f --- /dev/null +++ b/internal/fswatch/fsevents_darwin_nfd_test.go @@ -0,0 +1,176 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "os" + "path/filepath" + "testing" +) + +// These tests document a real cross-normalization failure mode on macOS: +// the directory or file exists on disk under one Unicode normalization +// form (e.g. NFD, because it was created by an older Mac tool, copied +// from an HFS+ volume, or synced from another machine) but the caller +// subscribes using the canonical/precomposed (NFC) form, or vice versa. +// APFS is normalization-insensitive for *lookups* (open/stat both forms +// resolve to the same inode), but FSEvents reports paths with whatever +// bytes are stored on disk, so direct string comparisons inside the +// library and in WatchFile silently misfire. + +// "é" +const ( + nfcE = "\u00e9" // U+00E9 + nfdE = "e\u0301" // U+0065 U+0301 +) + +// TestNormalizeNFC exercises the CoreFoundation-backed normalizer directly +// (without going through FSEvents) so a regression in the FFI plumbing is +// caught even if the end-to-end FSEvents tests are skipped. +func TestNormalizeNFC(t *testing.T) { + t.Parallel() + + const ( + // Latin combining marks (BMP, one combining mark per base). + nfcCafe = "caf" + nfcE + nfdCafe = "caf" + nfdE + // Hangul: composition is algorithmic, not table-driven. + // "한" (U+D55C) decomposes to ᄒ ᅡ ᆫ (U+1112 U+1161 U+11AB). + nfcHan = "\uD55C" + nfdHan = "\u1112\u1161\u11AB" + // Multi-codepoint compose: "ệ" (U+1EC7) ⇄ "e\u0323\u0302" (also valid as + // e\u0302\u0323 due to canonical ordering; CFStringNormalize handles both). + nfcEHook = "\u1EC7" + nfdEHook = "e\u0323\u0302" + ) + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"ascii", "/var/folders/abc/hello.txt", "/var/folders/abc/hello.txt"}, + {"ascii-only-high-bit-edge", "/\x7f/path", "/\x7f/path"}, + {"already-NFC-latin", nfcCafe, nfcCafe}, + {"NFD-to-NFC-latin", nfdCafe, nfcCafe}, + {"already-NFC-hangul", nfcHan, nfcHan}, + {"NFD-to-NFC-hangul", nfdHan, nfcHan}, + {"already-NFC-multi-mark", nfcEHook, nfcEHook}, + {"NFD-to-NFC-multi-mark", nfdEHook, nfcEHook}, + {"mixed-ascii-and-NFD", "/tmp/" + nfdCafe + "/file.txt", "/tmp/" + nfcCafe + "/file.txt"}, + {"non-bmp-passthrough", "/tmp/\U0001F600.txt", "/tmp/\U0001F600.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizeNFC(tt.in) + if got != tt.want { + t.Errorf("normalizeNFC(%q):\n want: %q (% x)\n got: %q (% x)", + tt.in, tt.want, tt.want, got, got) + } + }) + } +} + +// TestNormalizeNFCASCIIFastPath verifies the ASCII fast path returns the +// input unchanged with no Unicode round-trip. +func TestNormalizeNFCASCIIFastPath(t *testing.T) { + t.Parallel() + + in := "/var/folders/abc/def/hello.txt" + out := normalizeNFC(in) + if out != in { + t.Fatalf("ascii input mutated: want %q, got %q", in, out) + } +} + +func TestIsASCII(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want bool + }{ + {"", true}, + {"hello", true}, + {"/tmp/file.txt", true}, + {"\x7f", true}, // DEL is the last ASCII byte + {"\x80", false}, // first non-ASCII byte + {"caf\u00e9", false}, // NFC é + {"cafe\u0301", false}, // NFD é (combining mark is also non-ASCII) + {"a" + string([]byte{0xC2, 0xA9}), false}, // © (U+00A9) + } + for _, tt := range tests { + if got := isASCII(tt.in); got != tt.want { + t.Errorf("isASCII(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +// TestFSEventsNFDOnDiskNFCSubscribe creates the directory using its NFD +// byte sequence, subscribes via the NFC form (APFS resolves both to the +// same inode), and asserts that emitted event paths match what the +// caller subscribed with. Today the path comes back as NFD, so callers +// can't compare it against their own NFC paths. +func TestFSEventsNFDOnDiskNFCSubscribe(t *testing.T) { + t.Parallel() + + parent := newTmpDir(t) + + nfdDir := filepath.Join(parent, "caf"+nfdE+"-dir") + nfcDir := filepath.Join(parent, "caf"+nfcE+"-dir") + + if err := os.Mkdir(nfdDir, 0o755); err != nil { + t.Fatal(err) + } + + r, _ := subscribeFor(t, nfcDir, FSEvents()) + + nfcChild := filepath.Join(nfcDir, "hello.txt") + if err := os.WriteFile(nfcChild, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + got := r.next(r.deadline()) + if len(got) == 0 { + t.Fatal("no events received") + } + for _, e := range got { + if e.Path != nfcChild { + t.Errorf("event path not in subscriber's (NFC) form:\n want: %q (% x)\n got: %q (% x)", + nfcChild, nfcChild, e.Path, e.Path) + } + } +} + +// TestFSEventsNFDOnDiskNFCWatchFile shows WatchFile is silently broken +// across normalization forms: the file is created on disk as NFD, the +// caller watches the NFC path, and the e.Path == path filter in +// WatchFile drops every event. +func TestFSEventsNFDOnDiskNFCWatchFile(t *testing.T) { + t.Parallel() + + dir := newTmpDir(t) + + nfdTarget := filepath.Join(dir, "r"+nfdE+"sum"+nfdE+".txt") + nfcTarget := filepath.Join(dir, "r"+nfcE+"sum"+nfcE+".txt") + + r, _ := subscribeFileFor(t, nfcTarget, FSEvents()) + + if err := os.WriteFile(nfdTarget, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + got := r.next(r.deadline()) + if len(got) == 0 { + t.Fatal("WatchFile delivered no events: FSEvents reported the path in its on-disk (NFD) form and the e.Path == path filter in WatchFile dropped it") + } + for _, e := range got { + if e.Path != nfcTarget { + t.Errorf("event path mismatch:\n want: %q (% x)\n got: %q (% x)", + nfcTarget, nfcTarget, e.Path, e.Path) + } + } +} diff --git a/internal/fswatch/inotify_linux.go b/internal/fswatch/inotify_linux.go new file mode 100644 index 00000000000..29e8053038a --- /dev/null +++ b/internal/fswatch/inotify_linux.go @@ -0,0 +1,423 @@ +//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 + 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); 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.dir, true, func(path string, isDir bool) error { + if !isDir { + return nil + } + if _, err := b.watchDir(w, path); 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) (int, error) { + wd, err := unix.InotifyAddWatch(b.inotify, path, inotifyMask) + if err != nil { + return 0, err + } + sub := &inotifySubscription{path: path, 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 + isDir := ev.Mask&unix.IN_ISDIR != 0 + if name != "" { + path = path + "/" + name + } + + switch { + case ev.Mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0: + w.events.create(path) + if isDir && w.recursive { + _ = walkDir(path, true, func(p string, pIsDir bool) error { + if !pIsDir { + return nil + } + _, _ = b.watchDir(w, 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 +} diff --git a/internal/fswatch/kqueue.go b/internal/fswatch/kqueue.go new file mode 100644 index 00000000000..48d4aca5dfb --- /dev/null +++ b/internal/fswatch/kqueue.go @@ -0,0 +1,764 @@ +//go:build darwin || freebsd || openbsd || netbsd || dragonfly + +package fswatch + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + + "golang.org/x/sys/unix" +) + +// --------------------------------------------------------------------------- +// kqueue.go: kqueue backend (macOS, FreeBSD, OpenBSD, NetBSD, DragonFlyBSD) +// +// Uses the kernel's kqueue/kevent mechanism to watch individual files and +// directories via EVFILT_VNODE. Unlike inotify, kqueue requires an open file +// descriptor per watched path, not just per directory. On macOS, O_EVTONLY +// opens files for event monitoring only; on other BSDs, O_RDONLY is used. +// +// ┌──────────────────────────────────────────────────────────────┐ +// │ kqueueBackend │ +// │ │ +// │ ┌───────────┐ kevent(2) ┌──────────────────┐ │ +// │ │ pipe[0] ├───────────────────────►│ │ │ +// │ │ (wakeup) │ │ start() │ │ +// │ └───────────┘ │ goroutine │ │ +// │ ┌───────────┐ EVFILT_VNODE │ (event loop) │ │ +// │ │ kqueue ├───────────────────────►│ │ │ +// │ │ fd │ └────────┬─────────┘ │ +// │ └───────────┘ │ │ +// │ ▼ │ +// │ ┌──────────────────────────────────────────────┐ │ +// │ │ fdToEntry: map[fd] → *dirEntry │ │ +// │ │ subsByPath: map[path] → []*kqueueSub │ │ +// │ │ │ │ +// │ │ Each dirEntry.state stores the open fd │ │ +// │ └──────────────────────────────────────────────┘ │ +// └──────────────────────────────────────────────────────────────┘ +// +// Goroutines and threading: +// - One long-lived goroutine (start), launched by watcherBase.run(). It +// owns the kevent(2) loop and runs for the process lifetime. All event +// dispatch (compareDir, handleFileEvent) executes on this goroutine. +// compareDir and handleFileEvent acquire b.mu for watch/fd lookups. +// - subscribe/closeWatch run on the caller's goroutine under +// watcherBase.mu. watchPath acquires b.mu to register fd mappings. +// +// 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, building a path→dirEntry map (caller goroutine). +// 2. For every entry (file or directory), open an fd and register it with +// kqueue for EVFILT_VNODE events (NOTE_DELETE, NOTE_WRITE, NOTE_EXTEND, +// NOTE_ATTRIB, NOTE_RENAME, NOTE_REVOKE). Store the fd↔dirEntry mapping. +// +// Event dispatch (on the start goroutine): +// - NOTE_WRITE on a directory → compareDir: re-read the directory from +// disk, diff against the in-memory tree, emit update events for new +// entries (opening + watching them) and delete events for removed ones +// (closing their fds). +// - NOTE_DELETE / NOTE_RENAME / NOTE_REVOKE → close the stale fd. For a +// pure NOTE_DELETE on a file, tryRewatchLocked checks whether the path +// was immediately recreated (atomic-save pattern) and emits update +// instead of delete if so. Otherwise emit delete and remove from the +// tree. Directories skip tryRewatchLocked to avoid spurious updates +// during RemoveAll races. +// - NOTE_WRITE / NOTE_ATTRIB / NOTE_EXTEND on a file → emit update. +// After processing all returned kevents, call dirWatch.notify() on each +// touched dirWatch to trigger the debouncer. +// +// Shutdown: +// Write a byte to pipe[1] → kevent sees the pipe fd → loop exits → +// close all tracked fds, the kqueue fd, and the pipe. +// --------------------------------------------------------------------------- + +// openForEvents opens a path for kqueue event monitoring. On darwin, O_EVTONLY +// opens the file for event notification without granting read access. On other +// BSDs, falls back to O_RDONLY. +func openForEvents(path string) (int, error) { + flags := unix.O_RDONLY + if runtime.GOOS == "darwin" { + flags = 0x8000 // O_EVTONLY, darwin-only + } + return unix.Open(path, flags, 0) +} + +// dirEntry tracks a watched path for kqueue's fd↔path mapping. +type dirEntry struct { + path string + isDir bool + state any // stores the open fd +} + +// kqueueSubscription. +type kqueueSubscription struct { + dirWatch *dirWatch + path string + entries map[string]*dirEntry + fd int +} + +// kqueueBackend. It embeds treeReaderBackend (via Go +// composition) just like the inheritance hierarchy. +type kqueueBackend struct { + watcherBase + + mu sync.Mutex // local lock for kqueue-specific maps + kq int + // pipeFDs[0] is read in the Start goroutine only. pipeFDs[1] is written + // by Shutdown (any goroutine) to wake the loop, so it lives in + // pipeWriteFD as an atomic with a sentinel of -1 once closed. + pipeFDs [2]int + pipeWriteFD atomic.Int32 + subsByPath map[string][]*kqueueSubscription // multimap + fdToEntry map[int]*dirEntry + endedSignal chan struct{} + + // Persistent buffer reused across event batches. Only accessed + // from the start goroutine, so no synchronization needed. + watchersTouched map[*dirWatch]struct{} +} + +func init() { + kqueueWatcher.factory = func() watcherImpl { return newKqueueBackend() } +} + +func newKqueueBackend() *kqueueBackend { + b := &kqueueBackend{ + kq: -1, + pipeFDs: [2]int{-1, -1}, + subsByPath: map[string][]*kqueueSubscription{}, + fdToEntry: map[int]*dirEntry{}, + endedSignal: make(chan struct{}), + watchersTouched: make(map[*dirWatch]struct{}), + } + b.pipeWriteFD.Store(-1) + b.watcherBase.init(b) + return b +} + +func (b *kqueueBackend) start() error { + kq, err := unix.Kqueue() + if err != nil { + return fmt.Errorf("unable to open kqueue: %w", err) + } + b.kq = kq + defer func() { + b.closeSubscriptions() + b.closeFDs() + close(b.endedSignal) + }() + + if err := unix.Pipe(b.pipeFDs[:]); err != nil { + return fmt.Errorf("unable to open pipe: %w", err) + } + b.pipeWriteFD.Store(int32(b.pipeFDs[1])) + + // WatchDirectory kqueue to the read side of the pipe so we can break the + // loop on shutdown. SetKevent handles the per-arch Ident type + // (uint64 on 64-bit, uint32 on 386/arm). + var pipeEv unix.Kevent_t + unix.SetKevent(&pipeEv, b.pipeFDs[0], unix.EVFILT_READ, unix.EV_ADD|unix.EV_CLEAR) + if _, err := unix.Kevent(kq, []unix.Kevent_t{pipeEv}, nil, nil); err != nil { + return fmt.Errorf("unable to watch pipe: %w", err) + } + + b.notifyStarted() + + events := make([]unix.Kevent_t, 128) + for { + n, err := unix.Kevent(kq, nil, events, nil) + if err != nil { + if err == unix.EINTR { + continue + } + return fmt.Errorf("kevent error: %w", err) + } + + watchersTouched := b.watchersTouched + stop := false + for i := range n { + fflags := events[i].Fflags + flags := events[i].Flags + fd := int(events[i].Ident) + if fd == b.pipeFDs[0] { + stop = true + break + } + + // EV_ERROR indicates kevent couldn't apply a changelist + // entry or that the kernel rejected the registration. + // Data carries the errno. Skip dispatching as a normal + // event since fflags are not meaningful in this case. + if flags&unix.EV_ERROR != 0 { + continue + } + + b.mu.Lock() + entry, ok := b.fdToEntry[fd] + b.mu.Unlock() + if !ok || entry == nil { + continue + } + + if fflags&unix.NOTE_WRITE != 0 && entry.isDir { + b.compareDir(fd, entry.path, watchersTouched) + // NOTE_WRITE on a dir already ran compareDir above. + // On DragonFlyBSD, rename-over coalesces NOTE_DELETE + // with NOTE_WRITE on the parent directory (rather than + // firing NOTE_DELETE on the replaced file's fd). + // Skip handleFileEvent so we don't misinterpret the + // coalesced NOTE_DELETE as the directory itself being + // removed. + fflags &^= unix.NOTE_DELETE + } + if fflags&^unix.NOTE_WRITE != 0 || !entry.isDir { + b.handleFileEvent(fflags, entry, watchersTouched) + } + } + + for w := range watchersTouched { + w.notify() + } + clear(watchersTouched) + if stop { + break + } + } + + return nil +} + +func (b *kqueueBackend) closeFDs() { + 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.kq >= 0 { + _ = unix.Close(b.kq) + b.kq = -1 + } +} + +func (b *kqueueBackend) closeSubscriptions() { + b.mu.Lock() + seenFDs := map[int]struct{}{} + for _, list := range b.subsByPath { + for _, sub := range list { + if sub.fd < 0 { + continue + } + if _, ok := seenFDs[sub.fd]; ok { + continue + } + seenFDs[sub.fd] = struct{}{} + _ = unix.Close(sub.fd) + } + } + b.subsByPath = map[string][]*kqueueSubscription{} + b.fdToEntry = map[int]*dirEntry{} + b.mu.Unlock() +} + +func (b *kqueueBackend) shutdown() { + fd := b.pipeWriteFD.Load() + if fd < 0 { + return + } + _, _ = unix.Write(int(fd), []byte{'X'}) + <-b.endedSignal +} + +func (b *kqueueBackend) handleFileEvent(fflags uint32, entry *dirEntry, touched map[*dirWatch]struct{}) { + b.mu.Lock() + defer b.mu.Unlock() + subs := b.findSubscriptionsLocked(entry.path) + + if fflags&(unix.NOTE_DELETE|unix.NOTE_RENAME|unix.NOTE_REVOKE) != 0 { + // Close the stale fd; the watched inode is gone. + if oldFD, ok := entry.state.(int); ok { + unix.Close(oldFD) + delete(b.fdToEntry, oldFD) + entry.state = nil + } + + recreated := false + if fflags&unix.NOTE_DELETE != 0 && fflags&(unix.NOTE_RENAME|unix.NOTE_REVOKE) == 0 && !entry.isDir { + recreated = b.tryRewatchLocked(entry) + } + + for _, sub := range subs { + touched[sub.dirWatch] = struct{}{} + if recreated { + sub.dirWatch.events.update(sub.path) + } else { + sub.dirWatch.events.remove(sub.path) + // If we lost a directory, walk the entries map and + // close every fd we had open for descendants. Some + // kernels (OpenBSD in particular) deliver only the + // parent's NOTE_DELETE/NOTE_RENAME and never fire + // NOTE_DELETE on the children; without this cleanup, + // modifying a file inside the moved tree later + // surfaces an event against the descendant's stale + // (pre-rename) path. We also emit a delete for each + // descendant we close, so callers don't miss those + // removals if the kernel didn't fire per-child events. + // (When the kernel does fire them, our follow-up + // handleFileEvent finds the fd already gone and is a + // no-op, so events.create's coalescing handles dups.) + if entry.isDir { + b.closeDescendantFDsLocked(sub.dirWatch, sub.entries, sub.path) + } + removeEntryAndDescendants(sub.entries, sub.path) + // Root-of-watch deletion: no more events can fire + // for this dirWatch. Tell the caller. + if sub.path == sub.dirWatch.dir { + sub.dirWatch.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) + } + } + } + if !recreated { + delete(b.subsByPath, entry.path) + } + return + } + + for _, sub := range subs { + touched[sub.dirWatch] = struct{}{} + if fflags&(unix.NOTE_WRITE|unix.NOTE_ATTRIB|unix.NOTE_EXTEND) != 0 { + sub.dirWatch.events.update(sub.path) + } + } +} + +// closeDescendantFDsLocked closes every fd attached to an entry whose +// path lives strictly under root, removing the kevent registration and +// the corresponding b.subsByPath / b.fdToEntry bookkeeping, and emits a +// delete event for each. Used when a directory's parent is lost +// (deleted, renamed away) and the kernel didn't propagate the loss to +// children. eventList coalesces against any per-child NOTE_DELETE that +// arrives later. +func (b *kqueueBackend) closeDescendantFDsLocked(w *dirWatch, entries map[string]*dirEntry, root string) { + prefix := root + string(filepath.Separator) + for path, e := range entries { + if !strings.HasPrefix(path, prefix) { + continue + } + if fd, ok := e.state.(int); ok { + unix.Close(fd) + delete(b.fdToEntry, fd) + e.state = nil + } + delete(b.subsByPath, path) + w.events.remove(path) + } +} + +// tryRewatchLocked checks whether a deleted path was immediately recreated +// with the same type. If so, it opens a new fd, registers a kqueue watch, +// and returns true. The caller should emit update instead of delete. +func (b *kqueueBackend) tryRewatchLocked(entry *dirEntry) bool { + var st unix.Stat_t + if unix.Lstat(entry.path, &st) != nil { + return false + } + + // Only fast-path when the recreated path has the same type; + // a file→dir change needs a full tree rebuild via compareDir. + newIsDir := st.Mode&unix.S_IFMT == unix.S_IFDIR + if newIsDir != entry.isDir { + return false + } + + fd, err := openForEvents(entry.path) + if err != nil { + return false + } + + var ev unix.Kevent_t + unix.SetKevent(&ev, fd, unix.EVFILT_VNODE, unix.EV_ADD|unix.EV_CLEAR|unix.EV_ENABLE) + ev.Fflags = unix.NOTE_DELETE | unix.NOTE_WRITE | unix.NOTE_EXTEND | + unix.NOTE_ATTRIB | unix.NOTE_RENAME | unix.NOTE_REVOKE + if _, err := unix.Kevent(b.kq, []unix.Kevent_t{ev}, nil, nil); err != nil { + unix.Close(fd) + return false + } + + entry.state = fd + + b.fdToEntry[fd] = entry + return true +} + +func (b *kqueueBackend) closeEntryLocked(entry *dirEntry) { + if fd, ok := entry.state.(int); ok { + unix.Close(fd) + delete(b.fdToEntry, fd) + entry.state = nil + } +} + +func (b *kqueueBackend) removeSubsForEntriesLocked(path string, entriesPtr *map[string]*dirEntry) { + list := b.subsByPath[path] + kept := list[:0] + for _, sub := range list { + if &sub.entries == entriesPtr { + continue + } + kept = append(kept, sub) + } + if len(kept) == 0 { + delete(b.subsByPath, path) + } else { + b.subsByPath[path] = kept + } +} + +func (b *kqueueBackend) removeEntryAndDescendantsLocked(entriesPtr *map[string]*dirEntry, path string, includeRoot bool) { + entries := *entriesPtr + for descendant, e := range entries { + if descendant == path { + if !includeRoot { + continue + } + } else if !(len(descendant) > len(path) && descendant[len(path)] == filepath.Separator && descendant[:len(path)] == path) { + continue + } + b.closeEntryLocked(e) + b.removeSubsForEntriesLocked(descendant, entriesPtr) + delete(entries, descendant) + } +} + +func (b *kqueueBackend) findSubscriptionsLocked(path string) []*kqueueSubscription { + subs := b.subsByPath[path] + out := make([]*kqueueSubscription, len(subs)) + copy(out, subs) + return out +} + +// subscribe mirrors `kqueueBackend::subscribe`. Called under watcherBase.mu +// via watchAdd. +func (b *kqueueBackend) subscribe(w *dirWatch) error { + // Build the entries map without registering any watches or + // subscriptions. This avoids a data race: registering a subscription + // publishes the entries map to the event loop (via subsByPath), + // which could read it via compareDir while we're still populating it. + entries := map[string]*dirEntry{} + if err := walkDir(w.dir, w.recursive, func(path string, isDir bool) error { + entries[path] = &dirEntry{path: path, isDir: isDir} + return nil + }); err != nil { + return err + } + + // Open fds, register kevents, and publish subscriptions under b.mu. + // Holding the lock for the entire block ensures that the event loop + // cannot see a partially-built entries map, and that fds are always + // tracked in fdToEntry (no leak on early return). + b.mu.Lock() + defer b.mu.Unlock() + + for path, entry := range entries { + fd, err := openForEvents(path) + if err != nil { + if path == w.dir { + b.cleanupEntriesLocked(entries) + return &dirWatchError{ + err: fmt.Errorf("error watching %s: %w", w.dir, err), + dirWatch: w, + } + } + delete(entries, path) + continue + } + var ev unix.Kevent_t + unix.SetKevent(&ev, fd, unix.EVFILT_VNODE, unix.EV_ADD|unix.EV_CLEAR|unix.EV_ENABLE) + ev.Fflags = unix.NOTE_DELETE | unix.NOTE_WRITE | unix.NOTE_EXTEND | + unix.NOTE_ATTRIB | unix.NOTE_RENAME | unix.NOTE_REVOKE + if _, err := unix.Kevent(b.kq, []unix.Kevent_t{ev}, nil, nil); err != nil { + unix.Close(fd) + if path == w.dir { + b.cleanupEntriesLocked(entries) + return &dirWatchError{ + err: fmt.Errorf("error watching %s: %w", w.dir, err), + dirWatch: w, + } + } + delete(entries, path) + continue + } + entry.state = fd + b.fdToEntry[fd] = entry + } + + for path, entry := range entries { + fd := entry.state.(int) + sub := &kqueueSubscription{dirWatch: w, path: path, entries: entries, fd: fd} + b.subsByPath[path] = append(b.subsByPath[path], sub) + } + return nil +} + +// cleanupEntriesLocked closes fds for all entries that have been opened. +// Called on subscribe failure to avoid fd leaks. Must be called under b.mu. +func (b *kqueueBackend) cleanupEntriesLocked(entries map[string]*dirEntry) { + for _, e := range entries { + if fd, ok := e.state.(int); ok { + unix.Close(fd) + delete(b.fdToEntry, fd) + e.state = nil + } + } +} + +// watchPath corresponds to `kqueueBackend::watchDir`. +func (b *kqueueBackend) watchPath(w *dirWatch, path string, entries map[string]*dirEntry) bool { + entry := entries[path] + if entry == nil { + return false + } + b.mu.Lock() + defer b.mu.Unlock() + + sub := &kqueueSubscription{dirWatch: w, path: path, entries: entries} + if entry.state == nil { + fd, err := openForEvents(path) + if err != nil { + return false + } + var ev unix.Kevent_t + unix.SetKevent(&ev, fd, unix.EVFILT_VNODE, unix.EV_ADD|unix.EV_CLEAR|unix.EV_ENABLE) + ev.Fflags = unix.NOTE_DELETE | unix.NOTE_WRITE | unix.NOTE_EXTEND | + unix.NOTE_ATTRIB | unix.NOTE_RENAME | unix.NOTE_REVOKE + if _, err := unix.Kevent(b.kq, []unix.Kevent_t{ev}, nil, nil); err != nil { + unix.Close(fd) + return false + } + entry.state = fd + b.fdToEntry[fd] = entry + } + sub.fd = entry.state.(int) + b.subsByPath[path] = append(b.subsByPath[path], sub) + return true +} + +// compareDir mirrors `kqueueBackend::compareDir`. Triggered when a watched +// directory has NOTE_WRITE: list the dir, diff against the tree, emit +// create/remove events. +func (b *kqueueBackend) compareDir(_ int, path string, touched map[*dirWatch]struct{}) bool { + b.mu.Lock() + subs := b.findSubscriptionsLocked(path) + b.mu.Unlock() + + // For non-recursive subscriptions, only compareDir on the root dir. + // NOTE_WRITE on a child dir means something changed inside it, but + // non-recursive mode shouldn't report those changes. Emit an update + // for the child dir itself (its metadata changed) and return. + filteredSubs := subs[:0:0] + for _, s := range subs { + if !s.dirWatch.recursive && path != s.dirWatch.dir { + s.dirWatch.events.update(path) + touched[s.dirWatch] = struct{}{} + } else { + filteredSubs = append(filteredSubs, s) + } + } + if len(filteredSubs) == 0 { + return true + } + subs = filteredSubs + + dirStart := path + string(filepath.Separator) + + // Read the current dir contents from disk. + diskEntries, err := readEntries(path) + if err != nil { + return false + } + + // Each subscription has its own entries map (built in subscribe). + // Multiple subs at the same path arise from multiple dirWatches + // covering overlapping subtrees; their maps are always distinct, so + // we iterate subs directly rather than trying to dedup by map identity. + currentSet := map[string]struct{}{} + for _, ent := range diskEntries { + fullPath := dirStart + ent.Name() + currentSet[fullPath] = struct{}{} + + for _, sub := range subs { + entries := sub.entries + existing := entries[fullPath] + if existing != nil { + if existing.state != nil { + // Check if the fd still refers to the same inode as + // the path on disk. On DragonFlyBSD, rename-over + // doesn't fire NOTE_DELETE on the replaced file's fd, + // leaving a stale entry whose fd points to the old + // (now unlinked) inode. + if fd, ok := existing.state.(int); ok { + var fdSt, pathSt unix.Stat_t + if unix.Fstat(fd, &fdSt) == nil && unix.Lstat(fullPath, &pathSt) == nil { + if fdSt.Dev != pathSt.Dev || fdSt.Ino != pathSt.Ino { + // Inode changed: path was replaced. + b.mu.Lock() + b.closeEntryLocked(existing) + b.removeSubsForEntriesLocked(fullPath, &sub.entries) + if existing.isDir { + b.removeEntryAndDescendantsLocked(&sub.entries, fullPath, false) + } + existing.isDir = ent.IsDir() + b.mu.Unlock() + } + } + } + } + if existing.state != nil { + continue + } + // Entry exists but fd is stale: the file was replaced. + // Re-watch it and emit an update. + if !b.watchPath(sub.dirWatch, fullPath, entries) { + continue + } + sub.dirWatch.events.update(fullPath) + touched[sub.dirWatch] = struct{}{} + if ent.IsDir() && sub.dirWatch.recursive { + _ = walkDir(fullPath, true, func(p string, pIsDir bool) error { + if p == fullPath { + return nil + } + e := &dirEntry{path: p, isDir: pIsDir} + entries[p] = e + sub.dirWatch.events.create(p) + b.watchPath(sub.dirWatch, p, entries) + return nil + }) + } + continue + } + e := &dirEntry{path: fullPath, isDir: ent.IsDir()} + entries[fullPath] = e + if !b.watchPath(sub.dirWatch, fullPath, entries) { + delete(entries, fullPath) + continue + } + sub.dirWatch.events.create(fullPath) + touched[sub.dirWatch] = struct{}{} + + // For recursive subscriptions, walk into the new directory + // to catch pre-populated subdirectories (e.g. a directory + // tree moved into the watched area). + if ent.IsDir() && sub.dirWatch.recursive { + _ = walkDir(fullPath, true, func(p string, pIsDir bool) error { + if p == fullPath { + return nil // already handled above + } + e := &dirEntry{path: p, isDir: pIsDir} + entries[p] = e + sub.dirWatch.events.create(p) + b.watchPath(sub.dirWatch, p, entries) + return nil + }) + } + } + } + + // Detect removals: entries directly under dirStart that no longer + // exist on disk. + for _, sub := range subs { + entries := sub.entries + var toRemove []string + for p := range entries { + if !strings.HasPrefix(p, dirStart) { + continue + } + rest := p[len(dirStart):] + if strings.Contains(rest, string(filepath.Separator)) { + continue + } + if _, ok := currentSet[p]; ok { + continue + } + toRemove = append(toRemove, p) + } + for _, p := range toRemove { + sub.dirWatch.events.remove(p) + touched[sub.dirWatch] = struct{}{} + b.mu.Lock() + for descendant, e := range entries { + if descendant != p && !(len(descendant) > len(p) && descendant[len(p)] == filepath.Separator && descendant[:len(p)] == p) { + continue + } + if fd, ok := e.state.(int); ok { + unix.Close(fd) + delete(b.fdToEntry, fd) + } + delete(b.subsByPath, descendant) + } + b.mu.Unlock() + removeEntryAndDescendants(entries, p) + } + } + return true +} + +// readEntries lists directory entries (excluding "." and "..") at path. +func readEntries(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + +// closeWatch mirrors `kqueueBackend::closeWatch`. +func (b *kqueueBackend) closeWatch(w *dirWatch) error { + b.mu.Lock() + defer b.mu.Unlock() + for path, list := range b.subsByPath { + 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 { + // Closing the file descriptor automatically unwatches it in kqueue. + fd := list[0].fd + unix.Close(fd) + delete(b.fdToEntry, fd) + delete(b.subsByPath, path) + } else { + b.subsByPath[path] = kept + } + } + return nil +} + +// removeEntryAndDescendants removes path and all paths prefixed with +// path + separator from the entries map. +func removeEntryAndDescendants(entries map[string]*dirEntry, path string) { + delete(entries, path) + for k := range entries { + if len(k) > len(path) && k[len(path)] == filepath.Separator && k[:len(path)] == path { + delete(entries, k) + } + } +} diff --git a/internal/fswatch/testutil_test.go b/internal/fswatch/testutil_test.go new file mode 100644 index 00000000000..572bcf47263 --- /dev/null +++ b/internal/fswatch/testutil_test.go @@ -0,0 +1,193 @@ +package fswatch + +import "testing" + +// testingT is the subset of [testing.T] (and [testing.TB]) used by every +// test in this package. It exists so that the per-backend test bodies +// dispatched through [runForEachWatcher] can be re-run by a fake T that +// captures Fatal/Skip via panic+recover instead of terminating the +// goroutine. macOS event-delivery stalls (which are not regressions but +// environmental flakes) can then be transparently retried before +// propagating to the real *testing.T. +// +// Restricted vs *testing.T: +// - No Parallel, Run, or other subtest plumbing. +// - No Setenv / Chdir (would race across retries). +// +// All helper functions in this file accept testingT rather than +// *testing.T so they work with both the real test runner and the retry +// wrapper. +type testingT interface { + Helper() + Cleanup(fn func()) + TempDir() string + Name() string + + Log(args ...any) + Logf(format string, args ...any) + + Error(args ...any) + Errorf(format string, args ...any) + Fatal(args ...any) + Fatalf(format string, args ...any) + + Skip(args ...any) + Skipf(format string, args ...any) + SkipNow() + + Failed() bool +} + +// Compile-time assertion that *testing.T satisfies testingT. +var _ testingT = (*testing.T)(nil) + +// retryAttempts is the number of times runForEachWatcher will re-run a +// failing per-backend test body before propagating the failure to the +// real *testing.T. The per-event timeouts inside the body scale with +// the attempt number (1×, 5×, 15×), so the fast-path is cheap and only +// real environmental flakes pay the cost of longer waits. +const retryAttempts = 3 + +// retryTimeoutScale returns the multiplier applied to per-event timeouts +// on the given (1-based) attempt. 1× on first try, 5× on second, +// 15× on third. +func retryTimeoutScale(attempt int) int { + switch attempt { + case 1: + return 1 + case 2: + return 5 + default: + return 15 + } +} + +// retryT is a fake [testingT] used to run a test body and decide +// whether it passed without committing the verdict to the real +// *testing.T on intermediate attempts. +// +// Most methods (Helper, Cleanup, TempDir, Name, Log, Logf) are direct +// passthroughs to the real T so messages stream to test output as they +// happen rather than waiting for a verdict. Error/Errorf record a +// failure locally but also log to the real T (so the message is visible +// even on a successful retry). Fatal/Fatalf/Skip[Now/f] additionally +// panic with [retryBail] to unwind the goroutine; the retry driver +// recovers and either retries or surfaces a final failure. +type retryT struct { + t *testing.T + + // attempt is 1-based and increases on each retry. Per-event + // timeouts in helpers (waitForEvent etc.) scale from this so the + // fast-path uses a short deadline and only retries pay the cost of + // longer waits. + attempt int + + failed bool + skipped bool +} + +// retryBail is panicked by Fatal/Fatalf/SkipNow/Skip[f] to abort the +// test body. The retry driver recovers it and inspects the retryT +// state to decide whether to retry, surface a skip, or accept success. +type retryBail struct{} + +func newRetryT(t *testing.T, attempt int) *retryT { + return &retryT{t: t, attempt: attempt} +} + +func (r *retryT) Helper() { r.t.Helper() } +func (r *retryT) Cleanup(fn func()) { r.t.Cleanup(fn) } +func (r *retryT) TempDir() string { return r.t.TempDir() } +func (r *retryT) Name() string { return r.t.Name() } +func (r *retryT) Log(args ...any) { r.t.Helper(); r.t.Log(args...) } +func (r *retryT) Logf(format string, args ...any) { r.t.Helper(); r.t.Logf(format, args...) } +func (r *retryT) Failed() bool { return r.failed } + +func (r *retryT) Error(args ...any) { + r.t.Helper() + r.failed = true + r.t.Log(args...) +} + +func (r *retryT) Errorf(format string, args ...any) { + r.t.Helper() + r.failed = true + r.t.Logf(format, args...) +} + +func (r *retryT) Fatal(args ...any) { + r.t.Helper() + r.failed = true + r.t.Log(args...) + panic(retryBail{}) +} + +func (r *retryT) Fatalf(format string, args ...any) { + r.t.Helper() + r.failed = true + r.t.Logf(format, args...) + panic(retryBail{}) +} + +func (r *retryT) Skip(args ...any) { + r.t.Helper() + r.skipped = true + r.t.Log(args...) + panic(retryBail{}) +} + +func (r *retryT) Skipf(format string, args ...any) { + r.t.Helper() + r.skipped = true + r.t.Logf(format, args...) + panic(retryBail{}) +} + +func (r *retryT) SkipNow() { + r.skipped = true + panic(retryBail{}) +} + +// runWithRetry runs body up to [retryAttempts] times. Each attempt uses +// a fresh retryT whose attempt counter scales the per-event timeouts in +// the test helpers. Returns on the first attempt that does not fail +// (Skip and success both terminate the loop). On final failure the real +// T is marked failed; intermediate failures are visible in test output +// as Log messages (from Error/Errorf/Fatal/Fatalf streaming through) +// followed by a "retry: ..." log noting the next attempt. +// +// On the fast-path (body passes first try), this is one call with +// negligible overhead over a direct invocation. +func runWithRetry(t *testing.T, body func(testingT)) { + t.Helper() + for attempt := 1; attempt <= retryAttempts; attempt++ { + r := newRetryT(t, attempt) + func() { + defer func() { + if rec := recover(); rec != nil { + if _, ok := rec.(retryBail); !ok { + // Not our panic; resurface. + panic(rec) + } + } + }() + body(r) + }() + + if r.skipped { + t.SkipNow() + return + } + if !r.failed { + if attempt > 1 { + t.Logf("retry: succeeded on attempt %d/%d", attempt, retryAttempts) + } + return + } + if attempt < retryAttempts { + t.Logf("retry: attempt %d/%d failed, retrying with %d× timeout scale", + attempt, retryAttempts, retryTimeoutScale(attempt+1)) + } + } + t.Errorf("retry: gave up after %d attempts", retryAttempts) +} diff --git a/internal/fswatch/walkdir.go b/internal/fswatch/walkdir.go new file mode 100644 index 00000000000..97552c0f4e7 --- /dev/null +++ b/internal/fswatch/walkdir.go @@ -0,0 +1,57 @@ +package fswatch + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// walkDirGeneric is the portable walkDir implementation. It is used as the +// primary implementation on platforms without a native version, and is +// tested on all platforms. +func walkDirGeneric(dir string, recursive bool, fn func(path string, isDir bool) error) error { + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() { + return syscall.ENOTDIR + } + return walkDirGenericVisit(dir, recursive, fn) +} + +func walkDirGenericVisit(dir string, recursive bool, fn func(path string, isDir bool) error) error { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrPermission) || errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if fn != nil { + if err := fn(dir, true); err != nil { + return err + } + } + for _, e := range entries { + path := dir + string(filepath.Separator) + e.Name() + if e.IsDir() { + if recursive { + if err := walkDirGenericVisit(path, recursive, fn); err != nil { + return err + } + } else if fn != nil { + if err := fn(path, true); err != nil { + return err + } + } + } else if fn != nil { + if err := fn(path, false); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/fswatch/walkdir_dirent_darwin.go b/internal/fswatch/walkdir_dirent_darwin.go new file mode 100644 index 00000000000..e6de8d598e8 --- /dev/null +++ b/internal/fswatch/walkdir_dirent_darwin.go @@ -0,0 +1,8 @@ +//go:build darwin + +package fswatch + +import "golang.org/x/sys/unix" + +func reclenOf(d *unix.Dirent) uint16 { return d.Reclen } +func inoOf(d *unix.Dirent) uint64 { return d.Ino } diff --git a/internal/fswatch/walkdir_dirent_fileno.go b/internal/fswatch/walkdir_dirent_fileno.go new file mode 100644 index 00000000000..77a1c922177 --- /dev/null +++ b/internal/fswatch/walkdir_dirent_fileno.go @@ -0,0 +1,8 @@ +//go:build freebsd || openbsd || netbsd + +package fswatch + +import "golang.org/x/sys/unix" + +func reclenOf(d *unix.Dirent) uint16 { return d.Reclen } +func inoOf(d *unix.Dirent) uint64 { return d.Fileno } diff --git a/internal/fswatch/walkdir_dirent_linux.go b/internal/fswatch/walkdir_dirent_linux.go new file mode 100644 index 00000000000..516cab935df --- /dev/null +++ b/internal/fswatch/walkdir_dirent_linux.go @@ -0,0 +1,8 @@ +//go:build linux + +package fswatch + +import "golang.org/x/sys/unix" + +func reclenOf(d *unix.Dirent) uint16 { return d.Reclen } +func inoOf(d *unix.Dirent) uint64 { return d.Ino } diff --git a/internal/fswatch/walkdir_dirent_noreclen.go b/internal/fswatch/walkdir_dirent_noreclen.go new file mode 100644 index 00000000000..0b4d15f6c48 --- /dev/null +++ b/internal/fswatch/walkdir_dirent_noreclen.go @@ -0,0 +1,22 @@ +//go:build dragonfly + +package fswatch + +import ( + "unsafe" + + "golang.org/x/sys/unix" +) + +// DragonFlyBSD's Dirent has no Reclen field; compute it from Namlen +// by rounding up to the next 8-byte boundary (matching the kernel layout). +func reclenOf(d *unix.Dirent) uint16 { + return uint16(alignUp(unsafe.Offsetof(d.Name)+uintptr(d.Namlen)+1, 8)) +} + +func inoOf(d *unix.Dirent) uint64 { return d.Fileno } + +// alignUp rounds n up to a multiple of a. a must be a power of 2. +func alignUp(n, a uintptr) uintptr { + return (n + a - 1) &^ (a - 1) +} diff --git a/internal/fswatch/walkdir_other.go b/internal/fswatch/walkdir_other.go new file mode 100644 index 00000000000..a419b15d771 --- /dev/null +++ b/internal/fswatch/walkdir_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !windows && !darwin && !freebsd && !openbsd && !netbsd && !dragonfly + +package fswatch + +func walkDir(dir string, recursive bool, fn func(path string, isDir bool) error) error { + return walkDirGeneric(dir, recursive, fn) +} diff --git a/internal/fswatch/walkdir_test.go b/internal/fswatch/walkdir_test.go new file mode 100644 index 00000000000..81ae452207f --- /dev/null +++ b/internal/fswatch/walkdir_test.go @@ -0,0 +1,205 @@ +package fswatch + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +type walkDirFunc = func(dir string, recursive bool, fn func(string, bool) error) error + +func runWalkDirTest(t *testing.T, fn func(t *testing.T, walk walkDirFunc)) { + t.Helper() + t.Parallel() + for _, rt := range []struct { + name string + fn walkDirFunc + }{ + {"native", walkDir}, + {"generic", walkDirGeneric}, + } { + t.Run(rt.name, func(t *testing.T) { + t.Parallel() + fn(t, rt.fn) + }) + } +} + +func TestWalkDirDoesNotFollowSymlinkedDir(t *testing.T) { //nolint:paralleltest // runWalkDirTest calls t.Parallel. + runWalkDirTest(t, testWalkDirDoesNotFollowSymlinkedDir) +} + +func testWalkDirDoesNotFollowSymlinkedDir(t *testing.T, walk walkDirFunc) { + root := newTmpDir(t) + target := filepath.Join(t.TempDir(), "target") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "child"), []byte("hidden"), 0o644); err != nil { + t.Fatal(err) + } + + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + found := map[string]bool{} + if err := walk(root, true, func(path string, isDir bool) error { + found[path] = isDir + return nil + }); err != nil { + t.Fatal(err) + } + isDir, ok := found[link] + if !ok { + t.Fatalf("symlink %q missing from walk", link) + } + if isDir { + t.Fatalf("symlink %q was treated as a directory", link) + } + if _, ok := found[filepath.Join(link, "child")]; ok { + t.Fatal("walkDir followed symlinked directory") + } +} + +func TestWalkDirIgnoresUnreadableSubdir(t *testing.T) { //nolint:paralleltest // runWalkDirTest calls t.Parallel. + runWalkDirTest(t, testWalkDirIgnoresUnreadableSubdir) +} + +func testWalkDirIgnoresUnreadableSubdir(t *testing.T, walk walkDirFunc) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not enforce POSIX directory permission bits") + } + if os.Geteuid() == 0 { + t.Skip("root can read directories regardless of mode bits") + } + + root := newTmpDir(t) + denied := filepath.Join(root, "denied") + if err := os.Mkdir(denied, 0o700); err != nil { + t.Fatal(err) + } + child := filepath.Join(denied, "child") + if err := os.WriteFile(child, []byte("hidden"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(denied, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(denied, 0o700) }) + + found := map[string]bool{} + if err := walk(root, true, func(path string, isDir bool) error { + found[path] = isDir + return nil + }); err != nil { + t.Fatal(err) + } + if _, ok := found[denied]; ok { + t.Fatalf("unreadable directory should be ignored, found %q", denied) + } + if _, ok := found[child]; ok { + t.Fatalf("unreadable child should be ignored, found %q", child) + } +} + +func TestWalkDirMissingDir(t *testing.T) { runWalkDirTest(t, testWalkDirMissingDir) } //nolint:paralleltest // runWalkDirTest calls t.Parallel. +func testWalkDirMissingDir(t *testing.T, walk walkDirFunc) { + dir := filepath.Join(t.TempDir(), "nonexistent") + if err := walk(dir, true, nil); err == nil { + t.Fatal("expected error for missing directory") + } +} + +func TestWalkDirNotADir(t *testing.T) { runWalkDirTest(t, testWalkDirNotADir) } //nolint:paralleltest // runWalkDirTest calls t.Parallel. +func testWalkDirNotADir(t *testing.T, walk walkDirFunc) { + f := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := walk(f, true, nil); err == nil { + t.Fatal("expected error for non-directory") + } +} + +func TestWalkDirEntries(t *testing.T) { runWalkDirTest(t, testWalkDirEntries) } //nolint:paralleltest // runWalkDirTest calls t.Parallel. +func testWalkDirEntries(t *testing.T, walk walkDirFunc) { + root := newTmpDir(t) + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("b"), 0o644); err != nil { + t.Fatal(err) + } + + found := map[string]bool{} + if err := walk(root, true, func(path string, isDir bool) error { + found[path] = isDir + return nil + }); err != nil { + t.Fatal(err) + } + if _, ok := found[filepath.Join(root, "a.txt")]; !ok { + t.Fatal("missing a.txt") + } + if _, ok := found[sub]; !ok { + t.Fatal("missing sub/") + } + if _, ok := found[filepath.Join(sub, "b.txt")]; !ok { + t.Fatal("missing sub/b.txt") + } +} + +func TestWalkDirCallback(t *testing.T) { runWalkDirTest(t, testWalkDirCallback) } //nolint:paralleltest // runWalkDirTest calls t.Parallel. +func testWalkDirCallback(t *testing.T, walk walkDirFunc) { + root := newTmpDir(t) + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "f.txt"), []byte("f"), 0o644); err != nil { + t.Fatal(err) + } + + var dirs, files []string + err := walk(root, true, func(path string, isDir bool) error { + if isDir { + dirs = append(dirs, path) + } else { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(dirs) < 2 { + t.Fatalf("expected at least 2 dirs (root + sub), got %d: %v", len(dirs), dirs) + } + if len(files) < 1 { + t.Fatalf("expected at least 1 file, got %d", len(files)) + } +} + +func TestWalkDirCallbackError(t *testing.T) { runWalkDirTest(t, testWalkDirCallbackError) } //nolint:paralleltest // runWalkDirTest calls t.Parallel. +func testWalkDirCallbackError(t *testing.T, walk walkDirFunc) { + root := newTmpDir(t) + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + + sentinel := errors.New("stop") + err := walk(root, true, func(path string, isDir bool) error { + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) + } +} diff --git a/internal/fswatch/walkdir_unix.go b/internal/fswatch/walkdir_unix.go new file mode 100644 index 00000000000..800bcf468df --- /dev/null +++ b/internal/fswatch/walkdir_unix.go @@ -0,0 +1,145 @@ +//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly + +package fswatch + +import ( + "errors" + "unsafe" + + "golang.org/x/sys/unix" +) + +// walkState carries state shared across the whole walk so we only +// allocate one read buffer per top-level walkDir, not one per directory. +type walkState struct { + buf []byte +} + +// walkDir walks dir, optionally recursively, invoking fn for each entry. +// On Linux/BSDs it uses getdents/getdirentries directly so the d_type +// in each record drives the isDir flag without a stat. +func walkDir(dir string, recursive bool, fn func(path string, isDir bool) error) error { + const openFlags = unix.O_RDONLY | unix.O_CLOEXEC | unix.O_DIRECTORY | + unix.O_NOCTTY | unix.O_NONBLOCK | unix.O_NOFOLLOW + fd, err := unix.Open(dir, openFlags, 0) + if err != nil { + // Fall back to a path-based open when O_DIRECTORY rejects a + // non-directory: walkDir's contract is to return ENOTDIR. + if errors.Is(err, unix.ENOTDIR) { + return unix.ENOTDIR + } + return err + } + defer unix.Close(fd) + + st := &walkState{buf: make([]byte, 8192)} + return iterateDir(st, fd, dir, recursive, fn) +} + +// iterateDir reads fd's entries, invokes fn for the dir and each entry, +// and recurses into subdirectories via openat(fd, name). fd is owned by +// the caller; iterateDir does not close it. Sharing fd as the openat +// anchor for children avoids reopening the parent path once for the +// listing and again for each child. +func iterateDir(st *walkState, fd int, dirname string, recursive bool, fn func(path string, isDir bool) error) error { + if fn != nil { + if err := fn(dirname, true); err != nil { + return err + } + } + entries, err := readDirEntries(fd, st.buf) + if err != nil { + return err + } + + const childOpenFlags = unix.O_RDONLY | unix.O_CLOEXEC | unix.O_DIRECTORY | + unix.O_NOCTTY | unix.O_NONBLOCK | unix.O_NOFOLLOW + for _, ent := range entries { + fullPath := dirname + "/" + ent.name + isDir := ent.typ == unix.DT_DIR + if ent.typ == unix.DT_UNKNOWN { + var attrib unix.Stat_t + if err := unix.Lstat(fullPath, &attrib); err != nil { + continue + } + isDir = (attrib.Mode & unix.S_IFMT) == unix.S_IFDIR + } + if !isDir { + if fn != nil { + if err := fn(fullPath, false); err != nil { + return err + } + } + continue + } + if !recursive { + if fn != nil { + if err := fn(fullPath, true); err != nil { + return err + } + } + continue + } + childFD, err := unix.Openat(fd, ent.name, childOpenFlags, 0) + if err != nil { + if errors.Is(err, unix.EACCES) || errors.Is(err, unix.ENOTDIR) || errors.Is(err, unix.ENOENT) { + continue + } + return err + } + err = iterateDir(st, childFD, fullPath, recursive, fn) + unix.Close(childFD) + if err != nil { + return err + } + } + return nil +} + +type unixDirent struct { + name string + typ uint8 +} + +// readDirEntries reads every entry on fd via getdents/getdirentries, +// extracting d_type so callers can skip per-entry lstat on filesystems +// that support it. The supplied buf is reused for every getdents +// syscall in the loop and may be reused across calls. +func readDirEntries(fd int, buf []byte) ([]unixDirent, error) { + var entries []unixDirent + for { + n, err := unix.ReadDirent(fd, buf) + if err != nil { + return nil, err + } + if n <= 0 { + break + } + data := buf[:n] + for len(data) > 0 { + dirent := (*unix.Dirent)(unsafe.Pointer(&data[0])) + reclen := reclenOf(dirent) + if reclen == 0 || int(reclen) > len(data) { + break + } + if inoOf(dirent) == 0 { + data = data[reclen:] + continue + } + nameOff := unsafe.Offsetof(dirent.Name) + nameBytes := data[nameOff:reclen] + for i, b := range nameBytes { + if b == 0 { + nameBytes = nameBytes[:i] + break + } + } + name := string(nameBytes) + if name != "." && name != ".." { + entries = append(entries, unixDirent{name: name, typ: dirent.Type}) + } + data = data[reclen:] + } + } + return entries, nil +} diff --git a/internal/fswatch/walkdir_windows.go b/internal/fswatch/walkdir_windows.go new file mode 100644 index 00000000000..3e8d0d9a798 --- /dev/null +++ b/internal/fswatch/walkdir_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package fswatch + +import ( + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// walkDir walks a directory tree on Windows using FindFirstFile/FindNextFile. +func walkDir(dir string, recursive bool, fn func(path string, isDir bool) error) error { + rootPtr, err := windows.UTF16PtrFromString(dir) + if err != nil { + return err + } + var rootData windows.Win32FileAttributeData + if err := windows.GetFileAttributesEx(rootPtr, windows.GetFileExInfoStandard, (*byte)(unsafe.Pointer(&rootData))); err != nil { + return fmt.Errorf("error opening directory: %w", err) + } + if rootData.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return syscall.ENOTDIR + } + if fn != nil { + if err := fn(dir, true); err != nil { + return err + } + } + + stack := []string{dir} + for len(stack) > 0 { + path := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + spec := path + "\\*" + specPtr, err := windows.UTF16PtrFromString(spec) + if err != nil { + return err + } + var ffd windows.Win32finddata + hFind, err := windows.FindFirstFile(specPtr, &ffd) + if err != nil { + if path == dir { + return fmt.Errorf("error opening directory: %w", err) + } + continue + } + for { + name := windows.UTF16ToString(ffd.FileName[:]) + if name != "." && name != ".." { + fullPath := path + "\\" + name + isDir := ffd.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 && + ffd.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT == 0 + if fn != nil { + if err := fn(fullPath, isDir); err != nil { + windows.FindClose(hFind) + return err + } + } + if isDir && recursive { + stack = append(stack, fullPath) + } + } + if err := windows.FindNextFile(hFind, &ffd); err != nil { + break + } + } + windows.FindClose(hFind) + } + return nil +} diff --git a/internal/fswatch/watcher.go b/internal/fswatch/watcher.go new file mode 100644 index 00000000000..f1510583aa3 --- /dev/null +++ b/internal/fswatch/watcher.go @@ -0,0 +1,641 @@ +package fswatch + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" +) + +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 + // 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. + // Returns [ErrUnavailable] if the watcher is not supported on + // the current platform. + WatchDirectory(dir string, fn WatchCallback, opts ...WatchOption) (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) +} + +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 +} + +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() {} + +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) getOrCreateDirWatch(dir 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() + } + key := dir + if recursive { + key = dir + "\x00recursive" + } + if dw, ok := w.dirWatches[key]; ok { + return dw + } + dw := newDirWatch(dir, w.debounce) + dw.recursive = recursive + w.dirWatches[key] = dw + return dw +} + +func (w *watcher) removeDirWatch(dw *dirWatch) { + w.mu.Lock() + defer w.mu.Unlock() + key := dw.dir + if dw.recursive { + key = dw.dir + "\x00recursive" + } + 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) { + if fn == nil { + return nil, errNilCallback + } + if !w.Available() { + return nil, ErrUnavailable + } + dir = filepath.Clean(dir) + if !filepath.IsAbs(dir) { + return nil, errNotAbsolute + } + dir = canonicalizePath(dir) + + var sopts watchOptions + for _, o := range opts { + o.applyWatchOption(&sopts) + } + + dw := w.getOrCreateDirWatch(dir, sopts.recursive) + id, _ := dw.watch(fn, sopts.ignore) + + impl, err := w.getImpl() + if err != nil { + dw.unwatch(id) + dw.unref(w) + return nil, err + } + if err := impl.watchAdd(dw); err != nil { + dw.unwatch(id) + dw.unref(w) + return nil, err + } + return &watch{w: w, dw: dw, impl: impl, id: id}, 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 + 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 { + b.mu.Lock() + if _, ok := b.subscriptions[w]; ok { + b.mu.Unlock() + return nil + } + if err := b.self.subscribe(w); err != nil { + b.mu.Unlock() + return err + } + b.subscriptions[w] = struct{}{} + 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 + fn WatchCallback + ignore func(path string) 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 string + recursive bool + events eventList + + // state stores per-directory platform-specific bookkeeping (fsevents, windows). + state any + + mu sync.Mutex + callbacks []callback + debounce *debounce + nextCBID uint64 +} + +func newDirWatch(dir string, db *debounce) *dirWatch { + dw := &dirWatch{dir: dir} + dw.debounce = db + dw.debounce.add(dw, func() { dw.triggerCallbacks() }) + return dw +} + +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() + hasCBs := len(dw.callbacks) > 0 + hasEvents := dw.events.size() > 0 + hasError := dw.events.hasError() + db := dw.debounce + dw.mu.Unlock() + + if hasCBs && (hasEvents || hasError) && 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 + if len(dw.callbacks) == 0 || (!hasEvents && !hasError) { + dw.mu.Unlock() + return + } + events, err := dw.events.drain() + cbs := slices.Clone(dw.callbacks) + recursive := dw.recursive + dw.mu.Unlock() + + for _, cb := range cbs { + cbEvents := events + if cb.ignore != nil || !recursive { + filtered := make([]Event, 0, len(events)) + for _, e := range events { + if cb.ignore != nil && cb.ignore(e.Path) { + continue + } + if !recursive && !isDirectChild(dw.dir, e.Path) { + continue + } + filtered = append(filtered, e) + } + cbEvents = filtered + } + if len(cbEvents) > 0 || err != nil { + cb.fn(cbEvents, err) + } + } +} + +// 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(fn WatchCallback, ignore func(path string) bool) (uint64, bool) { + dw.mu.Lock() + defer dw.mu.Unlock() + dw.nextCBID++ + id := dw.nextCBID + dw.callbacks = append(dw.callbacks, callback{id: id, fn: fn, ignore: ignore}) + 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) + } +} diff --git a/internal/fswatch/watcher_test.go b/internal/fswatch/watcher_test.go new file mode 100644 index 00000000000..7fe53c65e74 --- /dev/null +++ b/internal/fswatch/watcher_test.go @@ -0,0 +1,2442 @@ +// Watcher tests: CRUD events for files, directories, sub-entries, and +// symlinks; event coalescing; multiple subscriptions; error handling; +// watch lifecycle; public API validation; and watcherBase/ +// dirWatchError internals. Each test runs against every watcher available +// on the host OS unless it exercises internal types directly. + +package fswatch + +import ( + "cmp" + "errors" + "fmt" + "math/rand" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// ----- helpers ----------------------------------------------------------- + +// defaultEventTimeout is the per-`next` wait used by subscribe tests for +// the fast/responsive backends (inotify, fanotify, Windows). Scales up +// on retry via [watcherEventTimeout] so the fast path is cheap. +func defaultEventTimeout() time.Duration { + return 1 * time.Second +} + +// kqueueFSEventsTimeout is the per-event deadline for the kqueue and +// fsevents backends. Those have materially higher kernel-to-userspace +// latency than inotify/fanotify/Windows: kqueue uses directory +// NOTE_WRITE + compareDir which takes a scheduling round-trip per +// change, and fsevents introduces its own batching on top of the GCD +// dispatch queue. Scales up on retry via [watcherEventTimeout]. +func kqueueFSEventsTimeout() time.Duration { + return 2 * time.Second +} + +// watcherEventTimeout returns the appropriate per-event deadline for +// the backend under test, scaled by the current [testingT]'s retry attempt +// number. The fast-path uses the base timeout (1-2 seconds); retries +// scale up so a single environmental hiccup gets a longer wait without +// inflating every passing run's wall-clock. +func watcherEventTimeout(t testingT, w Watcher) time.Duration { + base := defaultEventTimeout() + if w == FSEvents() || w == Kqueue() { + base = kqueueFSEventsTimeout() + } + scale := 1 + if rt, ok := t.(*retryT); ok { + scale = retryTimeoutScale(rt.attempt) + } + return base * time.Duration(scale) +} + +// availableWatchers is populated at init time from whichever backends +// are available on the current platform, plus any test-only watcher +// variants registered in additionalTestWatchers (see e.g. +// fanotify_linux_test.go). +var availableWatchers []Watcher + +// additionalTestWatchers is appended to by platform-specific *_test.go +// init() functions to register test-only watcher variants (e.g. the +// fanotify-no-rename backend that exercises the FAN_MOVED_FROM/_TO +// fallback path). Producers' init() must run before this file's init(); +// since Go runs file inits in lexicographic file-name order and this +// file is watcher_test.go, that ordering is satisfied for every other +// *_test.go file in the package. +var additionalTestWatchers []Watcher + +func init() { + for _, b := range AllWatchers() { + if b.Available() { + availableWatchers = append(availableWatchers, b) + } + } + for _, b := range additionalTestWatchers { + if b.Available() { + availableWatchers = append(availableWatchers, b) + } + } +} + +// runForEachWatcher runs fn as a subtest for every available watcher. +// +// The per-backend test body receives a [testingT] (a subset of *testing.T) +// rather than the real *testing.T. This lets [runWithRetry] re-run a +// body that fails due to environmental flakes (macOS event-delivery +// stalls under load) before propagating the failure to the real test +// runner. +func runForEachWatcher(t *testing.T, fn func(t testingT, watcherImpl Watcher)) { + t.Helper() + for _, b := range availableWatchers { + t.Run(b.Name(), func(t *testing.T) { + t.Parallel() + runWithRetry(t, func(rt testingT) { + fn(rt, b) + }) + }) + } +} + +// newTmpDir creates a fresh temp dir, resolves any symlinks in the path so +// it matches what backends report, and registers cleanup. +func newTmpDir(t testingT) string { + t.Helper() + d := t.TempDir() + resolved, err := filepath.EvalSymlinks(d) + if err != nil { + t.Fatal(err) + } + return resolved +} + +// nameCounter generates unique file names per test to avoid collisions. +var nameCounter atomic.Uint64 + +func uniqueName(parts ...string) string { + n := nameCounter.Add(1) + suffix := fmt.Sprintf("test%d%d", n, rand.Int63()) + return filepath.Join(append(parts, suffix)...) +} + +// subPath produces a unique name in dir. +func subPath(dir string) string { + return uniqueName(dir) +} + +// newDirectWatcher creates a bare dirWatch for unit-testing tree/debounce +// helpers without going through the full backend subscribe path. Each +// test gets its own debouncer so tests don't share goroutine state. +func newDirectWatcher(t testingT, dir string) *dirWatch { + t.Helper() + w := newDirWatch(dir, newDebounce()) + w.recursive = true + t.Cleanup(func() { w.destroyDebounce() }) + return w +} + +// subscribeFor sets up a recorder + WatchDirectory and registers cleanup. +func subscribeFor(t testingT, dir string, watcherImpl Watcher) (*recordingWatcher, Watch) { + return subscribeForOpts(t, dir, watcherImpl, WithRecursive()) +} + +// settleSleep is the post-subscribe settle wait. Empirically tuned per +// backend: fsevents and kqueue need a couple of hundred ms to actually +// arm their watches on the freshly-created tmp dir, while inotify/ +// fanotify/Windows are essentially synchronous. +func settleSleep(w Watcher) time.Duration { + if w == FSEvents() || w == Kqueue() { + return 300 * time.Millisecond + } + return 60 * time.Millisecond +} + +// preSubscribeSleep gives the macOS fsevents stream timestamp enough +// distance from any tmp-dir creation just before subscribe; without +// it the initial event batch may include the watched dir's own create. +func preSubscribeSleep(w Watcher) time.Duration { + if w == FSEvents() || w == Kqueue() { + return 50 * time.Millisecond + } + return 0 +} + +// subscribeFileFor sets up a recorder + WatchFile and registers cleanup. +func subscribeFileFor(t testingT, path string, watcherImpl Watcher) (*recordingWatcher, Watch) { + t.Helper() + if d := preSubscribeSleep(watcherImpl); d > 0 { + time.Sleep(d) + } + r := newRecorder(t) + r.watcher = watcherImpl + sub, err := watcherImpl.WatchFile(path, r.callback) + if err != nil { + t.Fatalf("subscribeFile: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + time.Sleep(settleSleep(watcherImpl)) + return r, sub +} + +// subscribeForOpts sets up a recorder + WatchDirectory with options and registers cleanup. +func subscribeForOpts(t testingT, dir string, watcherImpl Watcher, opts ...WatchOption) (*recordingWatcher, Watch) { + t.Helper() + if d := preSubscribeSleep(watcherImpl); d > 0 { + time.Sleep(d) + } + r := newRecorder(t) + r.watcher = watcherImpl + sub, err := watcherImpl.WatchDirectory(dir, r.callback, opts...) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + time.Sleep(settleSleep(watcherImpl)) + return r, sub +} + +// ----- recordingWatcher -------------------------------------------------- + +type recordingWatcher struct { + t testingT + watcher Watcher // bound at subscribe time so expect* helpers can choose timeouts + mu sync.Mutex + cond *sync.Cond + buf []Event + errs []error +} + +func newRecorder(t testingT) *recordingWatcher { + r := &recordingWatcher{t: t} + r.cond = sync.NewCond(&r.mu) + return r +} + +// deadline returns the per-event timeout appropriate for the recorder's +// bound watcher backend, or the default if no watcher was attached. +// The returned duration scales with the current retry attempt when the +// recorder is bound to a [retryT]. +func (r *recordingWatcher) deadline() time.Duration { + if r.watcher == nil { + return scaledDeadline(r.t, defaultEventTimeout()) + } + return watcherEventTimeout(r.t, r.watcher) +} + +// scaledDeadline multiplies base by the retry scale for t (if t is a +// retryT), so per-event timeouts grow on retries without inflating the +// fast path. +func scaledDeadline(t testingT, base time.Duration) time.Duration { + if rt, ok := t.(*retryT); ok { + return base * time.Duration(retryTimeoutScale(rt.attempt)) + } + return base +} + +func (r *recordingWatcher) callback(events []Event, err error) { + r.mu.Lock() + defer r.mu.Unlock() + if err != nil { + r.errs = append(r.errs, err) + } + r.buf = append(r.buf, events...) + r.cond.Broadcast() +} + +// next blocks for up to d for at least one event, then drains and returns +// everything that has accumulated. +func (r *recordingWatcher) next(d time.Duration) []Event { + r.t.Helper() + deadline := time.Now().Add(d) + r.mu.Lock() + defer r.mu.Unlock() + for len(r.buf) == 0 { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil + } + stopper := time.AfterFunc(remaining, func() { + r.mu.Lock() + r.cond.Broadcast() + r.mu.Unlock() + }) + r.cond.Wait() + stopper.Stop() + } + out := slices.Clone(r.buf) + r.buf = nil + return out +} + +// drainQuiet drains any buffered events, then waits at most d to make sure +// no further events arrive. Returns whatever shows up. +func (r *recordingWatcher) drainQuiet(d time.Duration) []Event { + r.t.Helper() + r.mu.Lock() + r.buf = nil + r.mu.Unlock() + time.Sleep(d) + r.mu.Lock() + out := slices.Clone(r.buf) + r.buf = nil + r.mu.Unlock() + return out +} + +// gather waits up to `wait` for at least one event, then settles for +// `settle` to give the rest of the debounced batch a chance to arrive. +func (r *recordingWatcher) gather(wait, settle time.Duration) []Event { + first := r.next(wait) + if len(first) == 0 { + return nil + } + time.Sleep(settle) + r.mu.Lock() + defer r.mu.Unlock() + more := slices.Clone(r.buf) + r.buf = nil + return append(first, more...) +} + +// gatherUntilQuiet collects events until either the initial wait expires +// without seeing one, or the recorder has gone quiet for `quiet`. Useful +// for assertions that need to observe events possibly spread across +// multiple debounce batches (e.g. rapid-coalescing tests). +func (r *recordingWatcher) gatherUntilQuiet(initialWait, quiet time.Duration) []Event { + first := r.next(initialWait) + if len(first) == 0 { + return nil + } + all := first + for { + more := r.next(quiet) + if len(more) == 0 { + return all + } + all = append(all, more...) + } +} + +// waitForEvent blocks until an event matching pred is observed in the +// recorder's accumulating buffer, or until deadline elapses. Returns all +// events collected up to the success / timeout (and drains them from the +// buffer). Useful for tests where the kernel backend takes a variable +// amount of time to install/propagate a fresh watch, instead of betting +// on a fixed sleep that breaks under host CPU/IO contention. +func (r *recordingWatcher) waitForEvent(d time.Duration, pred func(Event) bool) []Event { + r.t.Helper() + deadline := time.Now().Add(d) + for { + r.mu.Lock() + if slices.ContainsFunc(r.buf, pred) { + out := slices.Clone(r.buf) + r.buf = nil + r.mu.Unlock() + return out + } + remaining := time.Until(deadline) + if remaining <= 0 { + out := slices.Clone(r.buf) + r.buf = nil + r.mu.Unlock() + return out + } + stopper := time.AfterFunc(remaining, func() { + r.mu.Lock() + r.cond.Broadcast() + r.mu.Unlock() + }) + r.cond.Wait() + stopper.Stop() + r.mu.Unlock() + } +} + +// waitForAll polls the recorder's buffer until every event in want is +// observed (paths matched, kind matched) or d elapses. Returns the full +// accumulated set drained from the buffer either way. Extra events +// outside of want are kept in the returned slice but do not count +// against the deadline. +// +// Use this instead of one-shot r.next() in any test where the kernel +// might split events across multiple debounce batches or take a moment +// to install a watch on a freshly created dir. The retry behavior makes +// the test robust to host CPU/IO contention. +func (r *recordingWatcher) waitForAll(d time.Duration, want []wantEvent) []Event { + r.t.Helper() + if len(want) == 0 { + return nil + } + deadline := time.Now().Add(d) + collected := make([]Event, 0) + for { + r.mu.Lock() + collected = append(collected, r.buf...) + r.buf = nil + r.mu.Unlock() + if haveAll(collected, want) { + return collected + } + remaining := time.Until(deadline) + if remaining <= 0 { + return collected + } + r.mu.Lock() + if len(r.buf) > 0 { + r.mu.Unlock() + continue + } + stopper := time.AfterFunc(remaining, func() { + r.mu.Lock() + r.cond.Broadcast() + r.mu.Unlock() + }) + r.cond.Wait() + stopper.Stop() + r.mu.Unlock() + } +} + +// haveAll reports whether every event in want is matched at least once +// in got. Extra got events are ignored. +func haveAll(got []Event, want []wantEvent) bool { + for _, w := range want { + found := false + for _, e := range got { + if e.Kind == w.Kind && e.Path == w.Path { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// expectEventSet polls until every wanted event has arrived (or the +// scaled deadline elapses) and then asserts the matching set +// (ignoring order). Use everywhere the test had next/gather followed +// by assertEventSet; it removes the timing assumption that the events +// land in one debounce batch. +func expectEventSet(t testingT, r *recordingWatcher, want []wantEvent) []Event { + t.Helper() + got := r.waitForAll(r.deadline(), want) + assertEventSet(t, got, want) + return got +} + +// expectEventSequence polls until every wanted event has arrived, then +// asserts they appear in the exact specified order (filtered to +// wanted paths). Order-sensitive callers that previously used +// assertEventSequence on a one-shot next/gather. +func expectEventSequence(t testingT, r *recordingWatcher, want []wantEvent) []Event { + t.Helper() + got := r.waitForAll(r.deadline(), want) + assertEventSequence(t, got, want) + return got +} + +// expectContains polls until any event matching kind+path arrives, then +// returns the accumulated event slice. Use for tests that don't care +// about a specific set of events but want to verify at least one +// specific event surfaced. +func expectContains(t testingT, r *recordingWatcher, kind EventKind, path string) []Event { + t.Helper() + d := r.deadline() + got := r.waitForEvent(d, func(e Event) bool { + return e.Kind == kind && e.Path == path + }) + if !containsEvent(got, kind, path) { + t.Fatalf("expected event %s %s within %s, got %v", kind, path, d, toWantEvents(got)) + } + return got +} + +func expectNoBufferedEvents(t testingT, r *recordingWatcher, msg string) { + t.Helper() + r.mu.Lock() + got := slices.Clone(r.buf) + r.buf = nil + r.mu.Unlock() + if len(got) > 0 { + t.Fatalf("%s, got %v", msg, toWantEvents(got)) + } +} + +func assertNoEventsForPath(t testingT, got []Event, path, msg string) { + t.Helper() + got = filterEventsForPaths(got, path) + if len(got) > 0 { + t.Fatalf("%s %s, got %v", msg, path, toWantEvents(got)) + } +} + +// ----- assertion helpers ------------------------------------------------- + +type wantEvent struct { + Kind EventKind + Path string +} + +func toWantEvents(events []Event) []wantEvent { + out := make([]wantEvent, len(events)) + for i, e := range events { + out[i] = wantEvent(e) + } + return out +} + +// assertEventSet compares two event sets ignoring order. +// Events for paths not in want are ignored (e.g. parent-dir update noise). +func assertEventSet(t testingT, got []Event, want []wantEvent) { + t.Helper() + got = filterToWantedPaths(got, want) + gotW := toWantEvents(got) + cmpEvents := func(a, b wantEvent) int { + if a.Kind != b.Kind { + return cmp.Compare(a.Kind, b.Kind) + } + return cmp.Compare(a.Path, b.Path) + } + slices.SortFunc(gotW, cmpEvents) + slices.SortFunc(want, cmpEvents) + if !equalWantEvents(gotW, want) { + t.Fatalf("event mismatch\nwant: %v\n got: %v", want, gotW) + } +} + +// assertEventSequence is like assertEventSet but order-sensitive. +// Events for paths not in want are ignored (e.g. parent-dir update noise). +func assertEventSequence(t testingT, got []Event, want []wantEvent) { + t.Helper() + got = filterToWantedPaths(got, want) + gotW := toWantEvents(got) + if !equalWantEvents(gotW, want) { + t.Fatalf("event sequence mismatch\nwant: %v\n got: %v", want, gotW) + } +} + +// filterToWantedPaths returns only events whose path appears in want. +func filterToWantedPaths(got []Event, want []wantEvent) []Event { + paths := make(map[string]struct{}, len(want)) + for _, w := range want { + paths[w.Path] = struct{}{} + } + filtered := make([]Event, 0, len(got)) + for _, e := range got { + if _, ok := paths[e.Path]; ok { + filtered = append(filtered, e) + } + } + return filtered +} + +func equalWantEvents(a, b []wantEvent) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// containsEvent reports whether got contains an event with the given type+path. +func containsEvent(got []Event, typ EventKind, path string) bool { + for _, e := range got { + if e.Kind == typ && e.Path == path { + return true + } + } + return false +} + +// filterEventsForPaths returns only the events whose Path is in the +// allowed set. Used to discard incidental dir-update events that some +// backends emit for the parent dir of a touched file. +func filterEventsForPaths(events []Event, paths ...string) []Event { + allow := make(map[string]struct{}, len(paths)) + for _, p := range paths { + allow[p] = struct{}{} + } + out := make([]Event, 0, len(events)) + for _, e := range events { + if _, ok := allow[e.Path]; ok { + out = append(out, e) + } + } + return out +} + +// replayEventList re-applies a sequence of events through a fresh +// eventList and returns the coalesced result. This is what the directory watch +// would have produced if every event had landed in the same debounce +// batch; useful for assertions that must be tolerant to batch splitting. +func replayEventList(events []Event) []Event { + var el eventList + for _, e := range events { + switch e.Kind { + case EventUpdate: + el.update(e.Path) + case EventDelete: + el.remove(e.Path) + } + } + return el.getEvents() +} + +// ----- files ------------------------------------------------------------- + +func TestWatchFileCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + + f := subPath(dir) + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestWatchFileUpdate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(dir) + // Mirror upstream JS: create file AFTER subscribe so the create + // event populates the watcherImpl's internal tree, then update it so + // the subsequent modify event is correctly classified as update. + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.waitForEvent(r.deadline(), func(Event) bool { return true }) // consume the create event + if err := os.WriteFile(f, []byte("v2-longer"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestWatchFileRename(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f1 := subPath(dir) + f2 := subPath(dir) + if err := os.WriteFile(f1, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.Rename(f1, f2); err != nil { + t.Fatal(err) + } + expectEventSet(t, r, []wantEvent{ + {EventDelete, f1}, + {EventUpdate, f2}, + }) + }) +} + +func TestWatchFileRenameExisting(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + // Existing file present at subscribe time. + f1 := subPath(dir) + if err := os.WriteFile(f1, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + f2 := subPath(dir) + if err := os.Rename(f1, f2); err != nil { + t.Fatal(err) + } + expectEventSet(t, r, []wantEvent{ + {EventDelete, f1}, + {EventUpdate, f2}, + }) + }) +} + +func TestWatchFileDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := subPath(dir) + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, f}}) + }) +} + +// ----- directories ------------------------------------------------------- + +func TestSubscribeDirCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(dir) + if err := os.Mkdir(f, 0o755); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +// TestSubscribeNonASCIIPath checks that every backend round-trips a +// non-ASCII path byte-for-byte: subscribe to a directory whose name +// contains precomposed (NFC) Unicode, create a child with non-ASCII +// bytes in its name, and assert the event's Path equals what we would +// have produced with filepath.Join. Guards against any backend (or the +// shared event path) silently mutating the bytes. +func TestSubscribeNonASCIIPath(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + parent := newTmpDir(t) + // "café" + "résumé"; both precomposed NFC. + dir := filepath.Join(parent, "caf\u00e9-dir") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + child := filepath.Join(dir, "r\u00e9sum\u00e9.txt") + if err := os.WriteFile(child, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, child}}) + }) +} + +func TestSubscribeDirRename(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f1 := subPath(dir) + if err := os.Mkdir(f1, 0o755); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + f2 := subPath(dir) + if err := os.Rename(f1, f2); err != nil { + t.Fatal(err) + } + expectEventSet(t, r, []wantEvent{ + {EventDelete, f1}, + {EventUpdate, f2}, + }) + }) +} + +func TestSubscribeDirDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := subPath(dir) + if err := os.Mkdir(f, 0o755); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.RemoveAll(f); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, f}}) + }) +} + +func TestSubscribeWatchedDirDeleted(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, dir}}) + + // Give the backend a moment to surface ErrWatchTerminated alongside + // the delete; some backends batch the error into a later debounce + // tick than the event itself. + deadline := time.Now().Add(r.deadline()) + for time.Now().Before(deadline) { + r.mu.Lock() + n := len(r.errs) + r.mu.Unlock() + if n > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + r.mu.Lock() + errs := slices.Clone(r.errs) + r.errs = nil + r.mu.Unlock() + sawTerminated := false + for _, e := range errs { + if errors.Is(e, ErrWatchTerminated) { + sawTerminated = true + break + } + } + if !sawTerminated { + t.Fatalf("expected ErrWatchTerminated after watched dir delete, got errs=%v", errs) + } + + // Re-create; should not emit events for a now-stale watch. + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + extra := r.drainQuiet(200 * time.Millisecond) + if len(extra) != 0 { + t.Fatalf("expected no follow-up events, got %v", extra) + } + }) +} + +// ----- sub-files --------------------------------------------------------- + +func TestSubscribeSubfileCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, sub) + // Wait for the inotify watcherImpl to finish setting up the watch on + // the new dir before mutating it. + time.Sleep(100 * time.Millisecond) + + f := subPath(sub) + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeSubfileUpdate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(sub) + // WatchDirectory-then-create so the create event populates the + // watcherImpl's tree before the modify arrives. + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.waitForEvent(r.deadline(), func(Event) bool { return true }) + if err := os.WriteFile(f, []byte("v2-longer"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, f) + }) +} + +func TestSubscribeSubfileRename(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + f1 := subPath(sub) + if err := os.WriteFile(f1, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + f2 := subPath(sub) + if err := os.Rename(f1, f2); err != nil { + t.Fatal(err) + } + // Wait for both events to arrive before checking the set. + want := []wantEvent{{EventDelete, f1}, {EventUpdate, f2}} + got := r.waitForAll(r.deadline(), want) + filtered := filterEventsForPaths(got, f1, f2) + assertEventSet(t, filtered, want) + }) +} + +func TestSubscribeSubfileDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + f := subPath(sub) + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + want := []wantEvent{{EventDelete, f}} + got := r.waitForAll(r.deadline(), want) + filtered := filterEventsForPaths(got, f) + assertEventSequence(t, filtered, want) + }) +} + +// ----- sub-directories --------------------------------------------------- + +func TestSubscribeSubdirCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + nested := subPath(sub) + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + want := []wantEvent{{EventUpdate, nested}} + got := r.waitForAll(r.deadline(), want) + filtered := filterEventsForPaths(got, nested) + assertEventSequence(t, filtered, want) + }) +} + +func TestSubscribeSubdirDeleteWithFiles(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + subDir := subPath(dir) + if err := os.Mkdir(subDir, 0o755); err != nil { + t.Fatal(err) + } + child := subPath(subDir) + if err := os.WriteFile(child, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.RemoveAll(subDir); err != nil { + t.Fatal(err) + } + expectEventSet(t, r, []wantEvent{ + {EventDelete, subDir}, + {EventDelete, child}, + }) + }) +} + +// ----- symlinks ---------------------------------------------------------- + +func TestSubscribeSymlinkCreate(t *testing.T) { + t.Parallel() + if runtime.GOOS == "dragonfly" { + t.Skip("DragonFlyBSD kqueue doesn't fire NOTE_WRITE on symlink creation") + } + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f1 := subPath(dir) + if err := os.WriteFile(f1, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + f2 := subPath(dir) + if err := os.Symlink(f1, f2); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f2}}) + }) +} + +func TestSubscribeSymlinkDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f1 := subPath(dir) + f2 := subPath(dir) + if err := os.WriteFile(f1, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(f1, f2); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + if err := os.Remove(f2); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, f2}}) + }) +} + +// ----- event coalescing -------------------------------------------------- + +func TestSubscribeCoalesceCreateUpdate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(dir) + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f, []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + // The two writes should net to one update. Under host load the + // debounce may split them across batches, so check the coalesced + // effect via replayEventList rather than insisting on a single + // delivered event. + got := r.gatherUntilQuiet(r.deadline(), 3*maxWaitTime) + net := replayEventList(filterEventsForPaths(got, f)) + assertEventSet(t, net, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeCoalesceDeleteCreateAsUpdate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(dir) + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.waitForEvent(r.deadline(), func(Event) bool { return true }) + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f, []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + // Net: delete+create coalesces to update. + got := r.gatherUntilQuiet(r.deadline(), 3*maxWaitTime) + net := replayEventList(filterEventsForPaths(got, f)) + assertEventSet(t, net, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeCoalesceCreateThenDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f1 := subPath(dir) + f2 := subPath(dir) + if err := os.WriteFile(f1, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f2, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(f2); err != nil { + t.Fatal(err) + } + // Whether all three operations land in one debounce batch (perfect + // coalescing → just [update f1]) or split across batches (we may + // see [update f1] + [update f2] + [delete f2]) depends on kernel + // timing. Either is correct as long as the *net effect*, + // replaying the events through eventList, leaves only [update f1]. + // Quiet window must exceed the debouncer's maxWaitTime so a delayed + // follow-up batch doesn't get cut off by the gatherUntilQuiet timer. + // Use 3× maxWaitTime to leave headroom for -race overhead. + got := r.gatherUntilQuiet(r.deadline(), 3*maxWaitTime) + net := replayEventList(got) + assertEventSet(t, net, []wantEvent{{EventUpdate, f1}}) + }) +} + +func TestSubscribeCoalesceMultipleUpdates(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(dir) + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.waitForEvent(r.deadline(), func(Event) bool { return true }) // consume initial update + for _, v := range []string{"v2", "v3", "v4"} { + if err := os.WriteFile(f, []byte(v), 0o644); err != nil { + t.Fatal(err) + } + } + got := r.gatherUntilQuiet(r.deadline(), 3*maxWaitTime) + net := replayEventList(filterEventsForPaths(got, f)) + assertEventSet(t, net, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeCoalesceUpdateDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + f := subPath(dir) + // Upstream's debouncer (by design) fires the first event in a quiet + // window immediately. To exercise the coalescing path, we create + // the file post-subscribe and consume that initial event so the + // debouncer's lastTime is recent before the update+delete pair. + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.waitForEvent(r.deadline(), func(Event) bool { return true }) + if err := os.WriteFile(f, []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + got := r.gatherUntilQuiet(r.deadline(), 3*maxWaitTime) + net := replayEventList(filterEventsForPaths(got, f)) + assertEventSet(t, net, []wantEvent{{EventDelete, f}}) + }) +} + +// ----- multiple subscriptions -------------------------------------------- + +func TestSubscribeMultipleSameDir(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + // Let fseventsd register the freshly-created tmpDir before we + // subscribe; otherwise the dir's own creation can appear in the + // initial event batch on macOS. + time.Sleep(50 * time.Millisecond) + + r1 := newRecorder(t) + s1, err := watcherImpl.WatchDirectory(dir, r1.callback) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s1.Close() }) + + r2 := newRecorder(t) + s2, err := watcherImpl.WatchDirectory(dir, r2.callback) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s2.Close() }) + + time.Sleep(100 * time.Millisecond) + f := subPath(dir) + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + assertEventSequence(t, r1.next(r1.deadline()), []wantEvent{{EventUpdate, f}}) + assertEventSequence(t, r2.next(r2.deadline()), []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeMultipleDifferentDirs(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir1 := newTmpDir(t) + dir2 := newTmpDir(t) + + r1, _ := subscribeFor(t, dir1, watcherImpl) + r2, _ := subscribeFor(t, dir2, watcherImpl) + + f1 := subPath(dir1) + f2 := subPath(dir2) + if err := os.WriteFile(f1, []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f2, []byte("b"), 0o644); err != nil { + t.Fatal(err) + } + assertEventSequence(t, r1.next(r1.deadline()), []wantEvent{{EventUpdate, f1}}) + assertEventSequence(t, r2.next(r2.deadline()), []wantEvent{{EventUpdate, f2}}) + }) +} + +// ----- errors ------------------------------------------------------------ + +func TestSubscribeMissingDirError(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + bogus := filepath.Join(newTmpDir(t), "definitely-not-here") + _, err := watcherImpl.WatchDirectory(bogus, func([]Event, error) {}) + if err == nil { + t.Fatal("expected error subscribing to non-existent dir") + } + }) +} + +func TestSubscribeNotADirError(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := subPath(dir) + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, err := watcherImpl.WatchDirectory(f, func([]Event, error) {}) + if err == nil { + t.Fatal("expected error subscribing to a file") + } + }) +} + +func TestSubscribeRejectsNilCallback(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + if _, err := watcherImpl.WatchDirectory(t.TempDir(), nil); err == nil { + t.Fatal("WatchDirectory(nil callback) should return an error") + } + }) +} + +func TestSubscribeRejectsRelativePath(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + _, err := watcherImpl.WatchDirectory("relative/path", func([]Event, error) {}) + if err == nil { + t.Fatal("WatchDirectory with relative path should return an error") + } + _, err = watcherImpl.WatchFile("relative/path/file.txt", func([]Event, error) {}) + if err == nil { + t.Fatal("WatchFile with relative path should return an error") + } + }) +} + +// ----- watch lifecycle -------------------------------------------- + +func TestSubscribeUnsubscribeIdempotent(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r := newRecorder(t) + sub, err := watcherImpl.WatchDirectory(dir, r.callback) + if err != nil { + t.Fatal(err) + } + if err := sub.Close(); err != nil { + t.Fatal(err) + } + if err := sub.Close(); err != nil { + t.Fatalf("second Close should be a no-op, got %v", err) + } + }) +} + +// TestSubscribeCloseThenReSubscribe verifies that Close fully tears down +// any kernel-side resources before returning, so a follow-on subscribe +// on the same path immediately afterwards observes events from a fresh +// watch instead of getting stuck on a stale handle / fd / mark. +// +// Before Q7's fix on Windows, Close returned while the per-watch +// goroutine was still completing GetOverlappedResult and the directory +// handle remained open. A test (or a real program) racing to delete the +// watched dir or to install a different watcher could see flakes. +func TestSubscribeCloseThenReSubscribe(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + + r1 := newRecorder(t) + s1, err := watcherImpl.WatchDirectory(dir, r1.callback) + if err != nil { + t.Fatal(err) + } + if err = s1.Close(); err != nil { + t.Fatal(err) + } + + // Immediately re-watch the same directory and verify a fresh + // event still flows. Use a separate recorder so we know the + // event isn't a leftover from the first watch. + r2 := newRecorder(t) + s2, err := watcherImpl.WatchDirectory(dir, r2.callback) + if err != nil { + t.Fatalf("re-WatchDirectory after Close: %v", err) + } + t.Cleanup(func() { _ = s2.Close() }) + + // Give the second watcher a moment to settle (fsevents/kqueue + // need it; inotify/fanotify/Windows don't but the wait is cheap). + if watcherImpl == FSEvents() || watcherImpl == Kqueue() { + time.Sleep(300 * time.Millisecond) + } else { + time.Sleep(60 * time.Millisecond) + } + + f := subPath(dir) + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r2, []wantEvent{{EventUpdate, f}}) + + // The first recorder must not have seen the event meant for r2. + stale := r1.drainQuiet(50 * time.Millisecond) + if len(stale) != 0 { + t.Fatalf("closed watch saw events: %v", toWantEvents(stale)) + } + }) +} + +func TestSubscribeNoGoroutineLeak(t *testing.T) { //nolint:paralleltest // goroutine counting requires sequential execution + // No t.Parallel(): goroutine counting requires sequential execution. + for _, b := range availableWatchers { //nolint:paralleltest // goroutine counting requires sequential execution + t.Run(b.Name(), func(t *testing.T) { + dir := newTmpDir(t) + // Warm up: trigger any lazy singleton init (backend, + // debouncer) so it doesn't inflate the post-loop count. + warmup, err := b.WatchDirectory(dir, func([]Event, error) {}) + if err != nil { + t.Fatal(err) + } + if err := warmup.Close(); err != nil { + t.Fatal(err) + } + runtime.GC() + time.Sleep(100 * time.Millisecond) + + baseline := runtime.NumGoroutine() + for range 8 { + r := newRecorder(t) + sub, err := b.WatchDirectory(dir, r.callback) + if err != nil { + t.Fatal(err) + } + if err := sub.Close(); err != nil { + t.Fatal(err) + } + } + // Allow lazy backend/debounce shutdown to settle. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + runtime.GC() + if runtime.NumGoroutine() <= baseline+2 { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("goroutine leak: baseline=%d now=%d", baseline, runtime.NumGoroutine()) + }) + } +} + +// ----- additional coverage ----------------------------------------------- + +func TestSubscribeDeepNestedCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + + // Create a/b/c one level at a time so the watcher can keep up. + a := filepath.Join(dir, "a") + b := filepath.Join(a, "b") + c := filepath.Join(b, "c") + for _, d := range []string{a, b, c} { + if err := os.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + time.Sleep(150 * time.Millisecond) + } + f := filepath.Join(c, "deep.txt") + if err := os.WriteFile(f, []byte("deep"), 0o644); err != nil { + t.Fatal(err) + } + want := []wantEvent{{EventUpdate, a}, {EventUpdate, f}} + got := r.waitForAll(r.deadline(), want) + for _, w := range want { + if !containsEvent(got, w.Kind, w.Path) { + t.Fatalf("expected %s for %s, got %v", w.Kind, w.Path, toWantEvents(got)) + } + } + }) +} + +func TestSubscribeManyFilesAtOnce(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeFor(t, dir, watcherImpl) + + const count = 50 + paths := make([]string, count) + for i := range count { + paths[i] = subPath(dir) + if err := os.WriteFile(paths[i], []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + want := make([]wantEvent, count) + for i, p := range paths { + want[i] = wantEvent{EventUpdate, p} + } + // Some kqueue kernels coalesce dir-NOTE_WRITE events under + // load and miss a few. Retry the missing files (a fresh write + // provokes a new NOTE_WRITE on the parent) up to a couple of + // times before declaring failure. + got := r.waitForAll(r.deadline(), want) + for attempt := 0; attempt < 3 && !haveAll(got, want); attempt++ { + for _, p := range paths { + if !containsEvent(got, EventUpdate, p) { + _ = os.WriteFile(p, []byte("x"), 0o644) + } + } + more := r.waitForAll(r.deadline(), want) + got = append(got, more...) + } + for _, p := range paths { + if !containsEvent(got, EventUpdate, p) { + t.Fatalf("missing create for %s (got %d events total)", p, len(got)) + } + } + }) +} + +func TestSubscribeTruncateFile(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := subPath(dir) + if err := os.WriteFile(f, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + if err := os.Truncate(f, 0); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeConcurrentSubscribeUnsubscribe(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + done := make(chan struct{}) + for range 8 { + go func() { + defer func() { done <- struct{}{} }() + rec := newRecorder(t) + sub, err := watcherImpl.WatchDirectory(dir, rec.callback) + if err != nil { + return + } + _ = sub.Close() + }() + } + for range 8 { + <-done + } + }) +} + +func TestSubscribeRenameDir(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := filepath.Join(dir, "before") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + child := filepath.Join(sub, "file.txt") + if err := os.WriteFile(child, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + after := filepath.Join(dir, "after") + if err := os.Rename(sub, after); err != nil { + t.Fatal(err) + } + want := []wantEvent{{EventUpdate, after}, {EventDelete, sub}} + got := r.waitForAll(r.deadline(), want) + for _, w := range want { + if !containsEvent(got, w.Kind, w.Path) { + t.Fatalf("expected %s for %s, got %v", w.Kind, w.Path, toWantEvents(got)) + } + } + }) +} + +func TestSubscribeReplaceFileWithDir(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + target := subPath(dir) + if err := os.WriteFile(target, []byte("file"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + // Should see at least one event for target (delete and/or update). + got := r.waitForEvent(r.deadline(), func(e Event) bool { + return e.Path == target + }) + if !containsEvent(got, EventDelete, target) && !containsEvent(got, EventUpdate, target) { + t.Fatalf("expected events for file-to-dir replacement, got %v", toWantEvents(got)) + } + }) +} + +func TestSubscribeAppendToFile(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := subPath(dir) + if err := os.WriteFile(f, []byte("initial"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + fh, err := os.OpenFile(f, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + _, _ = fh.WriteString(" appended") + fh.Close() + + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestSubscribeNoEventsAfterUnsubscribe(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, sub := subscribeFor(t, dir, watcherImpl) + if err := sub.Close(); err != nil { + t.Fatal(err) + } + // Create a file after closeWatch; should produce nothing. + f := subPath(dir) + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + got := r.drainQuiet(500 * time.Millisecond) + if len(got) != 0 { + t.Fatalf("expected no events after closeWatch, got %v", toWantEvents(got)) + } + }) +} + +// ----- watcherBase / dirWatchError internals ----------------------------- + +type failingBackend struct { + watcherBase + err error +} + +func newFailingBackend(err error) *failingBackend { + b := &failingBackend{err: err} + b.watcherBase.init(b) + return b +} + +func (b *failingBackend) start() error { return b.err } + +func (b *failingBackend) subscribe(*dirWatch) error { + return nil +} + +func (b *failingBackend) closeWatch(*dirWatch) error { + return nil +} + +func TestBackendRunReturnsStartError(t *testing.T) { + t.Parallel() + want := errors.New("startup failed") + b := newFailingBackend(want) + if err := b.run(); !errors.Is(err, want) { + t.Fatalf("run() error = %v, want %v", err, want) + } +} + +func TestDirWatchErrorImplementsError(t *testing.T) { + t.Parallel() + var err error = &dirWatchError{err: errors.New("boom")} + if err.Error() != "boom" { + t.Fatalf("dirWatchError.Error want boom, got %q", err.Error()) + } +} + +func TestFileCallbackForwardsErrAlongsideEvents(t *testing.T) { + t.Parallel() + target := "/abs/dir/target.txt" + other := "/abs/dir/sibling.txt" + overflow := errors.New("overflow") + + type call struct { + events []Event + err error + } + var got []call + cb := fileCallback(target, func(events []Event, err error) { + got = append(got, call{events: events, err: err}) + }) + + // Plain events: only target events pass through, sibling dropped. + cb([]Event{{Kind: EventUpdate, Path: target}, {Kind: EventUpdate, Path: other}}, nil) + if len(got) != 1 || len(got[0].events) != 1 || got[0].events[0].Path != target || got[0].err != nil { + t.Fatalf("plain delivery: got %+v", got) + } + + // Err only, no matching events: still forwarded with empty slice. + got = nil + cb([]Event{{Kind: EventUpdate, Path: other}}, overflow) + if len(got) != 1 || len(got[0].events) != 0 || !errors.Is(got[0].err, overflow) { + t.Fatalf("err-only delivery: got %+v", got) + } + + // Err with matching events: deliver both the filtered events and err. + got = nil + cb([]Event{{Kind: EventDelete, Path: target}, {Kind: EventUpdate, Path: other}}, overflow) + if len(got) != 1 || len(got[0].events) != 1 || got[0].events[0].Path != target || + got[0].events[0].Kind != EventDelete || !errors.Is(got[0].err, overflow) { + t.Fatalf("combined delivery: got %+v", got) + } + + // No events, no err: callback not invoked at all. + got = nil + cb(nil, nil) + if len(got) != 0 { + t.Fatalf("no-op delivery: got %+v", got) + } +} + +// TestRenameDirOutOfTreeNoStaleEvents pins the cross-backend contract: +// once a subdirectory is renamed out of the watched root, modifications +// to files at its new location must not surface against the old paths. +// +// This passes today on every backend even without B6's fanotify fix: +// the FAN_RENAME path (kernel >= 5.17) already handles descendant +// cleanup correctly, and inotify/kqueue/fsevents/Windows track watches +// at a level where the moved subtree drops out naturally. The harder +// case (forced FAN_MOVED_FROM fallback) is in +// TestFanotifyNoRenameFallback/RenameDirOutDropsDescendants. +func TestRenameDirOutOfTreeNoStaleEvents(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + watched := newTmpDir(t) + outside := newTmpDir(t) // separate watch root, NOT watched. + + // Build a nested subtree under sub/. The descendant subdirs are + // the ones that exercise the bug: with the broken fanotify + // handleSubscription, sub itself got cleaned (exact-match) but + // sub/inner stayed in b.subscriptions with a stale path. Later + // modifications to files at the new location of sub/inner would + // then surface against the old (now-invalid) path. + sub := filepath.Join(watched, "sub") + inner := filepath.Join(sub, "inner") + if err := os.MkdirAll(inner, 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(inner, "leaf.txt") + if err := os.WriteFile(nested, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + + r, _ := subscribeForOpts(t, watched, watcherImpl, WithRecursive()) + + // Rename the whole subtree out of the watched dir. + dest := filepath.Join(outside, "moved") + if err := os.Rename(sub, dest); err != nil { + t.Fatal(err) + } + // Consume the rename-away events. + _ = r.drainQuiet(500 * time.Millisecond) + + // Modify the file at its new location. + movedNested := filepath.Join(dest, "inner", "leaf.txt") + if err := os.WriteFile(movedNested, []byte("v2-longer"), 0o644); err != nil { + t.Fatal(err) + } + + extra := r.drainQuiet(800 * time.Millisecond) + // Allow events for unrelated parts of the watched dir, but no + // event whose path is at or under the moved subtree may appear. + oldPrefix := sub + string(filepath.Separator) + for _, e := range extra { + if e.Path == sub || strings.HasPrefix(e.Path, oldPrefix) { + t.Fatalf("stale event for moved-out path %s: %+v\nall extras: %v", + e.Path, e, toWantEvents(extra)) + } + } + }) +} + +// ----- platform-specific ------------------------------------------------- + +func TestDefaultBackendMatchesPlatform(t *testing.T) { + t.Parallel() + d := Default() + var wantName string + switch runtime.GOOS { + case "linux": + if Fanotify().Available() { + wantName = "fanotify" + } else { + wantName = "inotify" + } + case "darwin": + wantName = "fsevents" + case "windows": + wantName = "windows" + case "freebsd", "openbsd", "netbsd", "dragonfly": + wantName = "kqueue" + default: + t.Skipf("no expected default watcher for %s", runtime.GOOS) + } + if !d.Available() { + t.Fatalf("Default() should be available on %s", runtime.GOOS) + } + if d.Name() != wantName { + t.Fatalf("Default().Name() = %q, want %q", d.Name(), wantName) + } +} + +func TestUnavailableBackendReturnsError(t *testing.T) { + t.Parallel() + // Pick a watcher that is definitely unavailable on the current OS. + var unavailable Watcher + for _, w := range AllWatchers() { + if !w.Available() { + unavailable = w + break + } + } + if unavailable == nil { + t.Skip("all watchers are available on this platform") + } + dir := newTmpDir(t) + _, err := unavailable.WatchDirectory(dir, func([]Event, error) {}) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("expected ErrUnavailable from %s, got %v", unavailable.Name(), err) + } +} + +func TestSubscribeNestedDirDeletionCleansDescendants(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := filepath.Join(dir, "parent") + nested := filepath.Join(sub, "child") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + childFile := filepath.Join(nested, "file.txt") + if err := os.WriteFile(childFile, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + r, _ := subscribeFor(t, dir, watcherImpl) + + if err := os.RemoveAll(sub); err != nil { + t.Fatal(err) + } + + expectContains(t, r, EventDelete, sub) + }) +} + +// ----- non-recursive tests ----------------------------------------------- + +func TestNonRecursiveFileCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeForOpts(t, dir, watcherImpl) + + f := subPath(dir) + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestNonRecursiveFileUpdate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeForOpts(t, dir, watcherImpl) + + f := subPath(dir) + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.waitForEvent(r.deadline(), func(Event) bool { return true }) // consume create + if err := os.WriteFile(f, []byte("v2-longer"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestNonRecursiveFileDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := subPath(dir) + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeForOpts(t, dir, watcherImpl) + + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, f}}) + }) +} + +func TestNonRecursiveDirCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeForOpts(t, dir, watcherImpl) + + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, sub) + }) +} + +func TestNonRecursiveGrandchildIgnored(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := filepath.Join(dir, "child") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + + r, _ := subscribeForOpts(t, dir, watcherImpl) + + // Create a file inside the child directory (grandchild). + grandchild := subPath(sub) + if err := os.WriteFile(grandchild, []byte("deep"), 0o644); err != nil { + t.Fatal(err) + } + + marker := subPath(dir) + if err := os.WriteFile(marker, []byte("flush"), 0o644); err != nil { + t.Fatal(err) + } + + // The marker proves the non-recursive watcher processed a later + // direct-child batch. It still must not report the grandchild. + got := expectContains(t, r, EventUpdate, marker) + got = append(got, r.drainQuiet(2*maxWaitTime)...) + assertNoEventsForPath(t, got, grandchild, "expected no events for grandchild") + }) +} + +func TestNonRecursiveNewSubdirContentIgnored(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + r, _ := subscribeForOpts(t, dir, watcherImpl) + + // Create a new subdirectory. + sub := subPath(dir) + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + // Wait for the dir create event. + expectContains(t, r, EventUpdate, sub) + + // Write a file inside the new subdirectory. + grandchild := subPath(sub) + if err := os.WriteFile(grandchild, []byte("nested"), 0o644); err != nil { + t.Fatal(err) + } + + marker := subPath(dir) + if err := os.WriteFile(marker, []byte("flush"), 0o644); err != nil { + t.Fatal(err) + } + + // Should NOT see the grandchild event. + got := expectContains(t, r, EventUpdate, marker) + got = append(got, r.drainQuiet(2*maxWaitTime)...) + assertNoEventsForPath(t, got, grandchild, "expected no events for nested file") + }) +} + +func TestNonRecursiveAndRecursiveSameDir(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := filepath.Join(dir, "child") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + + rNonRec, _ := subscribeForOpts(t, dir, watcherImpl) + rRec, _ := subscribeForOpts(t, dir, watcherImpl, WithRecursive()) + + // Create a grandchild file. + grandchild := subPath(sub) + if err := os.WriteFile(grandchild, []byte("deep"), 0o644); err != nil { + t.Fatal(err) + } + marker := subPath(dir) + if err := os.WriteFile(marker, []byte("flush"), 0o644); err != nil { + t.Fatal(err) + } + + // Recursive should see the grandchild. + expectContains(t, rRec, EventUpdate, grandchild) + + // Non-recursive should NOT see the grandchild. + gotNonRec := expectContains(t, rNonRec, EventUpdate, marker) + gotNonRec = append(gotNonRec, rNonRec.drainQuiet(2*maxWaitTime)...) + assertNoEventsForPath(t, gotNonRec, grandchild, "non-recursive: expected no events for") + }) +} + +func TestNonRecursiveWithDeniedSubdir(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("chmod is not meaningful on Windows") + } + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + + // Create a permission-denied subdirectory. + denied := filepath.Join(dir, "denied") + if err := os.Mkdir(denied, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(denied, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(denied, 0o700) }) + + // Non-recursive watch should succeed despite the inaccessible child. + r, _ := subscribeForOpts(t, dir, watcherImpl) + + f := subPath(dir) + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +// ----- file watch tests -------------------------------------------------- + +func TestFileWatchCreate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := filepath.Join(dir, "target.txt") + + r, _ := subscribeFileFor(t, f, watcherImpl) + + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestFileWatchUpdate(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := filepath.Join(dir, "target.txt") + if err := os.WriteFile(f, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + + r, _ := subscribeFileFor(t, f, watcherImpl) + + if err := os.WriteFile(f, []byte("v2-longer"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +func TestFileWatchDelete(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := filepath.Join(dir, "target.txt") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + r, _ := subscribeFileFor(t, f, watcherImpl) + + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, f}}) + }) +} + +func TestFileWatchIgnoresSiblings(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + target := filepath.Join(dir, "target.txt") + sibling := filepath.Join(dir, "sibling.txt") + + r, _ := subscribeFileFor(t, target, watcherImpl) + witness, _ := subscribeForOpts(t, dir, watcherImpl) + + // Write to sibling; should NOT see this. + if err := os.WriteFile(sibling, []byte("noise"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, witness, EventUpdate, sibling) + expectNoBufferedEvents(t, r, "expected no events for sibling") + }) +} + +// Not parallel: under load on macOS, this test (which subscribes +// twice to files in the same directory via WatchFile) intermittently +// stalls for the full FSEvents-timeout window. Running serially keeps +// the multi-WatchFile-share path predictable. +func TestFileWatchMultipleSameDir(t *testing.T) { //nolint:tparallel,paralleltest // see comment + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f1 := filepath.Join(dir, "a.txt") + f2 := filepath.Join(dir, "b.txt") + + r1, _ := subscribeFileFor(t, f1, watcherImpl) + r2, _ := subscribeFileFor(t, f2, watcherImpl) + + // Write to f1; only r1 should see it. + if err := os.WriteFile(f1, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + got1 := r1.next(r1.deadline()) + assertEventSequence(t, got1, []wantEvent{{EventUpdate, f1}}) + + expectNoBufferedEvents(t, r2, "r2 should not see f1 events") + + // Write to f2; only r2 should see it. + if err := os.WriteFile(f2, []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + got2 := r2.next(r2.deadline()) + assertEventSequence(t, got2, []wantEvent{{EventUpdate, f2}}) + + expectNoBufferedEvents(t, r1, "r1 should not see f2 events") + }) +} + +// Not parallel: under load on macOS, this test (delete then recreate +// a file inside a WatchFile target) intermittently stalls for the full +// FSEvents-timeout window. Running serially eliminates the flake. +func TestFileWatchDeleteAndRecreate(t *testing.T) { //nolint:tparallel,paralleltest // see comment + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := filepath.Join(dir, "config.json") + if err := os.WriteFile(f, []byte(`{"v":1}`), 0o644); err != nil { + t.Fatal(err) + } + + r, _ := subscribeFileFor(t, f, watcherImpl) + + // Delete the file. + if err := os.Remove(f); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventDelete, f}}) + + // Recreate it. + if err := os.WriteFile(f, []byte(`{"v":2}`), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, f) + }) +} + +func TestFileWatchNonExistentTarget(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + f := filepath.Join(dir, "doesnotexist.txt") + + // File doesn't exist; subscribe should still succeed + // (watches the parent dir). + r, _ := subscribeFileFor(t, f, watcherImpl) + + // Now create it. + if err := os.WriteFile(f, []byte("appeared"), 0o644); err != nil { + t.Fatal(err) + } + expectEventSequence(t, r, []wantEvent{{EventUpdate, f}}) + }) +} + +// TestRecursiveMoveInPrePopulated verifies that moving a pre-populated +// directory tree into a recursive watch detects changes in nested subdirs. +func TestRecursiveMoveInPrePopulated(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + outside := newTmpDir(t) + + // Build a tree outside the watched directory. + nested := filepath.Join(outside, "a", "b", "c") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + r, _ := subscribeForOpts(t, dir, watcherImpl, WithRecursive()) + + // Move the pre-populated tree into the watched directory. + dest := filepath.Join(dir, "tree") + if err := os.Rename(outside, dest); err != nil { + t.Fatal(err) + } + // Consume the move-in events. + _ = r.drainQuiet(500 * time.Millisecond) + + // Now modify a file deep inside the moved tree. There's a + // race against the backend's recursive re-arm of the moved + // subtree, so retry with fresh filenames until one surfaces + // rather than betting on the first write being seen. + nestedDir := filepath.Join(dest, "a", "b", "c") + deadline := time.Now().Add(r.deadline()) + var allSeen []Event + for attempt := 0; time.Now().Before(deadline); attempt++ { + f := filepath.Join(nestedDir, fmt.Sprintf("deep-%d.txt", attempt)) + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + more := r.waitForEvent(750*time.Millisecond, func(e Event) bool { + return e.Kind == EventUpdate && strings.HasPrefix(e.Path, nestedDir+string(filepath.Separator)) + }) + allSeen = append(allSeen, more...) + for _, e := range more { + if e.Kind == EventUpdate && strings.HasPrefix(e.Path, nestedDir+string(filepath.Separator)) { + return + } + } + } + t.Fatalf("expected update for a file inside moved-in tree (gave up after %s), got %v", + r.deadline(), toWantEvents(allSeen)) + }) +} + +// TestAtomicSave verifies that the "safe save" pattern (write tmp, rename +// over target) is detected as an update, not a delete+create or nothing. +func TestAtomicSave(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + target := filepath.Join(dir, "config.json") + if err := os.WriteFile(target, []byte(`{"v":1}`), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + // Atomic save: write to temp, rename over target. + tmp := target + ".tmp" + if err := os.WriteFile(tmp, []byte(`{"v":2}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, target); err != nil { + t.Fatal(err) + } + // Any event for target proves the atomic save was observed. + got := r.waitForEvent(r.deadline(), func(e Event) bool { + return e.Path == target + }) + got = filterEventsForPaths(got, target) + if len(got) == 0 { + t.Fatalf("expected events for %s after atomic save, got none", target) + } + }) +} + +// TestAtomicSaveFileWatch verifies atomic save detection through WatchFile. +func TestAtomicSaveFileWatch(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + target := filepath.Join(dir, "target.txt") + if err := os.WriteFile(target, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFileFor(t, target, watcherImpl) + + tmp := target + ".tmp" + if err := os.WriteFile(tmp, []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, target); err != nil { + t.Fatal(err) + } + got := r.waitForEvent(r.deadline(), func(e Event) bool { + return e.Path == target + }) + got = filterEventsForPaths(got, target) + if len(got) == 0 { + t.Fatalf("expected events for %s after atomic save, got none", target) + } + }) +} + +// TestReplaceDirWithFile verifies that replacing a directory with a file +// of the same name emits appropriate events. +func TestReplaceDirWithFile(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + child := filepath.Join(dir, "child") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + if err := os.Remove(child); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(child, []byte("now a file"), 0o644); err != nil { + t.Fatal(err) + } + + got := r.waitForEvent(r.deadline(), func(e Event) bool { + return e.Path == child + }) + got = filterEventsForPaths(got, child) + if len(got) == 0 { + t.Fatalf("expected events for dir->file replacement at %s, got none", child) + } + }) +} + +// TestRecreateSubdirAndModify verifies that after deleting and recreating +// a subdirectory, changes inside it are still detected. +func TestRecreateSubdirAndModify(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := filepath.Join(dir, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + inner := filepath.Join(sub, "file.txt") + if err := os.WriteFile(inner, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + // Delete the subdirectory tree. + if err := os.RemoveAll(sub); err != nil { + t.Fatal(err) + } + _ = r.drainQuiet(500 * time.Millisecond) + + // Recreate the same path. + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + + // Give the backend a chance to observe the FAN_CREATE / + // IN_CREATE / compareDir on the parent and install its watch + // on the new sub inode BEFORE we write inside it. Without + // this beat the user's write can race the kernel's + // enqueue-on-marked-inode and the event is never delivered. + // The poll loop below then has nothing to wait for. + time.Sleep(150 * time.Millisecond) + + // Keep nudging with a fresh file until one surfaces. Each + // iteration uses a new filename so a missed event on attempt + // N doesn't trap us waiting for it on attempt N+1. Short + // per-attempt deadline + long total deadline = many retry + // cycles, which is what kqueue needs when the kernel is slow + // to deliver NOTE_WRITE on a freshly-watched sub inode. + deadline := time.Now().Add(r.deadline() * 2) + var allSeen []Event + for attempt := 0; time.Now().Before(deadline); attempt++ { + f := filepath.Join(sub, fmt.Sprintf("attempt-%d.txt", attempt)) + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + more := r.waitForEvent(750*time.Millisecond, func(e Event) bool { + return e.Kind == EventUpdate && strings.HasPrefix(e.Path, sub+string(filepath.Separator)) + }) + allSeen = append(allSeen, more...) + for _, e := range more { + if e.Kind == EventUpdate && strings.HasPrefix(e.Path, sub+string(filepath.Separator)) { + return + } + } + } + t.Fatalf("expected update for a file inside recreated sub (gave up after %s), got %v", + r.deadline()*2, toWantEvents(allSeen)) + }) +} + +// TestReplaceParentDirWithDifferent verifies that replacing a subtree with +// a different pre-populated directory of the same name still detects +// changes inside the new tree. +func TestReplaceParentDirWithDifferent(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + sub := filepath.Join(dir, "pkg") + if err := os.MkdirAll(filepath.Join(sub, "old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "old", "a.txt"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + // Replace: remove old tree, create new tree at same path. + if err := os.RemoveAll(sub); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(sub, "new"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "new", "b.txt"), []byte("b"), 0o644); err != nil { + t.Fatal(err) + } + _ = r.drainQuiet(500 * time.Millisecond) + + // Now modify something inside the replaced tree. The same + // "watch may not yet be armed on the replaced subtree" race + // applies as TestRecreateSubdirAndModify; retry with fresh + // filenames. + newDir := filepath.Join(sub, "new") + deadline := time.Now().Add(r.deadline()) + var allSeen []Event + for attempt := 0; time.Now().Before(deadline); attempt++ { + f := filepath.Join(newDir, fmt.Sprintf("attempt-%d.txt", attempt)) + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + more := r.waitForEvent(750*time.Millisecond, func(e Event) bool { + return e.Kind == EventUpdate && strings.HasPrefix(e.Path, newDir+string(filepath.Separator)) + }) + allSeen = append(allSeen, more...) + for _, e := range more { + if e.Kind == EventUpdate && strings.HasPrefix(e.Path, newDir+string(filepath.Separator)) { + return + } + } + } + t.Fatalf("expected update for a file inside replaced tree (gave up after %s), got %v", + r.deadline(), toWantEvents(allSeen)) + }) +} + +// TestRoundTripRename renames a file away and back within a short window. +// The net result is that the file is unchanged, but we should see at least +// some events (coalescing may merge them). +func TestRoundTripRename(t *testing.T) { + t.Parallel() + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + if watcherImpl == Kqueue() { + t.Skip("kqueue fd-based tracking delivers stale delete before parent NOTE_WRITE reconciles") + } + dir := newTmpDir(t) + orig := filepath.Join(dir, "data.txt") + if err := os.WriteFile(orig, []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + r, _ := subscribeFor(t, dir, watcherImpl) + + tmp := filepath.Join(dir, "data.txt.bak") + if err := os.Rename(orig, tmp); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, orig); err != nil { + t.Fatal(err) + } + // Either some events or zero events (coalesced to no-op) are + // both acceptable. On fd-based backends (kqueue), a transient + // delete may appear if the debounce window fires between the + // rename-away and rename-back; as long as a subsequent update + // follows, the watcher correctly recovered. Use gatherUntilQuiet + // here because we genuinely need to see "everything that arrives" + // (the test asserts what coalesced, not a specific positive event). + got := r.gatherUntilQuiet(r.deadline(), 500*time.Millisecond) + got = filterEventsForPaths(got, orig) + hasDelete := containsEvent(got, EventDelete, orig) + hasUpdate := containsEvent(got, EventUpdate, orig) + if hasDelete && !hasUpdate { + t.Fatalf("round-trip rename left a stale delete without recovery for %s; events: %v", orig, toWantEvents(got)) + } + }) +} + +// TestRecursiveWithDeniedSubdir verifies that a recursive watch succeeds +// even when a child directory is unreadable. +func TestRecursiveWithDeniedSubdir(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("chmod is not meaningful on Windows") + } + runForEachWatcher(t, func(t testingT, watcherImpl Watcher) { + dir := newTmpDir(t) + accessible := filepath.Join(dir, "ok") + if err := os.Mkdir(accessible, 0o755); err != nil { + t.Fatal(err) + } + denied := filepath.Join(dir, "denied") + if err := os.Mkdir(denied, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(denied, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(denied, 0o700) }) + + // Recursive watch should succeed despite the inaccessible child. + r, _ := subscribeFor(t, dir, watcherImpl) + + // Events in the accessible sibling should still work. + f := filepath.Join(accessible, "test.txt") + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, f) + }) +} diff --git a/internal/fswatch/windows.go b/internal/fswatch/windows.go new file mode 100644 index 00000000000..fff9b10d4ef --- /dev/null +++ b/internal/fswatch/windows.go @@ -0,0 +1,470 @@ +//go:build windows + +package fswatch + +import ( + "errors" + "fmt" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// --------------------------------------------------------------------------- +// windows.go: Windows ReadDirectoryChangesW backend +// +// Uses the Win32 ReadDirectoryChangesW API with overlapped (asynchronous) +// I/O to monitor directory trees. Unlike the Unix backends, there is no +// shared event loop; each watch owns its own goroutine that +// independently polls for directory changes. +// +// ┌──────────────────────────────────────────────────────────────┐ +// │ windowsBackend │ +// │ (no event loop; start() just signals readiness) │ +// │ │ +// │ subscribe() per directory: │ +// │ │ │ +// │ ▼ │ +// │ ┌───────────────────────────────────────────────────────┐ │ +// │ │ windowsSubscription │ │ +// │ │ │ │ +// │ │ handle ← CreateFile(dir, FILE_FLAG_OVERLAPPED) │ │ +// │ │ │ │ +// │ │ run() goroutine: │ │ +// │ │ ┌───────────────────────────────┐ │ │ +// │ │ │ ReadDirectoryChangesW (async) │◄──────────┐ │ │ +// │ │ └───────────────┬───────────────┘ │ │ │ +// │ │ ▼ │ │ │ +// │ │ ┌───────────────────────────────┐ │ │ │ +// │ │ │ WaitForSingleObject(event) │ │ │ │ +// │ │ └───────────────┬───────────────┘ │ │ │ +// │ │ ▼ │ │ │ +// │ │ ┌───────────────────────────────┐ │ │ │ +// │ │ │ GetOverlappedResult │ │ │ │ +// │ │ └───────────────┬───────────────┘ │ │ │ +// │ │ ▼ │ │ │ +// │ │ ┌───────────────────────────────┐ │ │ │ +// │ │ │ Walk FILE_NOTIFY_INFORMATION │ │ │ │ +// │ │ │ chain → processOne() ├───────────┘ │ │ +// │ │ └───────────────────────────────┘ │ │ +// │ │ │ │ +// │ │ stop: stopCh → CancelIoEx → run() exits │ │ +// │ │ cleanup: deferred CloseHandle → doneCh closed │ │ +// │ └───────────────────────────────────────────────────────┘ │ +// └──────────────────────────────────────────────────────────────┘ +// +// Goroutines and threading: +// - One goroutine per watch (run). It blocks in WaitForSingleObject +// waiting for ReadDirectoryChangesW completions. processCompletion and +// processOne execute on this goroutine. There is no shared event loop. +// - subscribe runs on the caller's goroutine. It opens the directory handle, +// arms the first ReadDirectoryChangesW, and spawns run(). +// - closeWatch runs on the caller's goroutine. It closes stopCh, which +// triggers CancelIoEx (from a helper goroutine inside run's wait), waking +// the run goroutine so it can exit cleanly. +// - fatal() spawns a separate goroutine for handleWatcherError to avoid +// deadlock: handleWatcherError → closeWatch → wait(doneCh), but doneCh +// is only closed when run() returns. The indirection lets run() exit first. +// +// 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 per-watch goroutine. +// +// WatchDirectory flow: +// 1. Open the directory with CreateFile (FILE_FLAG_BACKUP_SEMANTICS | +// FILE_FLAG_OVERLAPPED) on the caller's goroutine. +// 2. Arm the first ReadDirectoryChangesW synchronously so that any +// filesystem operation after WatchDirectory returns is guaranteed to be +// observed. +// 3. Spawn the run() goroutine. +// +// Event dispatch (processCompletion / processOne, on run goroutine): +// 1. Wait for the overlapped read to complete (WaitForSingleObject). +// 2. Arm the next ReadDirectoryChangesW immediately (double-buffering). +// 3. Walk the FILE_NOTIFY_INFORMATION linked list: +// - FILE_ACTION_ADDED / RENAMED_NEW_NAME → events.create (→ EventUpdate) +// - FILE_ACTION_MODIFIED → events.update (→ EventUpdate) +// - FILE_ACTION_REMOVED / RENAMED_OLD_NAME → events.remove + tree.remove +// 4. Call dirWatch.notify() to trigger the debouncer. +// +// Error recovery: +// - ERROR_OPERATION_ABORTED → normal shutdown (CancelIoEx was called). +// - ERROR_INVALID_PARAMETER → shrink buffer to 64 KB (network share limit). +// - ERROR_NOTIFY_ENUM_DIR → ErrOverflow (too many changes queued). +// - ERROR_ACCESS_DENIED → check if the watched dir was deleted. +// +// Shutdown: +// close(stopCh) → CancelIoEx cancels in-flight IO → run() goroutine +// exits → deferred CloseHandle closes the directory handle → doneCh closed. +// --------------------------------------------------------------------------- + +var ( + errGetFileInfo = errors.New("could not get file information") + errReadChanges = errors.New("failed to read changes") + errGetOverlappedResult = errors.New("GetOverlappedResult failed") + errUnknown = errors.New("unknown error") +) + +const ( + defaultBufSize = 1024 * 1024 + networkBufSize = 64 * 1024 + + notifyChangeFilter = windows.FILE_NOTIFY_CHANGE_FILE_NAME | + windows.FILE_NOTIFY_CHANGE_DIR_NAME | + windows.FILE_NOTIFY_CHANGE_SIZE | + windows.FILE_NOTIFY_CHANGE_LAST_WRITE +) + +// windowsBackend. +type windowsBackend struct { + watcherBase +} + +func init() { + windowsWatcher.factory = func() watcherImpl { return newWindowsBackend() } +} + +func newWindowsBackend() *windowsBackend { + b := &windowsBackend{} + b.watcherBase.init(b) + return b +} + +// start notifies that the watcherImpl is ready. Each watch owns +// its own goroutine, so there's no shared event loop to start. +func (b *windowsBackend) start() error { + b.notifyStarted() + return nil +} + +// windowsSubscription. +type windowsSubscription struct { + mu sync.Mutex + watcherImpl *windowsBackend + dirWatch *dirWatch + handle windows.Handle + stopped bool + stopCh chan struct{} + doneCh chan struct{} + bufBytes int + first *windowsRead +} + +type windowsRead struct { + buf []byte + overlapped windows.Overlapped + event windows.Handle +} + +func newWindowsSubscription(watcherImpl *windowsBackend, w *dirWatch) (*windowsSubscription, error) { + pathPtr, err := windows.UTF16PtrFromString(w.dir) + if err != nil { + return nil, &dirWatchError{err: err, dirWatch: w} + } + h, err := windows.CreateFile( + pathPtr, + windows.FILE_LIST_DIRECTORY, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED, + 0, + ) + if err != nil { + return nil, &dirWatchError{err: fmt.Errorf("invalid handle: %w", err), dirWatch: w} + } + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &info); err != nil { + _ = windows.CloseHandle(h) + return nil, &dirWatchError{err: errGetFileInfo, dirWatch: w} + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + _ = windows.CloseHandle(h) + return nil, &dirWatchError{err: syscall.ENOTDIR, dirWatch: w} + } + return &windowsSubscription{ + watcherImpl: watcherImpl, + dirWatch: w, + + handle: h, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + bufBytes: defaultBufSize, + }, nil +} + +func (s *windowsSubscription) beginRead() (*windowsRead, error) { + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return nil, nil + } + bufSize := s.bufBytes + s.mu.Unlock() + + req := &windowsRead{buf: make([]byte, bufSize)} + ev, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + return nil, fmt.Errorf("CreateEvent: %w", err) + } + req.event = ev + req.overlapped.HEvent = ev + + var bytesReturned uint32 + err = windows.ReadDirectoryChanges( + s.handle, + &req.buf[0], + uint32(len(req.buf)), + s.dirWatch.recursive, // recursive + notifyChangeFilter, + &bytesReturned, + &req.overlapped, + 0, + ) + if err != nil { + _ = windows.CloseHandle(ev) + return nil, &dirWatchError{err: errReadChanges, dirWatch: s.dirWatch} + } + return req, nil +} + +func (r *windowsRead) wait(s *windowsSubscription) (uint32, error, error) { + stopWait := make(chan struct{}) + go func() { + select { + case <-s.stopCh: + _ = windows.CancelIoEx(s.handle, &r.overlapped) + case <-stopWait: + // Do nothing; wait completed normally. + } + }() + _, waitErr := windows.WaitForSingleObject(r.event, windows.INFINITE) + close(stopWait) + var bytes uint32 + completionErr := windows.GetOverlappedResult(s.handle, &r.overlapped, &bytes, false) + _ = windows.CloseHandle(r.event) + return bytes, waitErr, completionErr +} + +// run is the per-watch goroutine. It loops on ReadDirectoryChangesW +// until the watch is stopped or an unrecoverable error occurs. +// +// We close the directory handle here in a defer (not in stop()) to +// guarantee that any in-flight ReadDirectoryChangesW has completed and +// GetOverlappedResult has returned before the handle becomes invalid. +// Closing the handle from another goroutine while we're mid-syscall on +// it is undefined behavior on Windows. +func (s *windowsSubscription) run() { + defer close(s.doneCh) + defer func() { _ = windows.CloseHandle(s.handle) }() + if s.first == nil { + // subscribe always arms the initial read before spawning run. + // Guard the invariant rather than silently producing a watch + // that delivers neither events nor errors if it ever breaks. + s.fatal(&dirWatchError{err: errors.New("fswatch: windows: missing initial read"), dirWatch: s.dirWatch}) + return + } + current := s.first + s.first = nil + for { + bytes, waitErr, gErr := current.wait(s) + if waitErr != nil && gErr != nil { + s.fatal(&dirWatchError{err: errGetOverlappedResult, dirWatch: s.dirWatch}) + return + } + + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.mu.Unlock() + + if gErr != nil { + if shouldStop := s.processCompletion(gErr, current.buf, bytes); shouldStop { + return + } + next, err := s.beginRead() + if err != nil { + s.fatal(err) + return + } + if next == nil { + return + } + current = next + continue + } + + next, err := s.beginRead() + if err != nil { + s.fatal(err) + return + } + if next == nil { + return + } + if shouldStop := s.processCompletion(nil, current.buf, bytes); shouldStop { + return + } + current = next + } +} + +// processCompletion mirrors the body of `Watch::processEvents` for +// the cases that translate cleanly to Go's overlapped wrapper. +func (s *windowsSubscription) processCompletion(callErr error, buf []byte, bytes uint32) (stop bool) { + if callErr != nil { + switch { + case errors.Is(callErr, windows.ERROR_OPERATION_ABORTED): + return true + case errors.Is(callErr, windows.ERROR_INVALID_PARAMETER): + s.mu.Lock() + s.bufBytes = networkBufSize + s.mu.Unlock() + return false + case errors.Is(callErr, windows.ERROR_NOTIFY_ENUM_DIR): + s.dirWatch.events.setError(ErrOverflow) + s.dirWatch.notify() + return false + case errors.Is(callErr, windows.ERROR_ACCESS_DENIED): + // Possibly the watched dir was deleted; check and handle. + pathPtr, _ := windows.UTF16PtrFromString(s.dirWatch.dir) + attrs, err := windows.GetFileAttributes(pathPtr) + if err != nil || attrs == windows.INVALID_FILE_ATTRIBUTES || attrs&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + s.dirWatch.events.remove(s.dirWatch.dir) + s.dirWatch.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) + s.dirWatch.notify() + s.stop() + return true + } + fallthrough + default: + s.fatal(&dirWatchError{err: errUnknown, dirWatch: s.dirWatch}) + return true + } + } + + // Walk the FILE_NOTIFY_INFORMATION chain. + offset := uint32(0) + if bytes == 0 { + bytes = uint32(len(buf)) + } + for offset < bytes { + fni := (*windows.FileNotifyInformation)(unsafe.Pointer(&buf[offset])) + nameLen := int(fni.FileNameLength) / 2 + // The FileName field is a flexible array; reslice. + base := unsafe.Pointer(&fni.FileName) + nameSlice := unsafe.Slice((*uint16)(base), nameLen) + name := windows.UTF16ToString(nameSlice) + + s.processOne(fni.Action, name) + + if fni.NextEntryOffset == 0 { + break + } + offset += fni.NextEntryOffset + } + s.dirWatch.notify() + return false +} + +func (s *windowsSubscription) processOne(action uint32, name string) { + path := s.dirWatch.dir + "\\" + name + switch action { + case windows.FILE_ACTION_ADDED, windows.FILE_ACTION_RENAMED_NEW_NAME: + // Always emit the event, even if the file is already gone by the + // time we look it up. The kernel told us it was added, and a + // subsequent REMOVED needs to find this entry in the eventList so + // the create+delete pair coalesces away. + s.dirWatch.events.create(path) + case windows.FILE_ACTION_MODIFIED: + if pathPtr, err := windows.UTF16PtrFromString(path); err == nil { + var data windows.Win32FileAttributeData + if err := windows.GetFileAttributesEx(pathPtr, windows.GetFileExInfoStandard, (*byte)(unsafe.Pointer(&data))); err == nil { + if data.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + s.dirWatch.events.update(path) + } + } + } + case windows.FILE_ACTION_REMOVED, windows.FILE_ACTION_RENAMED_OLD_NAME: + s.dirWatch.events.remove(path) + } +} + +// fatal is invoked when the run goroutine hits an unrecoverable error. +// handleWatcherError eventually calls closeWatch which waits on doneCh, +// but doneCh isn't closed until run() returns. Calling handleWatcherError +// synchronously from inside run() would deadlock. Spawn a goroutine to do +// the cleanup so run() can exit and unblock the wait. +func (s *windowsSubscription) fatal(err error) { + werr := &dirWatchError{err: err, dirWatch: s.dirWatch} + go s.watcherImpl.handleWatcherError(werr) + s.stop() +} + +func (s *windowsSubscription) stopLocked() { + if s.stopped { + return + } + s.stopped = true + close(s.stopCh) + // Cancel any in-flight IO so the wait returns; the run goroutine + // closes the handle in its deferred cleanup once the IO has fully + // finished and GetOverlappedResult has returned. + _ = windows.CancelIoEx(s.handle, nil) +} + +func (s *windowsSubscription) stop() { + s.mu.Lock() + defer s.mu.Unlock() + s.stopLocked() +} + +// subscribe mirrors `windowsBackend::subscribe`. +func (b *windowsBackend) subscribe(w *dirWatch) error { + sub, err := newWindowsSubscription(b, w) + if err != nil { + return err + } + // Arm the first ReadDirectoryChangesW synchronously so that any file + // operation a caller performs after subscribe returns is guaranteed + // to be observed. Doing this in run() would race the spawning + // goroutine with the caller's first filesystem op, occasionally + // missing the initial create event or seeing it as a stray modify. + first, err := sub.beginRead() + if err != nil { + _ = windows.CloseHandle(sub.handle) + return err + } + sub.first = first + w.state = sub + go sub.run() + return nil +} + +// closeWatch mirrors `windowsBackend::closeWatch`. Signals the watch +// goroutine to stop and waits for it to finish; that way the directory +// handle is guaranteed to be closed before this returns, so a follow-on +// operation (e.g. immediately re-watching, deleting the directory) sees +// a clean slate. +func (b *windowsBackend) closeWatch(w *dirWatch) error { + sub, _ := w.state.(*windowsSubscription) + w.state = nil + if sub == nil { + return nil + } + sub.stop() + <-sub.doneCh + return nil +} + +// shutdown mirrors `windowsBackend::~windowsBackend`. +func (b *windowsBackend) shutdown() { + // Nothing to do; each watch owns its goroutine and is stopped + // by closeWatch. +} diff --git a/internal/vfs/vfswatch/vfswatch.go b/internal/vfs/vfswatch/vfswatch.go index 3189d961b09..b758e8bea14 100644 --- a/internal/vfs/vfswatch/vfswatch.go +++ b/internal/vfs/vfswatch/vfswatch.go @@ -1,10 +1,8 @@ -// This package implements a polling-based file watcher designed -// for use by both the CLI watcher and the language server. +// This package tracks filesystem state and detects changes +// by comparing current state against a stored baseline. package vfswatch import ( - "fmt" - "io" "slices" "sync" "time" @@ -13,8 +11,6 @@ import ( "github.com/zeebo/xxh3" ) -const debounceWait = 250 * time.Millisecond - type WatchEntry struct { ModTime time.Time Exists bool @@ -22,39 +18,17 @@ type WatchEntry struct { } type FileWatcher struct { - fs vfs.FS - pollInterval time.Duration - testing bool - callback func() - watchState map[string]WatchEntry - wildcardDirectories map[string]bool - mu sync.Mutex - debugLog io.Writer // nil = silent; non-nil = write timing lines here + fs vfs.FS + watchState map[string]WatchEntry + mu sync.Mutex } -func NewFileWatcher(fs vfs.FS, pollInterval time.Duration, testing bool, callback func()) *FileWatcher { +func NewFileWatcher(fs vfs.FS) *FileWatcher { return &FileWatcher{ - fs: fs, - pollInterval: pollInterval, - testing: testing, - callback: callback, + fs: fs, } } -// SetDebugLog enables per-scan timing output written to w. -// Pass nil to disable. Safe to call at any time. -func (fw *FileWatcher) SetDebugLog(w io.Writer) { - fw.mu.Lock() - defer fw.mu.Unlock() - fw.debugLog = w -} - -func (fw *FileWatcher) SetPollInterval(d time.Duration) { - fw.mu.Lock() - defer fw.mu.Unlock() - fw.pollInterval = d -} - func (fw *FileWatcher) WatchStateEntry(path string) (WatchEntry, bool) { fw.mu.Lock() defer fw.mu.Unlock() @@ -73,55 +47,6 @@ func (fw *FileWatcher) UpdateWatchState(paths []string, wildcardDirs map[string] fw.mu.Lock() defer fw.mu.Unlock() fw.watchState = state - fw.wildcardDirectories = wildcardDirs -} - -func (fw *FileWatcher) WaitForSettled(now func() time.Time) { - if fw.testing { - return - } - fw.mu.Lock() - pollInterval := fw.pollInterval - fw.mu.Unlock() - current := fw.currentState() - settledAt := now() - tick := min(pollInterval, debounceWait) - for now().Sub(settledAt) < debounceWait { - time.Sleep(tick) - if fw.hasChanges(current) { - current = fw.currentState() - settledAt = now() - } - } -} - -func (fw *FileWatcher) currentState() map[string]WatchEntry { - fw.mu.Lock() - watchState := fw.watchState - wildcardDirs := fw.wildcardDirectories - fw.mu.Unlock() - state := make(map[string]WatchEntry, len(watchState)) - for fn := range watchState { - if s := fw.fs.Stat(fn); s != nil { - state[fn] = WatchEntry{ModTime: s.ModTime(), Exists: true} - } else { - state[fn] = WatchEntry{Exists: false} - } - } - for dir, recursive := range wildcardDirs { - if !recursive { - snapshotDirEntry(fw.fs, state, dir) - continue - } - _ = fw.fs.WalkDir(dir, func(path string, d vfs.DirEntry, err error) error { - if err != nil || !d.IsDir() { - return nil - } - snapshotDirEntry(fw.fs, state, path) - return nil - }) - } - return state } func snapshotPaths(fs vfs.FS, paths []string, wildcardDirs map[string]bool) map[string]WatchEntry { @@ -223,45 +148,14 @@ func (fw *FileWatcher) hasChanges(baseline map[string]WatchEntry) bool { } // HasChangesFromWatchState compares the current filesystem against the -// stored watch state. Safe for concurrent use: watchState is snapshotted -// under lock; the map itself is never mutated after creation -// (UpdateWatchState replaces it). +// stored watch state. Returns true if the watch state has not been +// initialized yet. Safe for concurrent use. func (fw *FileWatcher) HasChangesFromWatchState() bool { fw.mu.Lock() ws := fw.watchState fw.mu.Unlock() - return fw.hasChanges(ws) -} - -func (fw *FileWatcher) Run(now func() time.Time) { - for { - fw.mu.Lock() - interval := fw.pollInterval - ws := fw.watchState - log := fw.debugLog - fw.mu.Unlock() - time.Sleep(interval) - start := now() - changed := ws == nil || fw.hasChanges(ws) - if log != nil { - elapsed := now().Sub(start) - files, dirs, missing := 0, 0, 0 - for _, e := range ws { - switch { - case !e.Exists: - missing++ - case e.ChildrenHash != 0: - dirs++ - default: - files++ - } - } - fmt.Fprintf(log, "[vfswatch] scan: %d paths (%d files, %d dirs, %d missing), %.1fms, changed=%v\n", - len(ws), files, dirs, missing, float64(elapsed.Microseconds())/1000.0, changed) - } - if changed { - fw.WaitForSettled(now) - fw.callback() - } + if ws == nil { + return true } + return fw.hasChanges(ws) } diff --git a/internal/vfs/vfswatch/vfswatch_race_test.go b/internal/vfs/vfswatch/vfswatch_race_test.go index b1004fbc2a6..8adae9f6fe7 100644 --- a/internal/vfs/vfswatch/vfswatch_race_test.go +++ b/internal/vfs/vfswatch/vfswatch_race_test.go @@ -4,7 +4,6 @@ import ( "fmt" "sync" "testing" - "time" "github.com/microsoft/typescript-go/internal/vfs" "github.com/microsoft/typescript-go/internal/vfs/vfstest" @@ -30,7 +29,7 @@ func newTestFS() vfs.FS { } func newWatcherWithState(fs vfs.FS) *vfswatch.FileWatcher { - fw := vfswatch.NewFileWatcher(fs, 10*time.Millisecond, true, func() {}) + fw := vfswatch.NewFileWatcher(fs) fw.UpdateWatchState(defaultPaths, nil) return fw } @@ -94,39 +93,8 @@ func TestRaceWildcardDirectoriesAccess(t *testing.T) { wg.Wait() } -// TestRacePollIntervalAccess tests for data races on the PollInterval -// field when it is read and written from multiple goroutines. -func TestRacePollIntervalAccess(t *testing.T) { - t.Parallel() - fs := newTestFS() - fw := newWatcherWithState(fs) - - var wg sync.WaitGroup - - for range 10 { - wg.Go(func() { - for range 500 { - fw.HasChangesFromWatchState() - } - }) - } - - for i := range 5 { - wg.Add(1) - go func(i int) { - defer wg.Done() - for j := range 200 { - fw.SetPollInterval(time.Duration(i*200+j) * time.Millisecond) - } - }(i) - } - - wg.Wait() -} - // TestRaceMixedOperations hammers all FileWatcher operations -// concurrently: HasChanges, UpdateWatchState, FS mutations, -// and PollInterval writes. +// concurrently: HasChanges, UpdateWatchState, and FS mutations. func TestRaceMixedOperations(t *testing.T) { t.Parallel() fs := newTestFS() @@ -171,17 +139,6 @@ func TestRaceMixedOperations(t *testing.T) { }(i) } - // PollInterval writers - for i := range 2 { - wg.Add(1) - go func(i int) { - defer wg.Done() - for j := range 100 { - fw.SetPollInterval(time.Duration(50+j) * time.Millisecond) - } - }(i) - } - wg.Wait() } @@ -246,7 +203,7 @@ func FuzzFileWatcherOperations(f *testing.F) { for i, op := range ops { path := files[i%len(files)] - switch op % 6 { + switch op % 5 { case 0: // Write/modify a file _ = fs.WriteFile(path, fmt.Sprintf("const x = %d;", i)) case 1: // Remove a file @@ -258,8 +215,6 @@ func FuzzFileWatcherOperations(f *testing.F) { case 4: // Set wildcard directories and check for changes fw.UpdateWatchState(files, map[string]bool{"/src": true}) fw.HasChangesFromWatchState() - case 5: // Modify PollInterval - fw.SetPollInterval(time.Duration(i*10) * time.Millisecond) } } }) diff --git a/internal/vfs/vfswatch/vfswatch_test.go b/internal/vfs/vfswatch/vfswatch_test.go index f7220e461ee..1344b1b0bed 100644 --- a/internal/vfs/vfswatch/vfswatch_test.go +++ b/internal/vfs/vfswatch/vfswatch_test.go @@ -3,7 +3,6 @@ package vfswatch_test import ( "sync/atomic" "testing" - "time" "github.com/microsoft/typescript-go/internal/vfs" "github.com/microsoft/typescript-go/internal/vfs/vfstest" @@ -45,7 +44,7 @@ func TestHasChangesNoRedundantGetAccessibleEntries(t *testing.T) { }, true) cfs := &countingFS{FS: inner} - fw := vfswatch.NewFileWatcher(cfs, 10*time.Millisecond, true, func() {}) + fw := vfswatch.NewFileWatcher(cfs) fw.UpdateWatchState( []string{"/src/a.ts", "/src/b.ts", "/src/sub/c.ts", "/node_modules", "/tsconfig.json"}, map[string]bool{"/src": true},