vendor tsgo

This commit is contained in:
2026-07-09 16:50:43 -04:00
parent c06ea2e5a4
commit 98978e4930
5804 changed files with 1556156 additions and 101 deletions

View File

@@ -0,0 +1,275 @@
# 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...)` |
| — | `WatchDirectories([]WatchDirectoryRequest)` |
| — | `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.
### Symlinked watch roots
When `WatchDirectory` is called with a symlink or reparse point to a directory,
Go follows the link for the OS subscription but reports events under the
caller-provided path. This matches TypeScript/Node's behavior for watch roots
while keeping the logical paths stable for callers.
Userspace recursive traversal still does not follow symlinked descendant
directories.
### 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.
### Batch directory watching
`WatchDirectories` registers multiple directory watches in one call. It has the
same logical behavior as repeated `WatchDirectory` calls, but lets backends batch
the underlying OS subscription work. On macOS this avoids rebuilding the shared
FSEvents stream once per logical watch during large watch reconciliations.
### 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.
### Shared FSEvents streams
Upstream opens one macOS FSEventStream per subscription. Go's FSEvents backend
shares streams across all logical directory watches in a backend instance. The
fast path attempts one stream containing every active physical watch root; if
that stream cannot be started, the backend retries with bounded path chunks.
Events from shared streams are routed back to matching logical watches by path,
so non-recursive and per-subscriber ignore semantics are preserved while using
far fewer system-wide FSEvents stream slots. When many sibling watches are
consolidated under one recursive parent watch, each callback still keeps its own
logical root, physical root, event-ID cutoff, and termination state, so
late-added watches don't receive older queued events and symlinked watch roots
continue reporting caller-visible paths.
## 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 and
event ID arrays 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.

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) Microsoft Corporation.
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.

View File

@@ -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)
```

View File

@@ -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) }

View File

@@ -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 }

View File

@@ -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"
}
}
}
]
}

View File

@@ -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{})
}
}

View File

@@ -0,0 +1,271 @@
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
includedWatchRoot bool
}
// eventEntry tracks coalescing state during a debounce batch.
type eventEntry struct {
createdSeq uint64
updatedSeq uint64
deletedSeq uint64
includedWatchRoot 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
seq uint64
}
// create records a new-file event for path. Both create and update
// produce EventUpdate externally; sequence state tracks coalescing
// (create+delete within a batch cancels out).
func (el *eventList) create(path string) {
el.mu.Lock()
defer el.mu.Unlock()
seq := el.nextSeqLocked()
el.createLocked(path, seq)
}
func (el *eventList) createAt(path string, seq uint64) {
el.mu.Lock()
defer el.mu.Unlock()
el.advanceSeqLocked(seq)
el.createLocked(path, seq)
}
func (el *eventList) createLocked(path string, seq uint64) {
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.deletedSeq = 0
entry.createdSeq = 0
entry.updatedSeq = seq
} else {
entry.createdSeq = seq
}
}
// update records an update event for path.
func (el *eventList) update(path string) {
el.mu.Lock()
defer el.mu.Unlock()
seq := el.nextSeqLocked()
el.updateLocked(path, seq)
}
func (el *eventList) updateAt(path string, seq uint64) {
el.mu.Lock()
defer el.mu.Unlock()
el.advanceSeqLocked(seq)
el.updateLocked(path, seq)
}
func (el *eventList) updateWatchRootAt(path string, seq uint64) {
el.mu.Lock()
defer el.mu.Unlock()
el.advanceSeqLocked(seq)
el.updateLocked(path, seq)
el.getOrCreate(path).includedWatchRoot = true
}
func (el *eventList) updateLocked(path string, seq uint64) {
el.getOrCreate(path).updatedSeq = seq
}
// remove records a delete event for path.
func (el *eventList) remove(path string) {
el.mu.Lock()
defer el.mu.Unlock()
seq := el.nextSeqLocked()
el.removeLocked(path, seq)
}
func (el *eventList) removeAndGetSequence(path string) uint64 {
el.mu.Lock()
defer el.mu.Unlock()
seq := el.nextSeqLocked()
el.removeLocked(path, seq)
return seq
}
func (el *eventList) removeAt(path string, seq uint64) {
el.mu.Lock()
defer el.mu.Unlock()
el.advanceSeqLocked(seq)
el.removeLocked(path, seq)
}
func (el *eventList) removeWatchRootAt(path string, seq uint64) {
el.mu.Lock()
defer el.mu.Unlock()
el.advanceSeqLocked(seq)
el.removeLocked(path, seq)
el.getOrCreate(path).includedWatchRoot = true
}
func (el *eventList) removeLocked(path string, seq uint64) {
entry := el.getOrCreate(path)
entry.deletedSeq = seq
}
// 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 {
return el.snapshotSinceLocked(0)
}
func (el *eventList) snapshotSinceLocked(startSeq uint64) []Event {
out := make([]Event, 0, len(el.entries))
for path, e := range el.entries {
kind, ok := e.kindSince(startSeq)
if !ok {
continue
}
out = append(out, Event{Kind: kind, Path: path, includedWatchRoot: e.includedWatchRoot})
}
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
}
func (el *eventList) drainForSequences(startSeqs []uint64) ([][]Event, error) {
el.mu.Lock()
defer el.mu.Unlock()
out := make([][]Event, len(startSeqs))
for i, startSeq := range startSeqs {
out[i] = el.snapshotSinceLocked(startSeq)
}
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
}
func (el *eventList) sequence() uint64 {
el.mu.Lock()
defer el.mu.Unlock()
return el.seq
}
func (el *eventList) nextSeqLocked() uint64 {
el.seq++
return el.seq
}
func (el *eventList) advanceSeqLocked(seq uint64) {
if seq > el.seq {
el.seq = seq
}
}
func (e *eventEntry) isDeleted() bool {
return e.deletedSeq > e.createdSeq && e.deletedSeq > e.updatedSeq
}
func (e *eventEntry) kindSince(startSeq uint64) (EventKind, bool) {
if e.deletedSeq > startSeq {
if e.createdSeq > startSeq && e.createdSeq < e.deletedSeq && e.updatedSeq < e.deletedSeq {
return 0, false
}
return EventDelete, true
}
seq := max(e.createdSeq, e.updatedSeq)
if seq > startSeq {
return EventUpdate, true
}
return 0, false
}

View File

@@ -0,0 +1,147 @@
// 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))
}
}
func TestEventListDrainForSequences(t *testing.T) {
t.Parallel()
var el eventList
el.create("file.txt")
startAfterCreate := el.sequence()
el.remove("file.txt")
eventsByCallback, err := el.drainForSequences([]uint64{0, startAfterCreate})
if err != nil {
t.Fatal(err)
}
if len(eventsByCallback[0]) != 0 {
t.Fatalf("create+delete should cancel for original callback, got %v", eventsByCallback[0])
}
if len(eventsByCallback[1]) != 1 {
t.Fatalf("expected delete for later callback, got %v", eventsByCallback[1])
}
if got := eventsByCallback[1][0]; got.Kind != EventDelete || got.Path != "file.txt" {
t.Fatalf("expected delete for file.txt, got %v", got)
}
}

View File

@@ -0,0 +1,758 @@
//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
watchPath 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.physicalDir)
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.physicalDir)
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, w.physicalDir); err != nil {
return &dirWatchError{
err: fmt.Errorf("fanotify_mark on '%s' failed: %w", w.dir, err),
dirWatch: w,
}
}
return nil
}
if err := walkDir(w.physicalDir, true, func(watchPath string, isDir bool) error {
if !isDir {
return nil
}
path := w.displayPath(watchPath)
if err := b.markDir(w, path, watchPath); 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, markPath string) error {
if err := unix.FanotifyMark(b.fanotifyFD, fanotifyMarkAddFlags, b.markMask, unix.AT_FDCWD, markPath); err != nil {
return err
}
handle, _, err := unix.NameToHandleAt(unix.AT_FDCWD, markPath, 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, markPath)
return fmt.Errorf("name_to_handle_at: %w", err)
}
var st unix.Statfs_t
if err := unix.Statfs(markPath, &st); err != nil {
_ = unix.FanotifyMark(b.fanotifyFD, unix.FAN_MARK_REMOVE|unix.FAN_MARK_ONLYDIR, b.markMask, unix.AT_FDCWD, markPath)
return fmt.Errorf("statfs: %w", err)
}
key := makeFanotifyHandleKey(st.Fsid.Val, handle.Type(), handle.Bytes())
sub := &fanotifySubscription{path: path, watchPath: markPath, 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(s.dirWatch.physicalPath(newPath), true, func(p string, pIsDir bool) error {
if !pIsDir {
return nil
}
_ = b.markDir(s.dirWatch, s.dirWatch.displayPath(p), 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(w.physicalPath(path), true, func(p string, pIsDir bool) error {
if !pIsDir {
return nil
}
_ = b.markDir(w, w.displayPath(p), 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.watchPath
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
}

View File

@@ -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))
}
})
}

View File

@@ -0,0 +1,640 @@
//go:build darwin && (amd64 || arm64)
package fswatch
import (
"errors"
"fmt"
"os"
"runtime"
"slices"
"sort"
"sync"
"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/closeWatch rebuild the shared stream set: │
// │ │ │
// │ ▼ │
// │ ┌─────────────────────────────────────────────────────┐ │
// │ │ fseventsStream[] │ │
// │ │ │ │
// │ │ each FSEventStream watches up to N paths ────────► │ │
// │ │ 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 chunk (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 route them to matching
// dirWatch event lists.
// - subscribe/closeWatch rebuild the stream chunks on the caller's goroutine
// under watcherBase.mu. Old streams are swapped out before teardown so a
// callback cannot deadlock against stream teardown while routing events.
//
// 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/closeWatch snapshots active dirWatches and creates one or more
// FSEventStreams with kFSEventStreamEventIdSinceNow. Each stream receives a
// chunk of physical watch roots. 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; the logical watch is marked terminated and
// receives ErrWatchTerminated. The shared stream remains active for other
// watches until the owner closes or reconciles the terminated watch.
// ---------------------------------------------------------------------------
// ----- 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
}
type fseventsState struct {
terminated atomic.Bool
}
type fseventsStream struct {
stream atomic.Uintptr
cb *streamCallback
pinner runtime.Pinner
}
// ----- the watcherImpl -------------------------------------------------------
// fsEventsBackend.
type fsEventsBackend struct {
watcherBase
mu sync.Mutex
watches map[*dirWatch]*fseventsState
streams []*fseventsStream
}
func init() {
fseventsWatcher.factory = func() watcherImpl { return newFSEventsBackend() }
fseventsWatcher.sequence = fsEventsGetCurrentEventID
}
func newFSEventsBackend() *fsEventsBackend {
b := &fsEventsBackend{
watches: make(map[*dirWatch]*fseventsState),
}
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.physicalDir)
if err != nil {
return &dirWatchError{err: err, dirWatch: w}
}
if !info.IsDir() {
return &dirWatchError{err: syscall.ENOTDIR, dirWatch: w}
}
return nil
}
var (
errCFStringCreateNull = errors.New("CFStringCreate returned NULL")
errCFArrayCreateNull = errors.New("CFArrayCreate returned NULL")
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)
)
const fseventsPathsPerStream = 512
type fseventsWatchSnapshot struct {
w *dirWatch
state *fseventsState
}
func (b *fsEventsBackend) activeWatchesLocked() []fseventsWatchSnapshot {
watches := make([]fseventsWatchSnapshot, 0, len(b.watches))
for w, state := range b.watches {
if state.terminated.Load() {
continue
}
watches = append(watches, fseventsWatchSnapshot{w: w, state: state})
}
return watches
}
func (b *fsEventsBackend) startStreams(watches []fseventsWatchSnapshot) ([]*fseventsStream, error) {
return startFSEventsStreams(watches, b.startStream)
}
func startFSEventsStreams(watches []fseventsWatchSnapshot, startStream func([]string, []fseventsWatchSnapshot) (*fseventsStream, error)) ([]*fseventsStream, error) {
if len(watches) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(watches))
paths := make([]string, 0, len(watches))
for _, watch := range watches {
path := watch.w.physicalDir
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
paths = append(paths, path)
}
sort.Strings(paths)
stream, err := startStream(paths, watches)
if err == nil {
return []*fseventsStream{stream}, nil
}
streams := make([]*fseventsStream, 0, (len(paths)+fseventsPathsPerStream-1)/fseventsPathsPerStream)
remainingPaths := paths
for len(remainingPaths) > 0 {
chunkLen := min(len(remainingPaths), fseventsPathsPerStream)
chunkPaths := remainingPaths[:chunkLen]
stream, err := startStream(chunkPaths, watchesForFSEventsPaths(watches, chunkPaths))
if err != nil {
stopFSEventsStreams(streams)
return nil, err
}
streams = append(streams, stream)
remainingPaths = remainingPaths[chunkLen:]
}
return streams, nil
}
func watchesForFSEventsPaths(watches []fseventsWatchSnapshot, paths []string) []fseventsWatchSnapshot {
if len(paths) == 0 {
return nil
}
filtered := make([]fseventsWatchSnapshot, 0, len(watches))
for _, watch := range watches {
if _, ok := slices.BinarySearch(paths, watch.w.physicalDir); ok {
filtered = append(filtered, watch)
}
}
return filtered
}
// startStream creates and starts one FSEventStream watching all supplied paths.
func (b *fsEventsBackend) startStream(paths []string, watches []fseventsWatchSnapshot) (*fseventsStream, error) {
if len(paths) == 0 {
return nil, nil
}
cfStrings := make([]uintptr, 0, len(paths))
for _, path := range paths {
dirCStr := append([]byte(path), 0)
cfDir := cfStringCreate(0, unsafe.Pointer(&dirCStr[0]), cfStringEncodingUTF8)
if cfDir == 0 {
for _, cfString := range cfStrings {
cfRelease(cfString)
}
return nil, errCFStringCreateNull
}
cfStrings = append(cfStrings, cfDir)
}
defer func() {
for _, cfString := range cfStrings {
cfRelease(cfString)
}
}()
pathsToWatch := cfArrayCreate(0, unsafe.Pointer(&cfStrings[0]), len(cfStrings), 0)
if pathsToWatch == 0 {
return nil, errCFArrayCreateNull
}
defer cfRelease(pathsToWatch)
cb, err := newStreamCallback(watches)
if err != nil {
return nil, err
}
state := &fseventsStream{cb: cb}
state.pinner.Pin(cb)
ctx := fsEventStreamContext{info: uintptr(unsafe.Pointer(cb))}
stream := fsEventStreamCreate(
0,
fsEventsCallbackAsmAddr,
unsafe.Pointer(&ctx),
pathsToWatch,
eventIDSinceNow,
0.001,
)
if stream == 0 {
cb.close()
state.cb = nil
state.pinner.Unpin()
return nil, errStreamCreateNull
}
fsEventStreamSetDispatchQueue(stream, cb.queue)
if fsEventStreamStart(stream) == 0 {
fsEventStreamInvalidate(stream)
fsEventStreamRelease(stream)
cb.close()
state.cb = nil
state.pinner.Unpin()
return nil, errStreamStartFailed
}
fsEventStreamFlushSync(stream)
state.stream.Store(stream)
return state, 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)
}
// 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 stopFSEventsStreams(streams []*fseventsStream) {
for _, stream := range streams {
stopFSEventsStream(stream)
}
}
func stopFSEventsStream(state *fseventsStream) {
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 {
return b.subscribeMany([]*dirWatch{w})
}
func (b *fsEventsBackend) subscribeMany(watchesToAdd []*dirWatch) error {
if len(watchesToAdd) == 0 {
return nil
}
states := make(map[*dirWatch]*fseventsState, len(watchesToAdd))
for _, w := range watchesToAdd {
if err := checkWatcher(w); err != nil {
return err
}
states[w] = &fseventsState{}
}
b.mu.Lock()
for w, state := range states {
w.state = state
b.watches[w] = state
}
watches := b.activeWatchesLocked()
b.mu.Unlock()
streams, err := b.startStreams(watches)
if err != nil {
b.mu.Lock()
for w, state := range states {
if b.watches[w] == state {
delete(b.watches, w)
w.state = nil
}
}
b.mu.Unlock()
return &dirWatchError{err: err, dirWatch: watchesToAdd[0]}
}
b.mu.Lock()
oldStreams := b.streams
b.streams = streams
b.mu.Unlock()
stopFSEventsStreams(oldStreams)
return nil
}
// closeWatch mirrors `fsEventsBackend::closeWatch`.
func (b *fsEventsBackend) closeWatch(w *dirWatch) error {
state, _ := w.state.(*fseventsState)
w.state = nil
if state == nil {
return nil
}
state.terminated.Store(true)
b.mu.Lock()
delete(b.watches, w)
watches := b.activeWatchesLocked()
b.mu.Unlock()
streams, err := b.startStreams(watches)
if err != nil {
return err
}
b.mu.Lock()
oldStreams := b.streams
b.streams = streams
b.mu.Unlock()
stopFSEventsStreams(oldStreams)
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()
const (
flagSize = unsafe.Sizeof(uint32(0))
idSize = unsafe.Sizeof(uint64(0))
)
if payload == nil || payload.paths == 0 || payload.flags == 0 || payload.ids == 0 {
return
}
numEvents := payload.numEvents
paths := payload.paths
flags := payload.flags
ids := payload.ids
watches := cb.watches
touched := map[*dirWatch]struct{}{}
for i := range numEvents {
flag := *(*uint32)(unsafe.Add(nil, flags+i*flagSize))
eventID := *(*uint64)(unsafe.Add(nil, ids+i*idSize))
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 {
var overflow error
switch {
case flag&flagUserDropped != 0:
overflow = errFSEventsUserDropped
case flag&flagKernelDropped != 0:
overflow = errFSEventsKernelDropped
default:
overflow = errFSEventsTooMany
}
for _, watch := range watches {
if watch.state.terminated.Load() {
continue
}
if fseventsOverflowMatches(watch.w, path) {
watch.w.events.setError(overflow)
touched[watch.w] = struct{}{}
}
}
}
if isDone {
break
}
if flag&^uint32(ignoredFlags) == 0 {
continue
}
rawPath := path
pathExists := false
pathExistsKnown := false
for _, watch := range watches {
if watch.state.terminated.Load() {
continue
}
w := watch.w
displayPath, ok := fseventsDisplayPath(w, rawPath)
if !ok {
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 displayPath == w.dir && !isRemoved && !isRenamed {
continue
}
switch {
case isRemoved && !isCreated:
if displayPath == w.dir {
w.events.removeWatchRootAt(displayPath, eventID)
} else {
w.events.removeAt(displayPath, eventID)
}
if w.terminateCallbacksForDeletedRoot(displayPath, eventID, fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) {
touched[w] = struct{}{}
}
if displayPath == w.dir {
watch.state.terminated.Store(true)
w.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated))
}
case isRenamed || (isRemoved && isCreated):
if !pathExistsKnown {
var st unix.Stat_t
pathExists = unix.Lstat(rawPath, &st) == nil
pathExistsKnown = true
}
if pathExists {
if displayPath == w.dir {
w.events.updateWatchRootAt(displayPath, eventID)
} else {
w.events.updateAt(displayPath, eventID)
}
} else {
if displayPath == w.dir {
w.events.removeWatchRootAt(displayPath, eventID)
} else {
w.events.removeAt(displayPath, eventID)
}
if w.terminateCallbacksForDeletedRoot(displayPath, eventID, fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) {
touched[w] = struct{}{}
}
if displayPath == w.dir {
watch.state.terminated.Store(true)
w.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated))
}
}
default:
if displayPath == w.dir {
w.events.updateWatchRootAt(displayPath, eventID)
} else {
w.events.updateAt(displayPath, eventID)
}
}
touched[w] = struct{}{}
}
}
for w := range touched {
w.notify()
}
}
func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) {
if isInDirectoryOrSelf(w.physicalDir, rawPath) {
return w.displayPath(rawPath), true
}
if w.physicalDir != w.dir && isInDirectoryOrSelf(w.dir, rawPath) {
return rawPath, true
}
return "", false
}
func fseventsOverflowMatches(w *dirWatch, rawPath string) bool {
if isInDirectoryOrSelf(w.physicalDir, rawPath) || isInDirectoryOrSelf(rawPath, w.physicalDir) {
return true
}
return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir))
}

View File

@@ -0,0 +1,553 @@
//go:build darwin && (amd64 || arm64)
package fswatch
import (
"io"
"math"
"os"
"runtime"
"slices"
"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,
// routes to matching 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_FSEventsGetCurrentEventId FSEventsGetCurrentEventId "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices"
var fse_FSEventsGetCurrentEventId_trampoline_addr uintptr
func fsEventsGetCurrentEventID() uint64 {
r1, _, _ := syscall_syscall6(fse_FSEventsGetCurrentEventId_trampoline_addr, 0, 0, 0, 0, 0, 0)
return uint64(r1)
}
//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{}
watches []fseventsWatchSnapshot
}
type fsEventsCallbackPayload struct {
numEvents uintptr
paths uintptr
flags uintptr
ids uintptr
}
func (p *fsEventsCallbackPayload) close() {
if p == nil {
return
}
if p.paths != 0 {
cfRelease(p.paths)
}
libcFree(p.flags)
libcFree(p.ids)
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(watches []fseventsWatchSnapshot) (*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{}),
watches: slices.Clone(watches),
}
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)
}
}

View File

@@ -0,0 +1,161 @@
//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_FSEventsGetCurrentEventId_trampoline<>(SB), NOSPLIT, $0-0
JMP fse_FSEventsGetCurrentEventId(SB)
GLOBL ·fse_FSEventsGetCurrentEventId_trampoline_addr(SB), RODATA, $8
DATA ·fse_FSEventsGetCurrentEventId_trampoline_addr(SB)/8, $fse_FSEventsGetCurrentEventId_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)

View File

@@ -0,0 +1,185 @@
//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+48: saved original IDs pointer
// RSP+56: copied IDs 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
MOVQ R9, 48(SP) // original IDs
// 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)
// Copy the event ID array into C heap memory owned by the Go event loop.
MOVQ 16(SP), DI
SHLQ $3, DI
XORL AX, AX
CALL fse_malloc(SB)
TESTQ AX, AX
JEQ freeFlags
MOVQ AX, 56(SP)
MOVQ AX, DI
MOVQ 48(SP), SI
MOVQ 16(SP), DX
SHLQ $3, DX
XORL AX, AX
CALL fse_memcpy(SB)
// Allocate and populate fsEventsCallbackPayload.
MOVQ $32, DI
XORL AX, AX
CALL fse_malloc(SB)
TESTQ AX, AX
JEQ freeIDs
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)
MOVQ 56(SP), CX
MOVQ CX, (3*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)
freeIDs:
MOVQ 56(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)

View File

@@ -0,0 +1,166 @@
//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
// RSP+64: saved original IDs pointer
// RSP+72: copied IDs 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
MOVD R5, 64(RSP) // original IDs
// 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)
// Copy the event ID array into C heap memory owned by the Go event loop.
MOVD 32(RSP), R0
LSL $3, R0, R0
BL fse_malloc(SB)
CBZ R0, freeFlags
MOVD R0, 72(RSP)
MOVD R0, R0
MOVD 64(RSP), R1
MOVD 32(RSP), R2
LSL $3, R2, R2
BL fse_memcpy(SB)
// Allocate and populate fsEventsCallbackPayload.
MOVD $32, R0
BL fse_malloc(SB)
CBZ R0, freeIDs
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)
MOVD 72(RSP), R6
MOVD R6, (3*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)
freeIDs:
MOVD 72(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)

View File

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

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,292 @@
//go:build darwin && (amd64 || arm64)
package fswatch
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"testing"
"time"
)
func newTestFSEventsWatcher(impl **fsEventsBackend) Watcher {
return &watcher{
name: "fsevents",
sequence: fsEventsGetCurrentEventID,
factory: func() watcherImpl {
*impl = newFSEventsBackend()
return *impl
},
}
}
func TestFSEventsSharedStreamAcrossWatches(t *testing.T) {
t.Parallel()
var impl *fsEventsBackend
watcherImpl := newTestFSEventsWatcher(&impl)
root := newTmpDir(t)
var subs []Watch
for i := range 5 {
dir := filepath.Join(root, fmt.Sprintf("dir%d", i))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
sub, err := watcherImpl.WatchDirectory(dir, func([]Event, error) {})
if err != nil {
t.Fatal(err)
}
subs = append(subs, sub)
}
t.Cleanup(func() {
for _, sub := range subs {
_ = sub.Close()
}
})
impl.mu.Lock()
streamCount := len(impl.streams)
watchCount := len(impl.watches)
impl.mu.Unlock()
if streamCount != 1 {
t.Fatalf("expected one shared FSEvents stream, got %d", streamCount)
}
if watchCount != len(subs) {
t.Fatalf("expected %d logical watches, got %d", len(subs), watchCount)
}
}
func TestFSEventsSharedStreamRoutesEvents(t *testing.T) {
t.Parallel()
var impl *fsEventsBackend
watcherImpl := newTestFSEventsWatcher(&impl)
root := newTmpDir(t)
dirA := filepath.Join(root, "a")
dirB := filepath.Join(root, "b")
if err := os.MkdirAll(dirA, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dirB, 0o755); err != nil {
t.Fatal(err)
}
time.Sleep(preSubscribeSleep(watcherImpl))
recA := newRecorder(t)
recA.watcher = watcherImpl
subA, err := watcherImpl.WatchDirectory(dirA, recA.callback)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = subA.Close() })
recB := newRecorder(t)
recB.watcher = watcherImpl
subB, err := watcherImpl.WatchDirectory(dirB, recB.callback)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = subB.Close() })
time.Sleep(settleSleep(watcherImpl))
fileA := filepath.Join(dirA, "file.ts")
if err := os.WriteFile(fileA, []byte("export {}"), 0o644); err != nil {
t.Fatal(err)
}
expectContains(t, recA, EventUpdate, fileA)
assertNoEventsForPath(t, recB.drainQuiet(500*time.Millisecond), fileA, "sibling watch saw event")
fileB := filepath.Join(dirB, "file.ts")
if err := os.WriteFile(fileB, []byte("export {}"), 0o644); err != nil {
t.Fatal(err)
}
expectContains(t, recB, EventUpdate, fileB)
assertNoEventsForPath(t, recA.drainQuiet(500*time.Millisecond), fileB, "sibling watch saw event")
}
func setupFSEventsConsolidatedParent(t *testing.T) (Watcher, string) {
t.Helper()
var impl *fsEventsBackend
watcherImpl := newTestFSEventsWatcher(&impl)
parent := filepath.Join(newTmpDir(t), "parent")
var subs []Watch
for i := range recursiveConsolidateThreshold {
dir := filepath.Join(parent, fmt.Sprintf("pkg%d", i))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
sub, err := watcherImpl.WatchDirectory(dir, func([]Event, error) {})
if err != nil {
t.Fatal(err)
}
subs = append(subs, sub)
}
t.Cleanup(func() {
for _, sub := range subs {
_ = sub.Close()
}
})
return watcherImpl, parent
}
func TestFSEventsConsolidatedWatchValidatesLogicalRoot(t *testing.T) {
t.Parallel()
watcherImpl, parent := setupFSEventsConsolidatedParent(t)
if sub, err := watcherImpl.WatchDirectory(filepath.Join(parent, "missing"), func([]Event, error) {}); err == nil {
_ = sub.Close()
t.Fatal("expected error subscribing to missing consolidated child")
}
file := filepath.Join(parent, "file")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if sub, err := watcherImpl.WatchDirectory(file, func([]Event, error) {}); err == nil {
_ = sub.Close()
t.Fatal("expected error subscribing to file consolidated child")
}
}
func TestFSEventsConsolidatedWatchTerminatesLogicalRoot(t *testing.T) {
t.Parallel()
watcherImpl, parent := setupFSEventsConsolidatedParent(t)
watched := filepath.Join(parent, "watched")
if err := os.MkdirAll(watched, 0o755); err != nil {
t.Fatal(err)
}
r := newRecorder(t)
r.watcher = watcherImpl
sub, err := watcherImpl.WatchDirectory(watched, r.callback, WithRecursive())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Close() })
time.Sleep(settleSleep(watcherImpl))
if err := os.RemoveAll(watched); err != nil {
t.Fatal(err)
}
expectEventSequence(t, r, []wantEvent{{EventDelete, watched}})
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()
if !slices.ContainsFunc(errs, func(err error) bool { return errors.Is(err, ErrWatchTerminated) }) {
t.Fatalf("expected ErrWatchTerminated after watched dir delete, got errs=%v", errs)
}
}
func TestFSEventsSharedStreamFallsBackToChunks(t *testing.T) {
t.Parallel()
const count = fseventsPathsPerStream*2 + 1
watches := make([]fseventsWatchSnapshot, 0, count)
for i := range count {
watches = append(watches, fseventsWatchSnapshot{
w: &dirWatch{physicalDir: fmt.Sprintf("/watch/dir%04d", i)},
state: &fseventsState{},
})
}
var calls []int
var watchCalls []int
streams, err := startFSEventsStreams(watches, func(paths []string, streamWatches []fseventsWatchSnapshot) (*fseventsStream, error) {
calls = append(calls, len(paths))
watchCalls = append(watchCalls, len(streamWatches))
if len(calls) == 1 {
return nil, errStreamStartFailed
}
return &fseventsStream{}, nil
})
if err != nil {
t.Fatal(err)
}
if len(streams) != 3 {
t.Fatalf("expected 3 chunked streams, got %d", len(streams))
}
wantCalls := []int{count, fseventsPathsPerStream, fseventsPathsPerStream, 1}
if !slices.Equal(calls, wantCalls) {
t.Fatalf("startStream calls = %v, want %v", calls, wantCalls)
}
if !slices.Equal(watchCalls, wantCalls) {
t.Fatalf("startStream watch calls = %v, want %v", watchCalls, wantCalls)
}
}
func TestWatchesForFSEventsPaths(t *testing.T) {
t.Parallel()
watchA := &dirWatch{physicalDir: "/watch/a"}
watchB := &dirWatch{physicalDir: "/watch/b"}
watchC := &dirWatch{physicalDir: "/watch/c"}
watches := []fseventsWatchSnapshot{
{w: watchA, state: &fseventsState{}},
{w: watchB, state: &fseventsState{}},
{w: watchC, state: &fseventsState{}},
}
got := watchesForFSEventsPaths(watches, []string{"/watch/a", "/watch/c"})
gotPaths := make([]string, 0, len(got))
for _, watch := range got {
gotPaths = append(gotPaths, watch.w.physicalDir)
}
slices.Sort(gotPaths)
want := []string{"/watch/a", "/watch/c"}
if !slices.Equal(gotPaths, want) {
t.Fatalf("watchesForFSEventsPaths = %v, want %v", gotPaths, want)
}
}
func TestFSEventsOverflowMatchesWatch(t *testing.T) {
t.Parallel()
w := &dirWatch{
dir: "/logical/root",
physicalDir: "/physical/root",
}
cases := []struct {
name string
rawPath string
want bool
}{
{name: "physical root", rawPath: "/physical/root", want: true},
{name: "physical descendant", rawPath: "/physical/root/sub", want: true},
{name: "physical ancestor", rawPath: "/physical", want: true},
{name: "logical root", rawPath: "/logical/root", want: true},
{name: "logical descendant", rawPath: "/logical/root/sub", want: true},
{name: "logical ancestor", rawPath: "/logical", want: true},
{name: "unrelated", rawPath: "/other/root", want: false},
{name: "sibling prefix", rawPath: "/physical/root2", want: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := fseventsOverflowMatches(w, c.rawPath); got != c.want {
t.Fatalf("fseventsOverflowMatches(%q) = %v, want %v", c.rawPath, got, c.want)
}
})
}
}

View File

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

View File

@@ -0,0 +1,784 @@
//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
watchPath 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<path, sub>
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.watchPath, &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.watchPath)
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.physicalDir, w.recursive, func(watchPath string, isDir bool) error {
path := w.displayPath(watchPath)
entries[path] = &dirEntry{path: path, watchPath: watchPath, 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(entry.watchPath)
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(entry.watchPath)
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)
type diskSnapshot struct {
entries []os.DirEntry
currentDisplayPaths map[string]struct{}
}
snapshots := map[string]diskSnapshot{}
// 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.
for _, sub := range subs {
baseEntry := sub.entries[path]
if baseEntry == nil {
continue
}
watchPath := baseEntry.watchPath
watchDirStart := watchPath + string(filepath.Separator)
snapshot, ok := snapshots[watchPath]
if !ok {
diskEntries, err := readEntries(watchPath)
if err != nil {
continue
}
snapshot.entries = diskEntries
snapshot.currentDisplayPaths = make(map[string]struct{}, len(diskEntries))
for _, ent := range diskEntries {
snapshot.currentDisplayPaths[dirStart+ent.Name()] = struct{}{}
}
snapshots[watchPath] = snapshot
}
entries := sub.entries
for _, ent := range snapshot.entries {
fullPath := dirStart + ent.Name()
fullWatchPath := watchDirStart + ent.Name()
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(fullWatchPath, &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(fullWatchPath, true, func(p string, pIsDir bool) error {
if p == fullWatchPath {
return nil
}
displayPath := sub.dirWatch.displayPath(p)
e := &dirEntry{path: displayPath, watchPath: p, isDir: pIsDir}
entries[displayPath] = e
sub.dirWatch.events.create(displayPath)
b.watchPath(sub.dirWatch, displayPath, entries)
return nil
})
}
continue
}
e := &dirEntry{path: fullPath, watchPath: fullWatchPath, 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(fullWatchPath, true, func(p string, pIsDir bool) error {
if p == fullWatchPath {
return nil // already handled above
}
displayPath := sub.dirWatch.displayPath(p)
entry := &dirEntry{path: displayPath, watchPath: p, isDir: pIsDir}
entries[displayPath] = entry
sub.dirWatch.events.create(displayPath)
b.watchPath(sub.dirWatch, displayPath, entries)
return nil
})
}
}
// Detect removals: entries directly under dirStart that no longer
// exist on disk.
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 := snapshot.currentDisplayPaths[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)
}
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -0,0 +1,222 @@
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 TestWalkDirDoesNotFollowRootSymlinkedDir(t *testing.T) { //nolint:paralleltest // runWalkDirTest calls t.Parallel.
runWalkDirTest(t, testWalkDirDoesNotFollowRootSymlinkedDir)
}
func testWalkDirDoesNotFollowRootSymlinkedDir(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)
}
link := filepath.Join(root, "link")
makeDirSymlink(t, target, link)
if err := walk(link, true, nil); err == nil {
t.Fatal("expected error for root symlinked directory")
}
}
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")
makeDirSymlink(t, target, link)
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)
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,74 @@
//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 ||
rootData.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,474 @@
//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.physicalDir)
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.physicalDir)
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
watchPath := s.dirWatch.physicalDir + "\\" + 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(watchPath); 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:
seq := s.dirWatch.events.removeAndGetSequence(path)
if s.dirWatch.terminateCallbacksForDeletedRoot(path, seq, fmt.Errorf("%w: watched directory removed", ErrWatchTerminated)) {
s.dirWatch.notify()
}
}
}
// 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.
}