Files
kjol/tools/tsgo/internal/fswatch/CHANGES.md
2026-07-09 16:50:43 -04:00

276 lines
13 KiB
Markdown

# 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.