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

13 KiB

Changes from upstream @parcel/watcher

This Go port started from the C++ @parcel/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 dirWatches, 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, 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 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.