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