Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions tsc/internal/execute/watchmanager/watchmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,16 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error {
// already present in the set, or when it is contained within a recursive watch
// directory already in the set.
type DirWatchSet struct {
opts tspath.ComparePathsOptions
dirs map[string]bool
opts tspath.ComparePathsOptions
dirs map[string]bool
names map[string]string
}

func NewDirWatchSet(opts tspath.ComparePathsOptions) *DirWatchSet {
return &DirWatchSet{
opts: opts,
dirs: make(map[string]bool),
opts: opts,
dirs: make(map[string]bool),
names: make(map[string]string),
}
}

Expand All @@ -309,7 +311,11 @@ func (s *DirWatchSet) canonical(dir string) string {
}

func (s *DirWatchSet) Set(dir string, recursive bool) {
original := dir
dir = s.canonical(dir)
if _, exists := s.names[dir]; !exists {
s.names[dir] = original
}
s.dirs[dir] = s.dirs[dir] || recursive
}

Expand All @@ -329,7 +335,11 @@ func (s *DirWatchSet) Covered(dir string) bool {
}

func (s *DirWatchSet) Dirs() map[string]bool {
return s.dirs
dirs := make(map[string]bool, len(s.dirs))
for key, recursive := range s.dirs {
dirs[s.names[key]] = recursive
}
return dirs
}

func (wm *WatchManager) IsPathUnderWatch(path string, opts tspath.ComparePathsOptions) bool {
Expand Down
4 changes: 2 additions & 2 deletions tsc/internal/execute/watchmanager/watchmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ func TestDirWatchSetCanonicalDedup(t *testing.T) {

dirs := insensitive.Dirs()
assert.Equal(t, len(dirs), 1, "differently-cased dirs must collapse to one entry")
_, canonical := dirs["/repo/node_modules/pkgname"]
assert.Assert(t, canonical, "Dirs must be keyed by the canonicalized path")
_, original := dirs["/repo/Node_Modules/PkgName"]
assert.Assert(t, original, "Dirs must retain the original spelling used for registration")

sensitive := NewDirWatchSet(caseSensitiveOpts)
sensitive.Set("/repo/Node_Modules/PkgName", false)
Expand Down
28 changes: 28 additions & 0 deletions tsc/internal/fswatch/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ 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.

### macOS path comparison

FSEvents and kqueue use the watched volume's case sensitivity, queried with
`pathconf`, rather than assuming event paths have the same spelling as the
subscription. On case-insensitive volumes, CoreFoundation case folding and NFC
normalization recognize Unicode aliases, including expansions such as sharp s /
`SS` and ligatures / letter sequences. This is not width- or
diacritic-insensitive comparison.

Folded forms are comparison keys, never displayed or opened paths. Watch roots
and subscribed filenames are normalized to NFC. Directory events retain the
caller's root casing, with NFC suffixes for FSEvents and on-disk child spellings
for kqueue; `WatchFile` events use the subscribed NFC filename. Rebasing uses
original path boundaries rather than folded byte lengths. FSEvents routing,
shared callback filtering, overflow matching, and logical-root deletion use the
same comparison rules.

An allocation-free ASCII comparison fast path avoids native folding. Watch-root
comparison forms are prepared at subscription time, while event paths are
folded lazily and reused across routing comparisons and within callback
filtering passes. `WatchFile` reuses its parent subscription's comparer rather
than querying filesystem case sensitivity twice.

The native fold has been compared with aliases and distinct names on
case-insensitive APFS, but is not a guarantee of identical lookup tables on every
filesystem or macOS version. Case-sensitive comparison and watcher backends on
other platforms remain unchanged.

## New backends

**fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It
Expand Down
23 changes: 17 additions & 6 deletions tsc/internal/fswatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,20 @@ if errors.Is(err, fswatch.ErrWatchTerminated) {
- 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)
```
- Paths in events are absolute. Subscribing through a directory symlink follows
its target while preserving the caller-visible root in delivered paths.

On macOS, watch roots and subscribed filenames are normalized to NFC. On volumes
reporting case-insensitive lookup, FSEvents and kqueue match paths using
CoreFoundation's case-insensitive fold, including expansions such as sharp s /
`SS` and ligatures / letter sequences. This is not width- or
diacritic-insensitive comparison. Folded forms are only comparison keys:
directory events retain the caller's root casing, with an NFC suffix for
FSEvents and the on-disk child spelling for kqueue; file events use the
subscribed NFC filename. Symlink-root subscriptions likewise retain the
caller-visible root.

The fold has been compared with actual aliases and distinct names on
case-insensitive APFS. It is not a guarantee of identical Unicode lookup
tables on every filesystem or macOS version. Case-sensitive volumes and
watcher backends on other platforms retain exact comparison.
40 changes: 31 additions & 9 deletions tsc/internal/fswatch/canonicalize_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@

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.
import (
"os"

"golang.org/x/sys/unix"
)

// canonicalizePath normalizes watch keys, subscribed filenames, and incoming
// FSEvents paths to NFC. kqueue retains on-disk child spellings for its fd
// bookkeeping and directory events; on case-insensitive volumes, the native
// path comparer handles normalization differences when filtering WatchFile.
func canonicalizePath(p string) string { return normalizeNFC(p) }

func (w *watcher) pathComparer(dir string) (pathComparer, error) {
if w.name != "fsevents" && w.name != "kqueue" {
return pathComparer{}, nil
}
c, err := PathComparerForPath(dir)
return c.comparer, err
}

// PathComparerForPath queries an existing path's volume. Errors are returned to
// the caller; a failed query must not silently enable or disable native folding.
func PathComparerForPath(path string) (PathComparer, error) {
// _PC_CASE_SENSITIVE from sys/unistd.h. Query the watched volume rather
// than assuming every volume mounted on macOS is case-insensitive.
const pcCaseSensitive = 11
sensitive, err := unix.Pathconf(path, pcCaseSensitive)
if err != nil {
return PathComparer{}, &os.PathError{Op: "pathconf", Path: path, Err: err}
}
return PathComparer{comparer: pathComparer{ignoreCase: sensitive == 0}}, nil
}
16 changes: 16 additions & 0 deletions tsc/internal/fswatch/canonicalize_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@

package fswatch

const nativePathFolding = false

func foldNativePath(string) string {
panic("fswatch: native path folding is only available on Darwin")
}

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

func (w *watcher) pathComparer(dir string) (pathComparer, error) {
return pathComparer{}, nil
}

// PathComparerForPath returns exact comparison on platforms without native
// Darwin watch aliases. It does not inspect the host filesystem.
func PathComparerForPath(path string) (PathComparer, error) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API has leaked in from future changes; it's probably not harmful to leave.

return PathComparer{}, nil
}
41 changes: 33 additions & 8 deletions tsc/internal/fswatch/fsevents_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
if path == "" {
continue
}
comparison := comparisonPath{path: path}

isRemoved := flag&flagItemRemoved != 0
isRenamed := flag&flagItemRenamed != 0
Expand All @@ -527,7 +528,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
if watch.state.terminated.Load() {
continue
}
if fseventsOverflowMatches(watch.w, path) {
if fseventsOverflowMatchesPrepared(watch.w, &comparison) {
watch.w.events.setError(overflow)
touched[watch.w] = struct{}{}
}
Expand All @@ -551,7 +552,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
continue
}
w := watch.w
displayPath, ok := fseventsDisplayPath(w, rawPath)
displayPath, ok := fseventsDisplayPathPrepared(w, &comparison)
if !ok {
continue
}
Expand Down Expand Up @@ -623,18 +624,42 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
}

func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) {
if isInDirectoryOrSelf(w.physicalDir, rawPath) {
return w.displayPath(rawPath), true
path := comparisonPath{path: rawPath}
return fseventsDisplayPathPrepared(w, &path)
}

func fseventsDisplayPathPrepared(w *dirWatch, rawPath *comparisonPath) (string, bool) {
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
if path, ok := w.comparer.rebasePrepared(rawPath, physical, w.dir); ok {
return path, true
}
if w.physicalDir != w.dir && isInDirectoryOrSelf(w.dir, rawPath) {
return rawPath, true
if w.physicalDir != w.dir {
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
return w.comparer.rebasePrepared(rawPath, logical, w.dir)
}
return "", false
}

func fseventsOverflowMatches(w *dirWatch, rawPath string) bool {
if isInDirectoryOrSelf(w.physicalDir, rawPath) || isInDirectoryOrSelf(rawPath, w.physicalDir) {
path := comparisonPath{path: rawPath}
return fseventsOverflowMatchesPrepared(w, &path)
}

func fseventsOverflowMatchesPrepared(w *dirWatch, rawPath *comparisonPath) bool {
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
if _, ok := w.comparer.suffixPrepared(physical, rawPath); ok {
return true
}
return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir))
if _, ok := w.comparer.suffixPrepared(*rawPath, &physical); ok {
return true
}
if w.physicalDir != w.dir {
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
if _, ok := w.comparer.suffixPrepared(logical, rawPath); ok {
return true
}
_, ok := w.comparer.suffixPrepared(*rawPath, &logical)
return ok
}
return false
}
51 changes: 48 additions & 3 deletions tsc/internal/fswatch/fsevents_darwin_ffi.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"os"
"runtime"
"slices"
"strings"
"syscall"
"unicode/utf8"
"unsafe"

"golang.org/x/sys/unix"
Expand Down Expand Up @@ -150,13 +152,16 @@ func cfArrayGetValueAtIndex(array uintptr, index int) uintptr {
// 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:
// bytes. The library normalizes watch paths and incoming FSEvents paths 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;
// - WatchFile filters and directory routing compare the same normalized paths;
// - subscribers can compare event paths against their own NFC strings.
//
// kqueue retains on-disk child spellings; its WatchFile comparisons also use
// the native fold below on volumes reporting case-insensitive lookup.
//
// 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
Expand All @@ -183,6 +188,46 @@ func cfStringNormalize(mutStr uintptr, form uintptr) {
_, _, _ = syscall_syscall6(fse_CFStringNormalize_trampoline_addr, mutStr, form, 0, 0, 0, 0)
}

//go:cgo_import_dynamic fse_CFStringFold CFStringFold "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"

var fse_CFStringFold_trampoline_addr uintptr

const nativePathFolding = true

// foldNativePath is a comparison form, never a displayed or opened path.
// Case folding expands sharp s and ligatures without making diacritics,
// dotless i, circled letters, or character widths interchangeable.
func foldNativePath(s string) string {
if isASCII(s) {
return strings.ToLower(s)
}
if !utf8.ValidString(s) || strings.IndexByte(s, 0) >= 0 {
return ""
}
cstr := append([]byte(s), 0)
src := cfStringCreate(0, unsafe.Pointer(&cstr[0]), cfStringEncodingUTF8)
if src == 0 {
panic("fswatch: cannot create CFString for path folding")
}
defer cfRelease(src)
mut := cfStringCreateMutableCopy(0, 0, src)
if mut == 0 {
panic("fswatch: cannot copy CFString for path folding")
}
defer cfRelease(mut)
// Normalize before folding as well: a decomposed capital I with dot
// must have the same comparison form as precomposed dotted capital I.
cfStringNormalize(mut, cfStringNormalizationFormC)
const cfCompareCaseInsensitive = 1
_, _, _ = syscall_syscall6(fse_CFStringFold_trampoline_addr, mut, cfCompareCaseInsensitive, 0, 0, 0, 0)
cfStringNormalize(mut, cfStringNormalizationFormC)
folded := cfStringToGo(mut)
if folded == "" {
panic("fswatch: cannot extract folded CFString")
}
return folded
}

//go:cgo_import_dynamic fse_CFStringGetLength CFStringGetLength "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"

var fse_CFStringGetLength_trampoline_addr uintptr
Expand Down
6 changes: 6 additions & 0 deletions tsc/internal/fswatch/fsevents_darwin_ffi.s
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ TEXT fse_CFStringNormalize_trampoline<>(SB), NOSPLIT, $0-0
GLOBL ·fse_CFStringNormalize_trampoline_addr(SB), RODATA, $8
DATA ·fse_CFStringNormalize_trampoline_addr(SB)/8, $fse_CFStringNormalize_trampoline<>(SB)

TEXT fse_CFStringFold_trampoline<>(SB), NOSPLIT, $0-0
JMP fse_CFStringFold(SB)

GLOBL ·fse_CFStringFold_trampoline_addr(SB), RODATA, $8
DATA ·fse_CFStringFold_trampoline_addr(SB)/8, $fse_CFStringFold_trampoline<>(SB)

TEXT fse_CFStringGetLength_trampoline<>(SB), NOSPLIT, $0-0
JMP fse_CFStringGetLength(SB)

Expand Down
Loading
Loading