vendor tsgo
This commit is contained in:
275
tools/tsgo/internal/fswatch/CHANGES.md
Normal file
275
tools/tsgo/internal/fswatch/CHANGES.md
Normal 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.
|
||||
22
tools/tsgo/internal/fswatch/LICENSE
Normal file
22
tools/tsgo/internal/fswatch/LICENSE
Normal 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.
|
||||
98
tools/tsgo/internal/fswatch/README.md
Normal file
98
tools/tsgo/internal/fswatch/README.md
Normal 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)
|
||||
```
|
||||
14
tools/tsgo/internal/fswatch/canonicalize_darwin.go
Normal file
14
tools/tsgo/internal/fswatch/canonicalize_darwin.go
Normal 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) }
|
||||
8
tools/tsgo/internal/fswatch/canonicalize_other.go
Normal file
8
tools/tsgo/internal/fswatch/canonicalize_other.go
Normal 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 }
|
||||
16
tools/tsgo/internal/fswatch/cgmanifest.json
Normal file
16
tools/tsgo/internal/fswatch/cgmanifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
155
tools/tsgo/internal/fswatch/debounce.go
Normal file
155
tools/tsgo/internal/fswatch/debounce.go
Normal 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{})
|
||||
}
|
||||
}
|
||||
271
tools/tsgo/internal/fswatch/event.go
Normal file
271
tools/tsgo/internal/fswatch/event.go
Normal 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
|
||||
}
|
||||
147
tools/tsgo/internal/fswatch/eventlist_test.go
Normal file
147
tools/tsgo/internal/fswatch/eventlist_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
758
tools/tsgo/internal/fswatch/fanotify_linux.go
Normal file
758
tools/tsgo/internal/fswatch/fanotify_linux.go
Normal 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
|
||||
}
|
||||
121
tools/tsgo/internal/fswatch/fanotify_linux_test.go
Normal file
121
tools/tsgo/internal/fswatch/fanotify_linux_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
640
tools/tsgo/internal/fswatch/fsevents_darwin.go
Normal file
640
tools/tsgo/internal/fswatch/fsevents_darwin.go
Normal 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))
|
||||
}
|
||||
553
tools/tsgo/internal/fswatch/fsevents_darwin_ffi.go
Normal file
553
tools/tsgo/internal/fswatch/fsevents_darwin_ffi.go
Normal 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)
|
||||
}
|
||||
}
|
||||
161
tools/tsgo/internal/fswatch/fsevents_darwin_ffi.s
Normal file
161
tools/tsgo/internal/fswatch/fsevents_darwin_ffi.s
Normal 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)
|
||||
185
tools/tsgo/internal/fswatch/fsevents_darwin_ffi_amd64.s
Normal file
185
tools/tsgo/internal/fswatch/fsevents_darwin_ffi_amd64.s
Normal 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)
|
||||
166
tools/tsgo/internal/fswatch/fsevents_darwin_ffi_arm64.s
Normal file
166
tools/tsgo/internal/fswatch/fsevents_darwin_ffi_arm64.s
Normal 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)
|
||||
151
tools/tsgo/internal/fswatch/fsevents_darwin_ffi_arm64_test.go
Normal file
151
tools/tsgo/internal/fswatch/fsevents_darwin_ffi_arm64_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
176
tools/tsgo/internal/fswatch/fsevents_darwin_nfd_test.go
Normal file
176
tools/tsgo/internal/fswatch/fsevents_darwin_nfd_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
292
tools/tsgo/internal/fswatch/fsevents_darwin_shared_test.go
Normal file
292
tools/tsgo/internal/fswatch/fsevents_darwin_shared_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
427
tools/tsgo/internal/fswatch/inotify_linux.go
Normal file
427
tools/tsgo/internal/fswatch/inotify_linux.go
Normal 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
|
||||
}
|
||||
784
tools/tsgo/internal/fswatch/kqueue.go
Normal file
784
tools/tsgo/internal/fswatch/kqueue.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
193
tools/tsgo/internal/fswatch/testutil_test.go
Normal file
193
tools/tsgo/internal/fswatch/testutil_test.go
Normal 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)
|
||||
}
|
||||
57
tools/tsgo/internal/fswatch/walkdir.go
Normal file
57
tools/tsgo/internal/fswatch/walkdir.go
Normal 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
|
||||
}
|
||||
8
tools/tsgo/internal/fswatch/walkdir_dirent_darwin.go
Normal file
8
tools/tsgo/internal/fswatch/walkdir_dirent_darwin.go
Normal 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 }
|
||||
8
tools/tsgo/internal/fswatch/walkdir_dirent_fileno.go
Normal file
8
tools/tsgo/internal/fswatch/walkdir_dirent_fileno.go
Normal 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 }
|
||||
8
tools/tsgo/internal/fswatch/walkdir_dirent_linux.go
Normal file
8
tools/tsgo/internal/fswatch/walkdir_dirent_linux.go
Normal 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 }
|
||||
22
tools/tsgo/internal/fswatch/walkdir_dirent_noreclen.go
Normal file
22
tools/tsgo/internal/fswatch/walkdir_dirent_noreclen.go
Normal 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)
|
||||
}
|
||||
7
tools/tsgo/internal/fswatch/walkdir_other.go
Normal file
7
tools/tsgo/internal/fswatch/walkdir_other.go
Normal 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)
|
||||
}
|
||||
222
tools/tsgo/internal/fswatch/walkdir_test.go
Normal file
222
tools/tsgo/internal/fswatch/walkdir_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
145
tools/tsgo/internal/fswatch/walkdir_unix.go
Normal file
145
tools/tsgo/internal/fswatch/walkdir_unix.go
Normal 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
|
||||
}
|
||||
74
tools/tsgo/internal/fswatch/walkdir_windows.go
Normal file
74
tools/tsgo/internal/fswatch/walkdir_windows.go
Normal 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
|
||||
}
|
||||
1033
tools/tsgo/internal/fswatch/watcher.go
Normal file
1033
tools/tsgo/internal/fswatch/watcher.go
Normal file
File diff suppressed because it is too large
Load Diff
2945
tools/tsgo/internal/fswatch/watcher_test.go
Normal file
2945
tools/tsgo/internal/fswatch/watcher_test.go
Normal file
File diff suppressed because it is too large
Load Diff
474
tools/tsgo/internal/fswatch/windows.go
Normal file
474
tools/tsgo/internal/fswatch/windows.go
Normal 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.
|
||||
}
|
||||
Reference in New Issue
Block a user