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

641 lines
20 KiB
Go

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