From 37b4252b8d4c5a0ac79988d96563bc746a14b121 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:13:24 -0700 Subject: [PATCH 1/5] Fix FSEvents routing for differently cased watch paths Use the watched volume's case sensitivity when routing FSEvents and filtering file watches. Preserve caller-visible root casing, including shared callbacks, overflow matching, and root deletion handling. --- tsc/internal/fswatch/CHANGES.md | 7 + tsc/internal/fswatch/canonicalize_darwin.go | 20 +++ tsc/internal/fswatch/canonicalize_other.go | 4 + tsc/internal/fswatch/fsevents_darwin.go | 12 +- .../fswatch/fsevents_darwin_case_test.go | 156 ++++++++++++++++++ tsc/internal/fswatch/pathcompare.go | 56 +++++++ tsc/internal/fswatch/pathcompare_test.go | 63 +++++++ tsc/internal/fswatch/watcher.go | 65 +++++--- tsc/internal/fswatch/watcher_test.go | 2 +- 9 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 tsc/internal/fswatch/fsevents_darwin_case_test.go create mode 100644 tsc/internal/fswatch/pathcompare.go create mode 100644 tsc/internal/fswatch/pathcompare_test.go diff --git a/tsc/internal/fswatch/CHANGES.md b/tsc/internal/fswatch/CHANGES.md index 3f3fb4153991b..36d35bb066957 100644 --- a/tsc/internal/fswatch/CHANGES.md +++ b/tsc/internal/fswatch/CHANGES.md @@ -149,6 +149,13 @@ 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. +FSEvents path routing and file filtering use the watched volume's case +sensitivity, queried with `pathconf`, rather than assuming event paths have the +same casing as the subscription. Delivered paths retain the caller's watch-root +casing (or the entire requested path for `WatchFile`), while descendant names +retain the casing reported by FSEvents. Overflow and logical-root deletion +matching use the same comparison rules. + ## New backends **fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It diff --git a/tsc/internal/fswatch/canonicalize_darwin.go b/tsc/internal/fswatch/canonicalize_darwin.go index 9a81ffdfb5b77..8a54f485079cf 100644 --- a/tsc/internal/fswatch/canonicalize_darwin.go +++ b/tsc/internal/fswatch/canonicalize_darwin.go @@ -2,6 +2,12 @@ package fswatch +import ( + "os" + + "golang.org/x/sys/unix" +) + // 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; @@ -12,3 +18,17 @@ package fswatch // library ingests to NFC keeps watch keys, dirWatch lookups, WatchFile // filters, and event paths all in one consistent form. func canonicalizePath(p string) string { return normalizeNFC(p) } + +func (w *watcher) pathComparer(dir string) (pathComparer, error) { + if w.name != "fsevents" { + return pathComparer{}, nil + } + // _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(dir, pcCaseSensitive) + if err != nil { + return pathComparer{}, &os.PathError{Op: "pathconf", Path: dir, Err: err} + } + return pathComparer{ignoreCase: sensitive == 0}, nil +} diff --git a/tsc/internal/fswatch/canonicalize_other.go b/tsc/internal/fswatch/canonicalize_other.go index 5ed784c394748..e8b3cfd9ef062 100644 --- a/tsc/internal/fswatch/canonicalize_other.go +++ b/tsc/internal/fswatch/canonicalize_other.go @@ -6,3 +6,7 @@ package fswatch // 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 +} diff --git a/tsc/internal/fswatch/fsevents_darwin.go b/tsc/internal/fswatch/fsevents_darwin.go index 6e0614ef0aad6..f414e6266b7a6 100644 --- a/tsc/internal/fswatch/fsevents_darwin.go +++ b/tsc/internal/fswatch/fsevents_darwin.go @@ -623,18 +623,18 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { } func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) { - if isInDirectoryOrSelf(w.physicalDir, rawPath) { - return w.displayPath(rawPath), true + if path, ok := w.comparer.rebase(rawPath, w.physicalDir, w.dir); ok { + return path, true } - if w.physicalDir != w.dir && isInDirectoryOrSelf(w.dir, rawPath) { - return rawPath, true + if w.physicalDir != w.dir { + return w.comparer.rebase(rawPath, w.dir, w.dir) } return "", false } func fseventsOverflowMatches(w *dirWatch, rawPath string) bool { - if isInDirectoryOrSelf(w.physicalDir, rawPath) || isInDirectoryOrSelf(rawPath, w.physicalDir) { + if w.comparer.contains(w.physicalDir, rawPath) || w.comparer.contains(rawPath, w.physicalDir) { return true } - return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir)) + return w.physicalDir != w.dir && (w.comparer.contains(w.dir, rawPath) || w.comparer.contains(rawPath, w.dir)) } diff --git a/tsc/internal/fswatch/fsevents_darwin_case_test.go b/tsc/internal/fswatch/fsevents_darwin_case_test.go new file mode 100644 index 0000000000000..b8294dd2f283e --- /dev/null +++ b/tsc/internal/fswatch/fsevents_darwin_case_test.go @@ -0,0 +1,156 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestFSEventsDifferentCasing(t *testing.T) { + t.Parallel() + + for _, recursive := range []bool{false, true} { + name := "nonrecursive" + if recursive { + name = "recursive" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + parent := newTmpDir(t) + diskDir := filepath.Join(parent, "MixedCase") + watchDir := filepath.Join(parent, "mixedcase") + if err := os.Mkdir(diskDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(watchDir); errors.Is(err, os.ErrNotExist) { + t.Skip("requires a case-insensitive filesystem") + } else if err != nil { + t.Fatal(err) + } + + var opts []WatchOption + if recursive { + opts = append(opts, WithRecursive()) + } + r, _ := subscribeForOpts(t, watchDir, FSEvents(), opts...) + file := filepath.Join(diskDir, "File.ts") + want := filepath.Join(watchDir, "File.ts") + if err := os.WriteFile(file, []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, want) + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventDelete, want) + }) + } +} + +func TestFSEventsWatchFileDifferentCasing(t *testing.T) { + t.Parallel() + dir := newTmpDir(t) + diskFile := filepath.Join(dir, "File.ts") + watchFile := filepath.Join(dir, "file.ts") + if err := os.WriteFile(diskFile, []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(watchFile); errors.Is(err, os.ErrNotExist) { + t.Skip("requires a case-insensitive filesystem") + } else if err != nil { + t.Fatal(err) + } + r, _ := subscribeFileFor(t, watchFile, FSEvents()) + if err := os.WriteFile(diskFile, []byte("export const x = 1;"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventUpdate, watchFile) + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + expectContains(t, r, EventDelete, watchFile) + + missingFile := filepath.Join(dir, "missing.ts") + missing, _ := subscribeFileFor(t, missingFile, FSEvents()) + if err := os.WriteFile(filepath.Join(dir, "Missing.ts"), []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, missing, EventUpdate, missingFile) +} + +func TestFSEventsCaseSensitiveRouting(t *testing.T) { + t.Parallel() + for _, ignoreCase := range []bool{false, true} { + w := &dirWatch{ + dir: "/logical/root", + physicalDir: "/physical/root", + comparer: pathComparer{ignoreCase: ignoreCase}, + } + for _, root := range []string{"/PHYSICAL/ROOT", "/LOGICAL/ROOT"} { + for _, suffix := range []string{"", "/File.ts", "/Nested/File.ts"} { + path, ok := fseventsDisplayPath(w, root+suffix) + if ok != ignoreCase || ok && path != w.dir+suffix { + t.Errorf("display path for %q, ignoreCase=%v: got (%q, %v)", root+suffix, ignoreCase, path, ok) + } + } + if fseventsOverflowMatches(w, root+"/Nested") != ignoreCase { + t.Errorf("overflow descendant %q, ignoreCase=%v", root, ignoreCase) + } + if fseventsOverflowMatches(w, filepath.Dir(root)) != ignoreCase { + t.Errorf("overflow ancestor %q, ignoreCase=%v", root, ignoreCase) + } + if _, ok := fseventsDisplayPath(w, root+"2/File.ts"); ok { + t.Errorf("matched sibling of %q, ignoreCase=%v", root, ignoreCase) + } + if fseventsOverflowMatches(w, root+"2") { + t.Errorf("overflow matched sibling of %q, ignoreCase=%v", root, ignoreCase) + } + } + } +} + +func TestFSEventsConsolidatedDifferentCasing(t *testing.T) { + t.Parallel() + for _, recursive := range []bool{false, true} { + dw := newDirectWatcher(t, "/parent") + dw.comparer = pathComparer{ignoreCase: true} + child := "/parent/child" + var got []Event + var gotErr error + dw.watch(child, child, recursive, func(events []Event, err error) { + got = append(got, events...) + gotErr = err + }, func(path string) bool { + return path == child+"/Ignored.ts" + }) + dw.events.update("/parent/CHILD/File.ts") + dw.events.update("/parent/CHILD/Ignored.ts") + dw.events.update("/parent/CHILD2/File.ts") + dw.events.update("/parent/CHILD/Nested/File.ts") + dw.triggerCallbacks() + wantCount := 1 + if recursive { + wantCount++ + } + if len(got) != wantCount || gotErr != nil { + t.Fatalf("recursive=%v: got events=%v, err=%v", recursive, got, gotErr) + } + for _, e := range got { + if e.Path != child+"/File.ts" && e.Path != child+"/Nested/File.ts" { + t.Fatalf("unexpected path %q", e.Path) + } + } + got = nil + if !dw.terminateCallbacksForDeletedRoot("/parent/CHILD", 1, ErrWatchTerminated) { + t.Fatal("expected differently cased child root to terminate") + } + dw.events.removeWatchRootAt("/parent/CHILD", 1) + dw.triggerCallbacks() + if !errors.Is(gotErr, ErrWatchTerminated) || len(got) != 1 || got[0].Kind != EventDelete || got[0].Path != child { + t.Fatalf("expected child deletion and termination: got events=%v, err=%v", got, gotErr) + } + } +} diff --git a/tsc/internal/fswatch/pathcompare.go b/tsc/internal/fswatch/pathcompare.go new file mode 100644 index 0000000000000..dbb20aee01a77 --- /dev/null +++ b/tsc/internal/fswatch/pathcompare.go @@ -0,0 +1,56 @@ +package fswatch + +import "strings" + +type pathComparer struct { + ignoreCase bool +} + +func (c pathComparer) equal(a, b string) bool { + return a == b || c.ignoreCase && strings.EqualFold(a, b) +} + +// suffix returns the part of path below root, respecting directory boundaries. +// Comparing components avoids assuming case-equivalent UTF-8 strings have the +// same byte length. +func (c pathComparer) suffix(root, path string) (string, bool) { + if isInDirectoryOrSelf(root, path) { + return path[len(root):], true + } + if !c.ignoreCase || root == "" { + return "", false + } + for { + rootPart, rootRest, rootMore := strings.Cut(root, "/") + pathPart, pathRest, pathMore := strings.Cut(path, "/") + if !strings.EqualFold(rootPart, pathPart) { + return "", false + } + if !rootMore { + if pathMore { + return path[len(pathPart):], true + } + return "", true + } + if !pathMore { + return "", false + } + root, path = rootRest, pathRest + } +} + +func (c pathComparer) contains(root, path string) bool { + _, ok := c.suffix(root, path) + return ok +} + +func (c pathComparer) rebase(path, from, to string) (string, bool) { + if isInDirectoryOrSelf(from, path) { + return rebasePath(path, from, to), true + } + suffix, ok := c.suffix(from, path) + if !ok { + return "", false + } + return joinPathSuffix(to, suffix), true +} diff --git a/tsc/internal/fswatch/pathcompare_test.go b/tsc/internal/fswatch/pathcompare_test.go new file mode 100644 index 0000000000000..87d9668f0f0cf --- /dev/null +++ b/tsc/internal/fswatch/pathcompare_test.go @@ -0,0 +1,63 @@ +package fswatch + +import "testing" + +func TestPathComparer(t *testing.T) { + t.Parallel() + tests := []struct { + root string + path string + suffix string + exact bool + ignoreCase bool + }{ + {"/root", "/root", "", true, true}, + {"/root", "/root/file.ts", "/file.ts", true, true}, + {"/root", "/ROOT", "", false, true}, + {"/root", "/ROOT/File.ts", "/File.ts", false, true}, + {"/root", "/ROOT/Nested/File.ts", "/Nested/File.ts", false, true}, + {"/root", "/ROOT2/File.ts", "", false, false}, + {"/root", "/roo", "", false, false}, + {"/root/sub", "/ROOT", "", false, false}, + {"/root", "/other/File.ts", "", false, false}, + {"/", "/File.ts", "File.ts", true, true}, + {"", "/File.ts", "", false, false}, + {"/caf\u00e9", "/CAF\u00c9/File.ts", "/File.ts", false, true}, + {"/s", "/\u017f/File.ts", "/File.ts", false, true}, + {"/\u017f", "/S/File.ts", "/File.ts", false, true}, + } + for _, tt := range tests { + for _, ignoreCase := range []bool{false, true} { + comparer := pathComparer{ignoreCase: ignoreCase} + want := tt.exact + if ignoreCase { + want = tt.ignoreCase + } + suffix, ok := comparer.suffix(tt.root, tt.path) + if ok != want || ok && suffix != tt.suffix { + t.Errorf("suffix(%q, %q), ignoreCase=%v: got (%q, %v), want (%q, %v)", tt.root, tt.path, ignoreCase, suffix, ok, tt.suffix, want) + } + } + } +} + +func TestFileCallbackCaseSensitivity(t *testing.T) { + t.Parallel() + for _, ignoreCase := range []bool{false, true} { + var got []Event + cb := fileCallback("/root/file.ts", func(events []Event, err error) { + got = append(got, events...) + }, pathComparer{ignoreCase: ignoreCase}) + cb([]Event{ + {Kind: EventUpdate, Path: "/root/FILE.ts"}, + {Kind: EventUpdate, Path: "/root/other.ts"}, + }, nil) + if ignoreCase { + if len(got) != 1 || got[0].Path != "/root/file.ts" { + t.Fatalf("case-insensitive callback: got %v", got) + } + } else if len(got) != 0 { + t.Fatalf("case-sensitive callback: got %v", got) + } + } +} diff --git a/tsc/internal/fswatch/watcher.go b/tsc/internal/fswatch/watcher.go index fa8a7489841d7..b6d6a3c157379 100644 --- a/tsc/internal/fswatch/watcher.go +++ b/tsc/internal/fswatch/watcher.go @@ -371,10 +371,10 @@ func (w *watcher) keyForDirWatch(dir string, recursive bool) string { return dir } -func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string) *dirWatch { +func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string, comparer pathComparer) *dirWatch { var best *dirWatch for _, dw := range w.dirWatches { - if !dw.recursive || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) { + if !dw.recursive || dw.comparer != comparer || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) { continue } if best == nil || len(dw.dir) > len(best.dir) { @@ -416,7 +416,7 @@ func (w *watcher) findConsolidationDirLocked(dir string, physicalDir string) str return "" } -func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool) *dirWatch { +func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool, comparer pathComparer) (*dirWatch, error) { w.mu.Lock() defer w.mu.Unlock() if w.dirWatches == nil { @@ -427,28 +427,35 @@ func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive } if w.canShareRecursiveDirWatches() { - if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil { - return dw + if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir, comparer); dw != nil { + return dw, nil } if consolidationDir := w.findConsolidationDirLocked(dir, physicalDir); consolidationDir != "" { - dir = consolidationDir - physicalDir = physicalDirFor(dir) - recursive = true - if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil { - return dw + parentComparer, err := w.pathComparer(consolidationDir) + if err != nil { + return nil, err + } + if parentComparer == comparer { + dir = consolidationDir + physicalDir = physicalDirFor(dir) + recursive = true + if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir, comparer); dw != nil { + return dw, nil + } } } } key := w.keyForDirWatch(dir, recursive) if dw, ok := w.dirWatches[key]; ok { - return dw + return dw, nil } dw := newDirWatch(dir, physicalDir, w.debounce) + dw.comparer = comparer dw.sequence = w.sequence dw.recursive = recursive w.dirWatches[key] = dw - return dw + return dw, nil } func (w *watcher) removeDirWatch(dw *dirWatch) { @@ -524,7 +531,16 @@ func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, e o.applyWatchOption(&sopts) } - dw := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive) + comparer, err := w.pathComparer(dir) + if err != nil { + rollback() + return nil, err + } + dw, err := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive, comparer) + if err != nil { + rollback() + return nil, err + } id, _ := dw.watch(dir, physicalDir, sopts.recursive, fn, sopts.ignore) prepared = append(prepared, preparedWatch{dw: dw, id: id, recursive: sopts.recursive, dir: dir}) if _, ok := seenDirWatches[dw]; !ok { @@ -578,18 +594,23 @@ func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) { return nil, errRootPath } - return w.WatchDirectory(dir, fileCallback(path, fn)) + comparer, err := w.pathComparer(dir) + if err != nil { + return nil, err + } + return w.WatchDirectory(dir, fileCallback(path, fn, comparer)) } // fileCallback wraps a WatchCallback so it only sees events for the // specific target path. Errors are always forwarded (with any matching // events delivered alongside) so callers don't lose overflow signals // just because their target wasn't in the same batch. -func fileCallback(target string, fn WatchCallback) WatchCallback { +func fileCallback(target string, fn WatchCallback, comparer pathComparer) WatchCallback { return func(events []Event, err error) { var filtered []Event for _, e := range events { - if e.Path == target { + if comparer.equal(e.Path, target) { + e.Path = target filtered = append(filtered, e) } } @@ -782,6 +803,7 @@ type callback struct { sinceSeq uint64 terminal error delivered bool + comparer pathComparer } // dirWatchError associates an error with a specific directory watch. @@ -803,6 +825,7 @@ type dirWatch struct { physicalDir string recursive bool events eventList + comparer pathComparer // state stores per-directory platform-specific bookkeeping (fsevents, windows). state any @@ -1002,10 +1025,10 @@ func (dw *dirWatch) triggerCallbacks() { } func (cb callback) mapEvent(e Event) Event { - if cb.physicalDir != "" && cb.physicalDir != cb.dir { + if cb.physicalDir != "" && (cb.physicalDir != cb.dir || cb.comparer.ignoreCase) { physicalPath := cb.eventPhysicalPath(e.Path) - if isInDirectoryOrSelf(cb.physicalDir, physicalPath) { - e.Path = rebasePath(physicalPath, cb.physicalDir, cb.dir) + if path, ok := cb.comparer.rebase(physicalPath, cb.physicalDir, cb.dir); ok { + e.Path = path } } return e @@ -1028,7 +1051,7 @@ func (dw *dirWatch) terminateCallbacksForDeletedRoot(path string, seq uint64, er continue } physicalPath := cb.eventPhysicalPath(path) - if isInDirectoryOrSelf(path, cb.dir) || (cb.physicalDir != cb.dir && isInDirectoryOrSelf(physicalPath, cb.physicalDir)) { + if cb.comparer.contains(path, cb.dir) || (cb.physicalDir != cb.dir && cb.comparer.contains(physicalPath, cb.physicalDir)) { cb.terminal = err changed = true } @@ -1082,7 +1105,7 @@ func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn Wat if dw.sequence != nil { sinceSeq = dw.sequence() } - dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq}) + dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq, comparer: dw.comparer}) return id, true } diff --git a/tsc/internal/fswatch/watcher_test.go b/tsc/internal/fswatch/watcher_test.go index 577c6acb2f4da..2b01060e525f0 100644 --- a/tsc/internal/fswatch/watcher_test.go +++ b/tsc/internal/fswatch/watcher_test.go @@ -2109,7 +2109,7 @@ func TestFileCallbackForwardsErrAlongsideEvents(t *testing.T) { var got []call cb := fileCallback(target, func(events []Event, err error) { got = append(got, call{events: events, err: err}) - }) + }, pathComparer{}) // Plain events: only target events pass through, sibling dropped. cb([]Event{{Kind: EventUpdate, Path: target}, {Kind: EventUpdate, Path: other}}, nil) From eee9f292580c1a0fb0cbfd68191639b73fc67f54 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:37:12 -0700 Subject: [PATCH 2/5] Optimize FSEvents path matching and watch setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip shared path prefixes eight bytes at a time and handle ASCII case folding without component splitting. Retain the Unicode fallback for case-equivalent paths with different UTF-8 lengths. Reuse the parent directory's comparer for WatchFile instead of querying filesystem case sensitivity twice. Add routing benchmarks and expand boundary and Unicode alignment coverage. Compare against the initial casing fix on Apple M1. Allocations are unchanged: zero for exact matches and rejections, and one for rebasing a differently cased event path. goos: darwin goarch: arm64 pkg: github.com/microsoft/TypeScript/tsc/internal/fswatch cpu: Apple M1 │ before │ after │ │ sec/op │ sec/op vs base │ FSEventsDisplayPath/exact-match-8 9.031n ± ∞ ¹ 8.958n ± ∞ ¹ ~ (p=0.548 n=5) FSEventsDisplayPath/case-mismatch-8 130.50n ± ∞ ¹ 80.89n ± ∞ ¹ -38.02% (p=0.008 n=5) FSEventsDisplayPath/sibling-miss-8 103.20n ± ∞ ¹ 14.26n ± ∞ ¹ -86.18% (p=0.008 n=5) FSEventsDisplayPath/unrelated-miss-8 26.120n ± ∞ ¹ 6.676n ± ∞ ¹ -74.44% (p=0.008 n=5) FSEventsDisplayPath/unicode-match-8 115.30n ± ∞ ¹ 74.29n ± ∞ ¹ -35.57% (p=0.008 n=5) FSEventsDisplayPath/unicode-length-match-8 98.83n ± ∞ ¹ 52.34n ± ∞ ¹ -47.04% (p=0.008 n=5) FSEventsRoutingFanout/100-8 10.799µ ± ∞ ¹ 1.634µ ± ∞ ¹ -84.87% (p=0.008 n=5) FSEventsRoutingFanout/1000-8 109.21µ ± ∞ ¹ 15.10µ ± ∞ ¹ -86.18% (p=0.008 n=5) geomean 284.3n 94.97n -66.60% ¹ need >= 6 samples for confidence interval at level 0.95 --- .../fswatch/fsevents_darwin_bench_test.go | 70 +++++++++++++++++++ tsc/internal/fswatch/pathcompare.go | 48 +++++++++++-- tsc/internal/fswatch/pathcompare_test.go | 52 +++++++++++++- tsc/internal/fswatch/watcher.go | 20 ++++-- 4 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 tsc/internal/fswatch/fsevents_darwin_bench_test.go diff --git a/tsc/internal/fswatch/fsevents_darwin_bench_test.go b/tsc/internal/fswatch/fsevents_darwin_bench_test.go new file mode 100644 index 0000000000000..4ed249ea230c4 --- /dev/null +++ b/tsc/internal/fswatch/fsevents_darwin_bench_test.go @@ -0,0 +1,70 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "fmt" + "strconv" + "strings" + "testing" +) + +func BenchmarkFSEventsDisplayPath(b *testing.B) { + const root = "/Users/developer/work/TypeScript/packages/vscode-typescript" + for _, scenario := range []struct { + name string + root string + path string + want string + ok bool + }{ + {"exact-match", root, root + "/src/File.ts", root + "/src/File.ts", true}, + {"case-mismatch", strings.ToLower(root), root + "/src/File.ts", strings.ToLower(root) + "/src/File.ts", true}, + {"sibling-miss", root, "/Users/developer/work/TypeScript/packages/other-package/src/File.ts", "", false}, + {"unrelated-miss", root, "/private/tmp/other/File.ts", "", false}, + {"unicode-match", "/Users/developer/work/caf\u00e9", "/Users/developer/work/CAF\u00c9/File.ts", "/Users/developer/work/caf\u00e9/File.ts", true}, + {"unicode-length-match", "/Users/developer/work/s", "/Users/developer/work/\u017f/File.ts", "/Users/developer/work/s/File.ts", true}, + } { + b.Run(scenario.name, func(b *testing.B) { + w := &dirWatch{dir: scenario.root, physicalDir: scenario.root, comparer: pathComparer{ignoreCase: true}} + if got, ok := fseventsDisplayPath(w, scenario.path); got != scenario.want || ok != scenario.ok { + b.Fatalf("got (%q, %v), want (%q, %v)", got, ok, scenario.want, scenario.ok) + } + b.ReportAllocs() + for b.Loop() { + fseventsDisplayPath(w, scenario.path) + } + }) + } +} + +func BenchmarkFSEventsRoutingFanout(b *testing.B) { + for _, count := range []int{100, 1000} { + watches := make([]dirWatch, count) + for i := range watches { + dir := fmt.Sprintf("/Users/developer/work/TypeScript/packages/package%04d", i) + watches[i] = dirWatch{dir: dir, physicalDir: dir, comparer: pathComparer{ignoreCase: true}} + } + path := watches[count-1].dir + "/src/File.ts" + b.Run(strconv.Itoa(count), func(b *testing.B) { + matches := 0 + for i := range watches { + if got, ok := fseventsDisplayPath(&watches[i], path); ok { + matches++ + if got != path { + b.Fatalf("got %q, want %q", got, path) + } + } + } + if matches != 1 { + b.Fatalf("got %d matches, want 1", matches) + } + b.ReportAllocs() + for b.Loop() { + for i := range watches { + fseventsDisplayPath(&watches[i], path) + } + } + }) + } +} diff --git a/tsc/internal/fswatch/pathcompare.go b/tsc/internal/fswatch/pathcompare.go index dbb20aee01a77..4ec26dba3c4c1 100644 --- a/tsc/internal/fswatch/pathcompare.go +++ b/tsc/internal/fswatch/pathcompare.go @@ -1,6 +1,9 @@ package fswatch -import "strings" +import ( + "strings" + "unicode/utf8" +) type pathComparer struct { ignoreCase bool @@ -11,8 +14,6 @@ func (c pathComparer) equal(a, b string) bool { } // suffix returns the part of path below root, respecting directory boundaries. -// Comparing components avoids assuming case-equivalent UTF-8 strings have the -// same byte length. func (c pathComparer) suffix(root, path string) (string, bool) { if isInDirectoryOrSelf(root, path) { return path[len(root):], true @@ -20,6 +21,42 @@ func (c pathComparer) suffix(root, path string) (string, bool) { if !c.ignoreCase || root == "" { return "", false } + return pathSuffixFold(root, path) +} + +func pathSuffixFold(root, path string) (string, bool) { + i := 0 + // Skip shared prefixes a word at a time, which is common when routing an + // event past sibling watches. String slice comparisons do not allocate. + for i+8 <= len(root) && i+8 <= len(path) && root[i:i+8] == path[i:i+8] { + i += 8 + } + for ; i < len(root) && i < len(path); i++ { + a, b := root[i], path[i] + if a >= utf8.RuneSelf || b >= utf8.RuneSelf { + // A skipped word may end inside a rune. Restart this component + // rather than interpreting a partial UTF-8 encoding. + i = strings.LastIndexByte(root[:i], '/') + 1 + return pathSuffixFoldUnicode(root[i:], path[i:]) + } + if a == b { + continue + } + a |= 0x20 + b |= 0x20 + if a != b || a < 'a' || a > 'z' { + return "", false + } + } + if i == len(root) && (i == len(path) || path[i] == '/') { + return path[i:], true + } + return "", false +} + +// Comparing the remaining components avoids assuming case-equivalent UTF-8 +// strings have the same byte length (for example, s and long s). +func pathSuffixFoldUnicode(root, path string) (string, bool) { for { rootPart, rootRest, rootMore := strings.Cut(root, "/") pathPart, pathRest, pathMore := strings.Cut(path, "/") @@ -48,7 +85,10 @@ func (c pathComparer) rebase(path, from, to string) (string, bool) { if isInDirectoryOrSelf(from, path) { return rebasePath(path, from, to), true } - suffix, ok := c.suffix(from, path) + if !c.ignoreCase || from == "" { + return "", false + } + suffix, ok := pathSuffixFold(from, path) if !ok { return "", false } diff --git a/tsc/internal/fswatch/pathcompare_test.go b/tsc/internal/fswatch/pathcompare_test.go index 87d9668f0f0cf..386a1d4d4fa2e 100644 --- a/tsc/internal/fswatch/pathcompare_test.go +++ b/tsc/internal/fswatch/pathcompare_test.go @@ -1,6 +1,9 @@ package fswatch -import "testing" +import ( + "strings" + "testing" +) func TestPathComparer(t *testing.T) { t.Parallel() @@ -20,11 +23,27 @@ func TestPathComparer(t *testing.T) { {"/root", "/roo", "", false, false}, {"/root/sub", "/ROOT", "", false, false}, {"/root", "/other/File.ts", "", false, false}, + {"/root", "/ROOTish/File.ts", "", false, false}, + {"/root/sub", "/ROOT/SUB", "", false, true}, + {"/root/sub", "/ROOT/su", "", false, false}, + {"/root/[", "/ROOT/{/File.ts", "", false, false}, + {"/root/@", "/ROOT/`/File.ts", "", false, false}, {"/", "/File.ts", "File.ts", true, true}, + {"/", "/", "", true, true}, + {"", "", "", false, false}, {"", "/File.ts", "", false, false}, {"/caf\u00e9", "/CAF\u00c9/File.ts", "/File.ts", false, true}, {"/s", "/\u017f/File.ts", "/File.ts", false, true}, {"/\u017f", "/S/File.ts", "/File.ts", false, true}, + {"/s/sub", "/\u017f/SUB/File.ts", "/File.ts", false, true}, + {"/\u017f/sub", "/S/SUB/File.ts", "/File.ts", false, true}, + {"/s", "/\u017foo/File.ts", "", false, false}, + {"/k", "/\u212a/File.ts", "/File.ts", false, true}, + {"/\u03c3", "/\u03c2/File.ts", "/File.ts", false, true}, + {"/\u00e9", "/\u00c8/File.ts", "", false, false}, + {"/\u00df", "/SS/File.ts", "", false, false}, + {"/root/s", "/ROOT/\u017f/File.ts", "/File.ts", false, true}, + {"/root/\u017f", "/ROOT/S", "", false, true}, } for _, tt := range tests { for _, ignoreCase := range []bool{false, true} { @@ -37,6 +56,37 @@ func TestPathComparer(t *testing.T) { if ok != want || ok && suffix != tt.suffix { t.Errorf("suffix(%q, %q), ignoreCase=%v: got (%q, %v), want (%q, %v)", tt.root, tt.path, ignoreCase, suffix, ok, tt.suffix, want) } + if comparer.contains(tt.root, tt.path) != want { + t.Errorf("contains(%q, %q), ignoreCase=%v: want %v", tt.root, tt.path, ignoreCase, want) + } + for _, to := range []string{"/display", "/"} { + rebased, ok := comparer.rebase(tt.path, tt.root, to) + if ok != want || ok && rebased != joinPathSuffix(to, tt.suffix) { + t.Errorf("rebase(%q, %q, %q), ignoreCase=%v: got (%q, %v)", tt.path, tt.root, to, ignoreCase, rebased, ok) + } + } + } + } +} + +func TestPathComparerUnicodeAlignment(t *testing.T) { + t.Parallel() + parts := []string{"s", "S", "\u017f", "k", "K", "\u212a", "\u03c3", "\u03c2", "\u00e9", "\u00c9", "\u00c8", "\U00010400", "\U00010428", "\xff", "\xfe", "\xc3"} + comparer := pathComparer{ignoreCase: true} + for padding := range 16 { + prefix := "/" + strings.Repeat("a", padding) + for _, a := range parts { + for _, b := range parts { + for _, child := range []string{"", "/child"} { + root := prefix + a + child + path := prefix + b + strings.ToUpper(child) + "/File.ts" + want := strings.EqualFold(a, b) + suffix, ok := comparer.suffix(root, path) + if ok != want || ok && suffix != "/File.ts" { + t.Fatalf("suffix(%q, %q): got (%q, %v), want match=%v", root, path, suffix, ok, want) + } + } + } } } } diff --git a/tsc/internal/fswatch/watcher.go b/tsc/internal/fswatch/watcher.go index b6d6a3c157379..4adf517348d68 100644 --- a/tsc/internal/fswatch/watcher.go +++ b/tsc/internal/fswatch/watcher.go @@ -119,6 +119,17 @@ type WatchDirectoryRequest struct { type watchOptions struct { ignore func(path string) bool recursive bool + file string +} + +// fileOption defers the file filter until the parent directory's comparer is +// available, so WatchFile does not need a second filesystem query. +type fileOption struct { + path string +} + +func (o fileOption) applyWatchOption(opts *watchOptions) { + opts.file = o.path } type ignoreOption struct { @@ -536,6 +547,9 @@ func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, e rollback() return nil, err } + if sopts.file != "" { + fn = fileCallback(sopts.file, fn, comparer) + } dw, err := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive, comparer) if err != nil { rollback() @@ -594,11 +608,7 @@ func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) { return nil, errRootPath } - comparer, err := w.pathComparer(dir) - if err != nil { - return nil, err - } - return w.WatchDirectory(dir, fileCallback(path, fn, comparer)) + return w.WatchDirectory(dir, fn, fileOption{path: path}) } // fileCallback wraps a WatchCallback so it only sees events for the From 4836323c807a2c504c15bd732364eefe1f32682e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:27:44 -0700 Subject: [PATCH 3/5] Handle expanding Unicode aliases in FSEvents watches Use CoreFoundation case folding and NFC normalization for non-ASCII comparisons on case-insensitive Darwin watches. Recognize expanding aliases such as sharp s and SS without merging distinct dotted-I, dotless-I, circled-letter, or fullwidth spellings. Preserve the ASCII fast path, prepare watch-root comparison forms, and share lazy event folding across routing and callback filtering. Rebase using original path boundaries rather than folded byte lengths. Cover file and directory watches, shared callbacks, symlinks, overflow, and root termination with filesystem regressions and routing benchmarks. Keep fswatch isolated and leave Windows and case-sensitive behavior unchanged. --- tsc/internal/fswatch/README.md | 21 +- tsc/internal/fswatch/canonicalize_other.go | 6 + tsc/internal/fswatch/fsevents_darwin.go | 37 +- .../fswatch/fsevents_darwin_bench_test.go | 49 ++- tsc/internal/fswatch/fsevents_darwin_ffi.go | 44 ++- tsc/internal/fswatch/fsevents_darwin_ffi.s | 6 + .../fswatch/fsevents_darwin_fold_test.go | 360 ++++++++++++++++++ tsc/internal/fswatch/pathcompare.go | 126 +++++- tsc/internal/fswatch/pathcompare_test.go | 15 +- tsc/internal/fswatch/watcher.go | 117 +++--- tsc/internal/fswatch/watcher_test.go | 18 +- 11 files changed, 711 insertions(+), 88 deletions(-) create mode 100644 tsc/internal/fswatch/fsevents_darwin_fold_test.go diff --git a/tsc/internal/fswatch/README.md b/tsc/internal/fswatch/README.md index 82f2759224a4d..11545f11f9010 100644 --- a/tsc/internal/fswatch/README.md +++ b/tsc/internal/fswatch/README.md @@ -90,9 +90,18 @@ 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, paths are normalized to NFC. On volumes reporting case-insensitive +lookup, FSEvents matches 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 and the +event's NFC suffix; file events use the subscribed 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 +other watcher backends retain exact comparison. diff --git a/tsc/internal/fswatch/canonicalize_other.go b/tsc/internal/fswatch/canonicalize_other.go index e8b3cfd9ef062..5444d290b05e8 100644 --- a/tsc/internal/fswatch/canonicalize_other.go +++ b/tsc/internal/fswatch/canonicalize_other.go @@ -2,6 +2,12 @@ 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. diff --git a/tsc/internal/fswatch/fsevents_darwin.go b/tsc/internal/fswatch/fsevents_darwin.go index f414e6266b7a6..c7b4f4c1d4745 100644 --- a/tsc/internal/fswatch/fsevents_darwin.go +++ b/tsc/internal/fswatch/fsevents_darwin.go @@ -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 @@ -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{}{} } @@ -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 } @@ -623,18 +624,42 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) { } func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) { - if path, ok := w.comparer.rebase(rawPath, w.physicalDir, w.dir); ok { + 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 { - return w.comparer.rebase(rawPath, w.dir, 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 w.comparer.contains(w.physicalDir, rawPath) || w.comparer.contains(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 && (w.comparer.contains(w.dir, rawPath) || w.comparer.contains(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 } diff --git a/tsc/internal/fswatch/fsevents_darwin_bench_test.go b/tsc/internal/fswatch/fsevents_darwin_bench_test.go index 4ed249ea230c4..047610c4e28d0 100644 --- a/tsc/internal/fswatch/fsevents_darwin_bench_test.go +++ b/tsc/internal/fswatch/fsevents_darwin_bench_test.go @@ -24,9 +24,13 @@ func BenchmarkFSEventsDisplayPath(b *testing.B) { {"unrelated-miss", root, "/private/tmp/other/File.ts", "", false}, {"unicode-match", "/Users/developer/work/caf\u00e9", "/Users/developer/work/CAF\u00c9/File.ts", "/Users/developer/work/caf\u00e9/File.ts", true}, {"unicode-length-match", "/Users/developer/work/s", "/Users/developer/work/\u017f/File.ts", "/Users/developer/work/s/File.ts", true}, + {"expanding-event", "/Users/developer/work/SS", "/Users/developer/work/\u00df/File.ts", "/Users/developer/work/SS/File.ts", true}, + {"expanding-root", "/Users/developer/work/\u00df", "/Users/developer/work/SS/File.ts", "/Users/developer/work/\u00df/File.ts", true}, + {"unicode-unrelated-miss", root, "/private/tmp/\u00df/File.ts", "", false}, } { b.Run(scenario.name, func(b *testing.B) { w := &dirWatch{dir: scenario.root, physicalDir: scenario.root, comparer: pathComparer{ignoreCase: true}} + w.setComparer(w.comparer) if got, ok := fseventsDisplayPath(w, scenario.path); got != scenario.want || ok != scenario.ok { b.Fatalf("got (%q, %v), want (%q, %v)", got, ok, scenario.want, scenario.ok) } @@ -44,6 +48,7 @@ func BenchmarkFSEventsRoutingFanout(b *testing.B) { for i := range watches { dir := fmt.Sprintf("/Users/developer/work/TypeScript/packages/package%04d", i) watches[i] = dirWatch{dir: dir, physicalDir: dir, comparer: pathComparer{ignoreCase: true}} + watches[i].setComparer(watches[i].comparer) } path := watches[count-1].dir + "/src/File.ts" b.Run(strconv.Itoa(count), func(b *testing.B) { @@ -61,10 +66,52 @@ func BenchmarkFSEventsRoutingFanout(b *testing.B) { } b.ReportAllocs() for b.Loop() { + event := comparisonPath{path: path} for i := range watches { - fseventsDisplayPath(&watches[i], path) + fseventsDisplayPathPrepared(&watches[i], &event) } + } }) } } + +func BenchmarkFSEventsUnicodeFanout(b *testing.B) { + for _, scenario := range []struct{ name, root, event string }{ + {"simple", "S", "\u017f"}, + {"expanding-event", "SS", "\u00df"}, + {"expanding-root", "\u00df", "SS"}, + } { + for _, count := range []int{100, 1000} { + b.Run(scenario.name+"/"+strconv.Itoa(count), func(b *testing.B) { + watches := make([]dirWatch, count) + for i := range watches { + dir := fmt.Sprintf("/Users/developer/work/%s/package%04d", scenario.root, i) + watches[i] = dirWatch{dir: dir, physicalDir: dir} + watches[i].setComparer(pathComparer{ignoreCase: true}) + } + path := fmt.Sprintf("/Users/developer/work/%s/package%04d/File.ts", scenario.event, count-1) + matches := 0 + event := comparisonPath{path: path} + for i := range watches { + if got, ok := fseventsDisplayPathPrepared(&watches[i], &event); ok { + matches++ + if got != watches[i].dir+"/File.ts" { + b.Fatalf("unexpected display path %q", got) + } + } + } + if matches != 1 || !event.ready { + b.Fatalf("matches=%d, event folded=%v", matches, event.ready) + } + b.ReportAllocs() + for b.Loop() { + event := comparisonPath{path: path} + for i := range watches { + fseventsDisplayPathPrepared(&watches[i], &event) + } + } + }) + } + } +} diff --git a/tsc/internal/fswatch/fsevents_darwin_ffi.go b/tsc/internal/fswatch/fsevents_darwin_ffi.go index 30294b9a43f51..68ec91f1f998e 100644 --- a/tsc/internal/fswatch/fsevents_darwin_ffi.go +++ b/tsc/internal/fswatch/fsevents_darwin_ffi.go @@ -8,7 +8,9 @@ import ( "os" "runtime" "slices" + "strings" "syscall" + "unicode/utf8" "unsafe" "golang.org/x/sys/unix" @@ -154,7 +156,7 @@ func cfArrayGetValueAtIndex(array uintptr, index int) uintptr { // 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. // // All-ASCII inputs are bit-identical in NFC and NFD, so the hot path skips @@ -183,6 +185,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 diff --git a/tsc/internal/fswatch/fsevents_darwin_ffi.s b/tsc/internal/fswatch/fsevents_darwin_ffi.s index 07608498b5097..fed8e24c42f68 100644 --- a/tsc/internal/fswatch/fsevents_darwin_ffi.s +++ b/tsc/internal/fswatch/fsevents_darwin_ffi.s @@ -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) diff --git a/tsc/internal/fswatch/fsevents_darwin_fold_test.go b/tsc/internal/fswatch/fsevents_darwin_fold_test.go new file mode 100644 index 0000000000000..c945babb65a5b --- /dev/null +++ b/tsc/internal/fswatch/fsevents_darwin_fold_test.go @@ -0,0 +1,360 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var fseventsFoldPairs = []struct { + name string + a, b string + alias bool +}{ + {"sharp-s", "\u00df", "SS", true}, + {"capital-sharp-s", "\u1e9e", "SS", true}, + {"dotted-i", "\u0130", "i\u0307", true}, + {"ligature-ff", "\ufb00", "ff", true}, + {"ligature-ffi", "\ufb03", "ffi", true}, + {"long-s", "\u017f", "S", true}, + {"sigma", "\u03c2", "\u03a3", true}, + {"accent", "\u00e9", "E\u0301", true}, + {"dotless-i", "I", "\u0131", false}, + {"ascii-dotted-i", "i", "\u0130", false}, + {"ascii-i-dot", "I", "i\u0307", false}, + {"circled-a", "\u24d0", "a", false}, + {"fullwidth-a", "\uff21", "A", false}, +} + +func TestNativePathFold(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + a, b := foldNativePath(pair.a), foldNativePath(pair.b) + if (a == b) != pair.alias { + t.Errorf("%s: folds %q / %q, alias=%v", pair.name, a, b, pair.alias) + } + if a != foldNativePath(canonicalizePath(pair.a)) || b != foldNativePath(canonicalizePath(pair.b)) { + t.Errorf("%s: normalization changed folding", pair.name) + } + } + for _, input := range []string{"\u0130", "I\u0307", "i\u0307"} { + if got := foldNativePath(input); got != "i\u0307" { + t.Errorf("fold(%q) = %q", input, got) + } + } + for _, input := range []string{"/\xff", "/\xfe", "/\u00df\x00suffix"} { + if got := foldNativePath(input); got != "" { + t.Errorf("invalid native path %q produced %q", input, got) + } + } + c := pathComparer{ignoreCase: true} + for _, pair := range [][2]string{{"/SS/", "/\u00df/File.ts"}, {"/\u00df/", "/SS/File.ts"}} { + if suffix, ok := c.suffix(pair[0], pair[1]); !ok || suffix != "File.ts" { + t.Errorf("trailing separator: got (%q, %v)", suffix, ok) + } + } +} + +func TestFSEventsConsolidatedExpansion(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + for _, reverse := range []bool{false, true} { + a, b := pair.a, pair.b + if reverse { + a, b = b, a + } + for _, recursive := range []bool{false, true} { + dw := newDirectWatcher(t, "/parent") + dw.setComparer(pathComparer{ignoreCase: true}) + child, raw := "/parent/"+canonicalizePath(a), "/parent/"+canonicalizePath(b) + var got []Event + var gotErr error + dw.watch(child, child, recursive, func(events []Event, err error) { + got = append(got, events...) + gotErr = err + }, func(path string) bool { return path == child+"/Ignored.ts" }) + dw.events.update(raw + "/File.ts") + dw.events.update(raw + "/Nested/File.ts") + dw.events.update(raw + "/Ignored.ts") + dw.events.update(raw + "2/File.ts") + dw.triggerCallbacks() + want := 0 + if pair.alias { + want = 1 + if recursive { + want++ + } + } + if len(got) != want || gotErr != nil { + t.Fatalf("%s reverse=%v recursive=%v: got %v, %v", pair.name, reverse, recursive, got, gotErr) + } + for _, e := range got { + if e.Path != child+"/File.ts" && e.Path != child+"/Nested/File.ts" { + t.Fatalf("incorrect rebasing: %v", e) + } + } + got = nil + if dw.terminateCallbacksForDeletedRoot(raw, 1, ErrWatchTerminated) != pair.alias { + t.Fatalf("%s: incorrect termination", pair.name) + } + dw.events.removeWatchRootAt(raw, 1) + dw.triggerCallbacks() + if pair.alias && (len(got) != 1 || got[0].Path != child || got[0].Kind != EventDelete || !errors.Is(gotErr, ErrWatchTerminated)) { + t.Fatalf("%s: missing deletion/termination: %v, %v", pair.name, got, gotErr) + } + if !pair.alias && (len(got) != 0 || gotErr != nil) { + t.Fatalf("%s: cross-routed deletion/termination: %v, %v", pair.name, got, gotErr) + } + } + } + } +} + +func TestFSEventsExpansionRouting(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + for _, reverse := range []bool{false, true} { + a, b := pair.a, pair.b + if reverse { + a, b = b, a + } + for padding := range 16 { + root := "/physical/" + strings.Repeat("x", padding) + canonicalizePath(a) + raw := "/PHYSICAL/" + strings.Repeat("x", padding) + canonicalizePath(b) + for _, ignoreCase := range []bool{false, true} { + w := &dirWatch{dir: "/logical/Caller", physicalDir: root, comparer: pathComparer{ignoreCase: ignoreCase}} + w.setComparer(w.comparer) + want := ignoreCase && pair.alias + for _, suffix := range []string{"", "/File\u00df.ts", "/Nested/File.ts"} { + got, ok := fseventsDisplayPath(w, raw+suffix) + if ok != want || ok && got != w.dir+suffix { + t.Fatalf("%s reverse=%v padding=%d ignoreCase=%v: got (%q, %v)", pair.name, reverse, padding, ignoreCase, got, ok) + } + } + if fseventsOverflowMatches(w, raw+"/Nested") != want || fseventsOverflowMatches(w, raw) != want { + t.Fatalf("%s: incorrect overflow routing", pair.name) + } + if _, ok := fseventsDisplayPath(w, raw+"2/File.ts"); ok { + t.Fatalf("%s: matched sibling", pair.name) + } + w.physicalDir += "/Nested" + w.setComparer(w.comparer) + if fseventsOverflowMatches(w, raw) != want { + t.Fatalf("%s: incorrect ancestor overflow routing", pair.name) + } + } + } + } + } +} + +func TestFSEventsLazyPathFolding(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + root, event string + match, fold bool + }{ + {"/root", "/root/File\u00df.ts", true, false}, + {"/root", "/other/\u00df/File.ts", false, false}, + {"/ROOT", "/root/File.ts", true, false}, + {"/SS", "/\u00df/File.ts", true, true}, + {"/\u00df", "/SS/File.ts", true, true}, + } { + w := &dirWatch{dir: tt.root, physicalDir: tt.root} + w.setComparer(pathComparer{ignoreCase: true}) + event := comparisonPath{path: tt.event} + for range 10 { + if _, ok := fseventsDisplayPathPrepared(w, &event); ok != tt.match || event.ready != tt.fold { + t.Fatalf("root=%q event=%q: match=%v, folded=%v", tt.root, tt.event, ok, event.ready) + } + } + } + var cache comparisonCache + for _, root := range []string{"/SS", "/ss"} { + cb := callback{ + dir: root, physicalDir: root, comparer: pathComparer{ignoreCase: true}, + physicalComparison: pathComparer{ignoreCase: true}.prepare(root), + } + e := cb.mapEventCached(Event{Path: "/\u00df/File.ts", Kind: EventUpdate}, &cache) + if e.Path != root+"/File.ts" { + t.Fatalf("cached callback: %v", e) + } + } + if len(cache) != 1 || cache["/\u00df/File.ts"] != "/ss/file.ts" { + t.Fatalf("expected one shared event comparison, got %v", cache) + } +} + +// Check identity independently of the comparer, including exclusive creation. +// Filesystems that do not support a particular alias cannot exercise its watch. +func requireFSEventsAlias(t *testing.T, a, b string) { + t.Helper() + first, err := os.Stat(a) + if err != nil { + t.Fatal(err) + } + second, err := os.Stat(b) + if errors.Is(err, os.ErrNotExist) { + t.Skip("filesystem does not alias these spellings") + } + if err != nil { + t.Fatal(err) + } + if !os.SameFile(first, second) { + t.Fatal("alternate spelling resolved to a different inode") + } + if err := os.Mkdir(b, 0o755); !errors.Is(err, os.ErrExist) { + t.Fatalf("exclusive alternate creation: %v", err) + } +} + +func TestFSEventsExpansionAliases(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + if !pair.alias { + continue + } + t.Run(pair.name, func(t *testing.T) { + t.Parallel() + for _, reverse := range []bool{false, true} { + a, b := pair.a, pair.b + if reverse { + a, b = b, a + } + parent := newTmpDir(t) + disk, root := filepath.Join(parent, a), filepath.Join(parent, b) + if err := os.Mkdir(disk, 0o755); err != nil { + t.Fatal(err) + } + requireFSEventsAlias(t, disk, root) + nested := filepath.Join(disk, "Nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + direct, _ := subscribeForOpts(t, root, FSEvents()) + recursive, _ := subscribeFor(t, root, FSEvents()) + link := filepath.Join(parent, "Link") + makeDirSymlink(t, root, link) + linked, _ := subscribeFor(t, link, FSEvents()) + file, _ := subscribeFileFor(t, filepath.Join(root, b+".ts"), FSEvents()) + control, _ := subscribeFileFor(t, filepath.Join(disk, a+".ts"), FSEvents()) + diskFile := filepath.Join(disk, a+".ts") + for round := range 3 { + kind := EventUpdate + if round == 2 { + kind = EventDelete + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(diskFile, []byte(strings.Repeat("x", round+1)), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, control, kind, canonicalizePath(diskFile)) + expectContains(t, file, kind, canonicalizePath(filepath.Join(root, b+".ts"))) + expectContains(t, direct, kind, canonicalizePath(filepath.Join(root, a+".ts"))) + expectContains(t, recursive, kind, canonicalizePath(filepath.Join(root, a+".ts"))) + expectContains(t, linked, kind, canonicalizePath(filepath.Join(link, a+".ts"))) + } + child := filepath.Join(nested, "File.ts") + if err := os.WriteFile(child, nil, 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, recursive, EventUpdate, canonicalizePath(filepath.Join(root, "Nested", "File.ts"))) + expectContains(t, linked, EventUpdate, filepath.Join(link, "Nested", "File.ts")) + if events := direct.next(400 * time.Millisecond); len(events) != 0 { + t.Fatalf("nonrecursive watch received nested events: %v", events) + } + if err := os.Remove(child); err != nil { + t.Fatal(err) + } + expectContains(t, recursive, EventDelete, canonicalizePath(filepath.Join(root, "Nested", "File.ts"))) + if err := os.Remove(nested); err != nil { + t.Fatal(err) + } + expectContains(t, direct, EventDelete, canonicalizePath(filepath.Join(root, "Nested"))) + if err := os.Remove(disk); err != nil { + t.Fatal(err) + } + expectContains(t, recursive, EventDelete, canonicalizePath(root)) + terminated := false + deadline := time.Now().Add(direct.deadline()) + for !terminated && time.Now().Before(deadline) { + direct.mu.Lock() + for _, err := range direct.errs { + terminated = terminated || errors.Is(err, ErrWatchTerminated) + } + direct.mu.Unlock() + if !terminated { + time.Sleep(20 * time.Millisecond) + } + } + if !terminated { + t.Fatal("missing root termination") + } + } + }) + } +} + +func TestFSEventsFoldDistinctNames(t *testing.T) { + t.Parallel() + for _, pair := range fseventsFoldPairs { + if pair.alias { + continue + } + t.Run(pair.name, func(t *testing.T) { + t.Parallel() + for _, reverse := range []bool{false, true} { + parent := newTmpDir(t) + roots := []string{filepath.Join(parent, pair.a), filepath.Join(parent, pair.b)} + if reverse { + roots[0], roots[1] = roots[1], roots[0] + } + recorders := make([]*recordingWatcher, 2) + files := make([]*recordingWatcher, 2) + for i, root := range roots { + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + recorders[i], _ = subscribeFor(t, root, FSEvents()) + files[i], _ = subscribeFileFor(t, root+".ts", FSEvents()) + } + a, err := os.Stat(roots[0]) + if err != nil { + t.Fatal(err) + } + b, err := os.Stat(roots[1]) + if err != nil || os.SameFile(a, b) { + t.Fatalf("expected distinct inodes: %v", err) + } + child := filepath.Join(roots[0], "File.ts") + file := roots[0] + ".ts" + for round := range 3 { + kind := EventUpdate + for _, path := range []string{child, file} { + if round == 2 { + kind = EventDelete + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(path, []byte(strings.Repeat("x", round+1)), 0o644); err != nil { + t.Fatal(err) + } + } + expectContains(t, recorders[0], kind, canonicalizePath(child)) + expectContains(t, files[0], kind, canonicalizePath(file)) + for _, r := range []*recordingWatcher{recorders[1], files[1]} { + if events := r.next(400 * time.Millisecond); len(events) != 0 { + t.Fatalf("cross-routed distinct name: %v", events) + } + } + } + } + }) + } +} diff --git a/tsc/internal/fswatch/pathcompare.go b/tsc/internal/fswatch/pathcompare.go index 4ec26dba3c4c1..32ebc332fe8e7 100644 --- a/tsc/internal/fswatch/pathcompare.go +++ b/tsc/internal/fswatch/pathcompare.go @@ -9,22 +9,107 @@ type pathComparer struct { ignoreCase bool } -func (c pathComparer) equal(a, b string) bool { - return a == b || c.ignoreCase && strings.EqualFold(a, b) +// Watch roots are prepared before publication and are immutable thereafter. +// Event paths are local to one routing operation and folded only on demand. +type comparisonPath struct { + path string + folded string + ready bool + cache *comparisonCache +} + +// Shared only within a synchronous callback/termination pass, never published +// to a subscriber or stored on a watch. +type comparisonCache map[string]string + +func (c pathComparer) prepare(path string) comparisonPath { + p := comparisonPath{path: path} + if c.ignoreCase && nativePathFolding { + p.fold() + } + return p +} + +func (p *comparisonPath) fold() string { + if !p.ready { + if p.cache != nil { + if folded, ok := (*p.cache)[p.path]; ok { + p.folded, p.ready = folded, true + return folded + } + } + p.folded = foldNativePath(p.path) + p.ready = true + if p.cache != nil { + if *p.cache == nil { + *p.cache = make(comparisonCache) + } + (*p.cache)[p.path] = p.folded + } + } + return p.folded } // suffix returns the part of path below root, respecting directory boundaries. func (c pathComparer) suffix(root, path string) (string, bool) { - if isInDirectoryOrSelf(root, path) { - return path[len(root):], true + p := comparisonPath{path: path} + return c.suffixPrepared(comparisonPath{path: root}, &p) +} + +func (c pathComparer) suffixPrepared(root comparisonPath, path *comparisonPath) (string, bool) { + if isInDirectoryOrSelf(root.path, path.path) { + return path.path[len(root.path):], true } - if !c.ignoreCase || root == "" { + if !c.ignoreCase || root.path == "" { return "", false } - return pathSuffixFold(root, path) + suffix, ok, unicode := pathSuffixASCII(root.path, path.path) + if !unicode { + return suffix, ok + } + return c.suffixUnicode(root, path) } -func pathSuffixFold(root, path string) (string, bool) { +func (c pathComparer) suffixUnicode(root comparisonPath, path *comparisonPath) (string, bool) { + if !nativePathFolding { + return pathSuffixFoldUnicode(root.path, path.path) + } + a, b := root.fold(), path.fold() + if a == "" || b == "" { + // CFString cannot represent invalid UTF-8. Retain the simple-fold + // behavior for malformed paths rather than truncating or losing bytes. + return pathSuffixFoldUnicode(root.path, path.path) + } + if !isInDirectoryOrSelf(a, b) { + return "", false + } + if a == b { + return "", true + } + // Folding and canonical normalization preserve separators, but not byte + // lengths. Find the matching boundary in the original event, not its fold. + offset := 0 + separators := strings.Count(root.path, "/") + trailingSeparator := root.path[len(root.path)-1] == '/' + if !trailingSeparator { + separators++ + } + for range separators { + i := strings.IndexByte(path.path[offset:], '/') + if i < 0 { + panic("fswatch: folded path lost a directory boundary") + } + offset += i + 1 + } + if trailingSeparator { + return path.path[offset:], true + } + return path.path[offset-1:], true +} + +// The third result requests Unicode comparison; an ASCII rejection must not +// reject an expanding alias just because the other spelling is ASCII. +func pathSuffixASCII(root, path string) (string, bool, bool) { i := 0 // Skip shared prefixes a word at a time, which is common when routing an // event past sibling watches. String slice comparisons do not allocate. @@ -34,10 +119,7 @@ func pathSuffixFold(root, path string) (string, bool) { for ; i < len(root) && i < len(path); i++ { a, b := root[i], path[i] if a >= utf8.RuneSelf || b >= utf8.RuneSelf { - // A skipped word may end inside a rune. Restart this component - // rather than interpreting a partial UTF-8 encoding. - i = strings.LastIndexByte(root[:i], '/') + 1 - return pathSuffixFoldUnicode(root[i:], path[i:]) + return "", false, true } if a == b { continue @@ -45,13 +127,13 @@ func pathSuffixFold(root, path string) (string, bool) { a |= 0x20 b |= 0x20 if a != b || a < 'a' || a > 'z' { - return "", false + return "", false, false } } if i == len(root) && (i == len(path) || path[i] == '/') { - return path[i:], true + return path[i:], true, false } - return "", false + return "", false, i < len(root) && root[i] >= utf8.RuneSelf || i < len(path) && path[i] >= utf8.RuneSelf } // Comparing the remaining components avoids assuming case-equivalent UTF-8 @@ -82,13 +164,21 @@ func (c pathComparer) contains(root, path string) bool { } func (c pathComparer) rebase(path, from, to string) (string, bool) { - if isInDirectoryOrSelf(from, path) { - return rebasePath(path, from, to), true + p := comparisonPath{path: path} + return c.rebasePrepared(&p, comparisonPath{path: from}, to) +} + +func (c pathComparer) rebasePrepared(path *comparisonPath, from comparisonPath, to string) (string, bool) { + if isInDirectoryOrSelf(from.path, path.path) { + return rebasePath(path.path, from.path, to), true } - if !c.ignoreCase || from == "" { + if !c.ignoreCase || from.path == "" { return "", false } - suffix, ok := pathSuffixFold(from, path) + suffix, ok, unicode := pathSuffixASCII(from.path, path.path) + if unicode { + suffix, ok = c.suffixUnicode(from, path) + } if !ok { return "", false } diff --git a/tsc/internal/fswatch/pathcompare_test.go b/tsc/internal/fswatch/pathcompare_test.go index 386a1d4d4fa2e..49b810f22c1ef 100644 --- a/tsc/internal/fswatch/pathcompare_test.go +++ b/tsc/internal/fswatch/pathcompare_test.go @@ -41,7 +41,7 @@ func TestPathComparer(t *testing.T) { {"/k", "/\u212a/File.ts", "/File.ts", false, true}, {"/\u03c3", "/\u03c2/File.ts", "/File.ts", false, true}, {"/\u00e9", "/\u00c8/File.ts", "", false, false}, - {"/\u00df", "/SS/File.ts", "", false, false}, + {"/\u00df", "/SS/File.ts", "/File.ts", false, nativePathFolding}, {"/root/s", "/ROOT/\u017f/File.ts", "/File.ts", false, true}, {"/root/\u017f", "/ROOT/S", "", false, true}, } @@ -95,13 +95,14 @@ func TestFileCallbackCaseSensitivity(t *testing.T) { t.Parallel() for _, ignoreCase := range []bool{false, true} { var got []Event - cb := fileCallback("/root/file.ts", func(events []Event, err error) { + dw := newDirectWatcher(t, "/root") + dw.setComparer(pathComparer{ignoreCase: ignoreCase}) + dw.addCallback("/root", "/root", false, func(events []Event, err error) { got = append(got, events...) - }, pathComparer{ignoreCase: ignoreCase}) - cb([]Event{ - {Kind: EventUpdate, Path: "/root/FILE.ts"}, - {Kind: EventUpdate, Path: "/root/other.ts"}, - }, nil) + }, nil, "/root/file.ts") + dw.events.update("/root/FILE.ts") + dw.events.update("/root/other.ts") + dw.triggerCallbacks() if ignoreCase { if len(got) != 1 || got[0].Path != "/root/file.ts" { t.Fatalf("case-insensitive callback: got %v", got) diff --git a/tsc/internal/fswatch/watcher.go b/tsc/internal/fswatch/watcher.go index 4adf517348d68..cfaaacd1b15cc 100644 --- a/tsc/internal/fswatch/watcher.go +++ b/tsc/internal/fswatch/watcher.go @@ -462,7 +462,7 @@ func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive return dw, nil } dw := newDirWatch(dir, physicalDir, w.debounce) - dw.comparer = comparer + dw.setComparer(comparer) dw.sequence = w.sequence dw.recursive = recursive w.dirWatches[key] = dw @@ -547,15 +547,12 @@ func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, e rollback() return nil, err } - if sopts.file != "" { - fn = fileCallback(sopts.file, fn, comparer) - } dw, err := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive, comparer) if err != nil { rollback() return nil, err } - id, _ := dw.watch(dir, physicalDir, sopts.recursive, fn, sopts.ignore) + id, _ := dw.addCallback(dir, physicalDir, sopts.recursive, fn, sopts.ignore, sopts.file) prepared = append(prepared, preparedWatch{dw: dw, id: id, recursive: sopts.recursive, dir: dir}) if _, ok := seenDirWatches[dw]; !ok { seenDirWatches[dw] = struct{}{} @@ -611,25 +608,6 @@ func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) { return w.WatchDirectory(dir, fn, fileOption{path: path}) } -// fileCallback wraps a WatchCallback so it only sees events for the -// specific target path. Errors are always forwarded (with any matching -// events delivered alongside) so callers don't lose overflow signals -// just because their target wasn't in the same batch. -func fileCallback(target string, fn WatchCallback, comparer pathComparer) WatchCallback { - return func(events []Event, err error) { - var filtered []Event - for _, e := range events { - if comparer.equal(e.Path, target) { - e.Path = target - filtered = append(filtered, e) - } - } - if len(filtered) > 0 || err != nil { - fn(filtered, err) - } - } -} - type watch struct { mu sync.Mutex w *watcher @@ -802,18 +780,21 @@ func (b *watcherBase) handleWatcherError(werr *dirWatchError) { // ----- dirWatch: per-directory watch state ------------------------- type callback struct { - id uint64 - dir string - physicalDir string - watchDir string - watchPhysicalDir string - recursive bool - fn WatchCallback - ignore func(path string) bool - sinceSeq uint64 - terminal error - delivered bool - comparer pathComparer + id uint64 + dir string + physicalDir string + watchDir string + watchPhysicalDir string + recursive bool + fn WatchCallback + ignore func(path string) bool + sinceSeq uint64 + terminal error + delivered bool + comparer pathComparer + dirComparison comparisonPath + physicalComparison comparisonPath + fileComparison comparisonPath } // dirWatchError associates an error with a specific directory watch. @@ -832,10 +813,12 @@ type dirWatch struct { dir string // physicalDir is the path passed to OS watcher APIs. It differs from dir // when dir or an ancestor is a symlink or reparse point to a directory. - physicalDir string - recursive bool - events eventList - comparer pathComparer + physicalDir string + recursive bool + events eventList + comparer pathComparer + dirFold string + physicalDirFold string // state stores per-directory platform-specific bookkeeping (fsevents, windows). state any @@ -855,6 +838,16 @@ func newDirWatch(dir string, physicalDir string, db *debounce) *dirWatch { return dw } +func (dw *dirWatch) setComparer(comparer pathComparer) { + dw.comparer = comparer + dw.dirFold = comparer.prepare(dw.dir).folded + if dw.physicalDir == dw.dir { + dw.physicalDirFold = dw.dirFold + } else { + dw.physicalDirFold = comparer.prepare(dw.physicalDir).folded + } +} + // physicalDirFor returns the physical path to watch for dir. If dir, or an // ancestor of dir, is a symlink or reparse point, events are subscribed on its // realpath while callbacks still use dir. @@ -1001,12 +994,20 @@ func (dw *dirWatch) triggerCallbacks() { } dw.mu.Unlock() + var comparisons comparisonCache for i, cb := range cbs { cbEvents := eventsByCallback[i] - if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir { + if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir || cb.fileComparison.path != "" { filtered := make([]Event, 0, len(cbEvents)) for _, e := range cbEvents { - e = cb.mapEvent(e) + e = cb.mapEventCached(e, &comparisons) + if cb.fileComparison.path != "" { + path := comparisonPath{path: e.Path, cache: &comparisons} + if suffix, ok := cb.comparer.suffixPrepared(cb.fileComparison, &path); !ok || suffix != "" { + continue + } + e.Path = cb.fileComparison.path + } if cb.ignore != nil && cb.ignore(e.Path) { continue } @@ -1035,9 +1036,17 @@ func (dw *dirWatch) triggerCallbacks() { } func (cb callback) mapEvent(e Event) Event { + return cb.mapEventCached(e, nil) +} + +func (cb callback) mapEventCached(e Event, cache *comparisonCache) Event { if cb.physicalDir != "" && (cb.physicalDir != cb.dir || cb.comparer.ignoreCase) { - physicalPath := cb.eventPhysicalPath(e.Path) - if path, ok := cb.comparer.rebase(physicalPath, cb.physicalDir, cb.dir); ok { + physicalPath := comparisonPath{path: cb.eventPhysicalPath(e.Path), cache: cache} + root := cb.physicalComparison + if root.path == "" { + root.path = cb.physicalDir + } + if path, ok := cb.comparer.rebasePrepared(&physicalPath, root, cb.dir); ok { e.Path = path } } @@ -1055,13 +1064,18 @@ func (dw *dirWatch) terminateCallbacksForDeletedRoot(path string, seq uint64, er dw.mu.Lock() defer dw.mu.Unlock() changed := false + var comparisons comparisonCache + deleted := comparisonPath{path: path, cache: &comparisons} for i := range dw.callbacks { cb := &dw.callbacks[i] if cb.delivered || cb.terminal != nil || cb.sinceSeq >= seq { continue } - physicalPath := cb.eventPhysicalPath(path) - if cb.comparer.contains(path, cb.dir) || (cb.physicalDir != cb.dir && cb.comparer.contains(physicalPath, cb.physicalDir)) { + physicalPath := comparisonPath{path: cb.eventPhysicalPath(path), cache: &comparisons} + dir, physical := cb.dirComparison, cb.physicalComparison + _, logicalMatch := cb.comparer.suffixPrepared(deleted, &dir) + _, physicalMatch := cb.comparer.suffixPrepared(physicalPath, &physical) + if logicalMatch || physicalMatch { cb.terminal = err changed = true } @@ -1107,6 +1121,10 @@ func isDirectChild(dir, path string) bool { } func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool) (uint64, bool) { + return dw.addCallback(dir, physicalDir, recursive, fn, ignore, "") +} + +func (dw *dirWatch) addCallback(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool, file string) (uint64, bool) { dw.mu.Lock() defer dw.mu.Unlock() dw.nextCBID++ @@ -1115,7 +1133,12 @@ func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn Wat if dw.sequence != nil { sinceSeq = dw.sequence() } - dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq, comparer: dw.comparer}) + dw.callbacks = append(dw.callbacks, callback{ + id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, + recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq, comparer: dw.comparer, + dirComparison: dw.comparer.prepare(dir), physicalComparison: dw.comparer.prepare(physicalDir), + fileComparison: dw.comparer.prepare(file), + }) return id, true } diff --git a/tsc/internal/fswatch/watcher_test.go b/tsc/internal/fswatch/watcher_test.go index 2b01060e525f0..57d8c41e8e9a7 100644 --- a/tsc/internal/fswatch/watcher_test.go +++ b/tsc/internal/fswatch/watcher_test.go @@ -2107,9 +2107,23 @@ func TestFileCallbackForwardsErrAlongsideEvents(t *testing.T) { err error } var got []call - cb := fileCallback(target, func(events []Event, err error) { + dw := newDirectWatcher(t, "/abs/dir") + dw.addCallback("/abs/dir", "/abs/dir", false, func(events []Event, err error) { got = append(got, call{events: events, err: err}) - }, pathComparer{}) + }, nil, target) + cb := func(events []Event, err error) { + for _, e := range events { + if e.Kind == EventDelete { + dw.events.remove(e.Path) + } else { + dw.events.update(e.Path) + } + } + if err != nil { + dw.events.setError(err) + } + dw.triggerCallbacks() + } // Plain events: only target events pass through, sibling dropped. cb([]Event{{Kind: EventUpdate, Path: target}, {Kind: EventUpdate, Path: other}}, nil) From b7496ddd80553c8fe6e0dc7bb520b429c6d933ca Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:31:22 -0700 Subject: [PATCH 4/5] Use volume-aware file matching for macOS kqueue Enable the existing Darwin path comparer for kqueue subscriptions so WatchFile recognizes alternate casing, expanding Unicode aliases, and normalization-equivalent names reported by directory enumeration. Add shared Darwin coverage for creation, modification, deletion, and distinct filenames. Preserve kqueue directory-event spelling and leave case-sensitive comparison and other platforms unchanged. --- tsc/internal/fswatch/README.md | 18 +-- tsc/internal/fswatch/canonicalize_darwin.go | 15 +- tsc/internal/fswatch/fsevents_darwin_ffi.go | 7 +- .../fswatch/fsevents_darwin_fold_test.go | 25 +--- .../fswatch/pathcompare_darwin_test.go | 136 ++++++++++++++++++ 5 files changed, 157 insertions(+), 44 deletions(-) create mode 100644 tsc/internal/fswatch/pathcompare_darwin_test.go diff --git a/tsc/internal/fswatch/README.md b/tsc/internal/fswatch/README.md index 11545f11f9010..c53f8a8328ed8 100644 --- a/tsc/internal/fswatch/README.md +++ b/tsc/internal/fswatch/README.md @@ -93,15 +93,17 @@ if errors.Is(err, fswatch.ErrWatchTerminated) { - Paths in events are absolute. Subscribing through a directory symlink follows its target while preserving the caller-visible root in delivered paths. -On macOS, paths are normalized to NFC. On volumes reporting case-insensitive -lookup, FSEvents matches 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 and the -event's NFC suffix; file events use the subscribed filename. Symlink-root -subscriptions likewise retain the caller-visible root. +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 -other watcher backends retain exact comparison. +watcher backends on other platforms retain exact comparison. diff --git a/tsc/internal/fswatch/canonicalize_darwin.go b/tsc/internal/fswatch/canonicalize_darwin.go index 8a54f485079cf..00cd83342d582 100644 --- a/tsc/internal/fswatch/canonicalize_darwin.go +++ b/tsc/internal/fswatch/canonicalize_darwin.go @@ -8,19 +8,14 @@ import ( "golang.org/x/sys/unix" ) -// 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. +// 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" { + if w.name != "fsevents" && w.name != "kqueue" { return pathComparer{}, nil } // _PC_CASE_SENSITIVE from sys/unistd.h. Query the watched volume rather diff --git a/tsc/internal/fswatch/fsevents_darwin_ffi.go b/tsc/internal/fswatch/fsevents_darwin_ffi.go index 68ec91f1f998e..d3932f8a340ad 100644 --- a/tsc/internal/fswatch/fsevents_darwin_ffi.go +++ b/tsc/internal/fswatch/fsevents_darwin_ffi.go @@ -152,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 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 diff --git a/tsc/internal/fswatch/fsevents_darwin_fold_test.go b/tsc/internal/fswatch/fsevents_darwin_fold_test.go index c945babb65a5b..c52b8a0e0f573 100644 --- a/tsc/internal/fswatch/fsevents_darwin_fold_test.go +++ b/tsc/internal/fswatch/fsevents_darwin_fold_test.go @@ -190,29 +190,6 @@ func TestFSEventsLazyPathFolding(t *testing.T) { } } -// Check identity independently of the comparer, including exclusive creation. -// Filesystems that do not support a particular alias cannot exercise its watch. -func requireFSEventsAlias(t *testing.T, a, b string) { - t.Helper() - first, err := os.Stat(a) - if err != nil { - t.Fatal(err) - } - second, err := os.Stat(b) - if errors.Is(err, os.ErrNotExist) { - t.Skip("filesystem does not alias these spellings") - } - if err != nil { - t.Fatal(err) - } - if !os.SameFile(first, second) { - t.Fatal("alternate spelling resolved to a different inode") - } - if err := os.Mkdir(b, 0o755); !errors.Is(err, os.ErrExist) { - t.Fatalf("exclusive alternate creation: %v", err) - } -} - func TestFSEventsExpansionAliases(t *testing.T) { t.Parallel() for _, pair := range fseventsFoldPairs { @@ -231,7 +208,7 @@ func TestFSEventsExpansionAliases(t *testing.T) { if err := os.Mkdir(disk, 0o755); err != nil { t.Fatal(err) } - requireFSEventsAlias(t, disk, root) + requireDarwinAlias(t, disk, root) nested := filepath.Join(disk, "Nested") if err := os.Mkdir(nested, 0o755); err != nil { t.Fatal(err) diff --git a/tsc/internal/fswatch/pathcompare_darwin_test.go b/tsc/internal/fswatch/pathcompare_darwin_test.go new file mode 100644 index 0000000000000..313b5b5cabaa7 --- /dev/null +++ b/tsc/internal/fswatch/pathcompare_darwin_test.go @@ -0,0 +1,136 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// Check identity independently of the comparer, including exclusive creation. +// Filesystems that do not support a particular alias cannot exercise its watch. +func requireDarwinAlias(t *testing.T, a, b string) { + t.Helper() + first, err := os.Stat(a) + if err != nil { + t.Fatal(err) + } + second, err := os.Stat(b) + if errors.Is(err, os.ErrNotExist) { + t.Skip("filesystem does not alias these spellings") + } + if err != nil { + t.Fatal(err) + } + if !os.SameFile(first, second) { + t.Fatal("alternate spelling resolved to a different inode") + } + if err := os.Mkdir(b, 0o755); !errors.Is(err, os.ErrExist) { + t.Fatalf("exclusive alternate creation: %v", err) + } +} + +func TestDarwinWatchFileComparison(t *testing.T) { + t.Parallel() + cases := []struct { + name string + diskRoot, watchRoot string + diskFile, watchFile string + alias bool + }{ + {"root-case", "Mixed", "mixed", "file.ts", "file.ts", true}, + {"leaf-case", "root", "root", "File.ts", "file.ts", true}, + {"sharp-s", "root", "root", "\u00df.ts", "SS.ts", true}, + {"dotted-i", "root", "root", "\u0130.ts", "i\u0307.ts", true}, + {"ligature", "root", "root", "\ufb03.ts", "ffi.ts", true}, + {"normalization", "root", "root", "cafe\u0301.ts", "caf\u00e9.ts", true}, + {"dotless-i", "root", "root", "I.ts", "\u0131.ts", false}, + {"ascii-dotted-i", "root", "root", "i.ts", "\u0130.ts", false}, + {"fullwidth", "root", "root", "\uff21.ts", "A.ts", false}, + {"sibling", "root", "root", "file.ts2", "file.ts", false}, + {"case-sensitive", "root", "root", "File.ts", "file.ts", false}, + } + for _, impl := range []Watcher{Kqueue(), FSEvents()} { + for _, c := range cases { + t.Run(impl.Name()+"/"+c.name, func(t *testing.T) { + t.Parallel() + for _, reverse := range []bool{false, true} { + diskRoot, watchRoot := c.diskRoot, c.watchRoot + diskName, watchName := c.diskFile, c.watchFile + if reverse { + diskRoot, watchRoot = watchRoot, diskRoot + diskName, watchName = watchName, diskName + } + parent := newTmpDir(t) + diskRoot, watchRoot = filepath.Join(parent, diskRoot), filepath.Join(parent, watchRoot) + if err := os.Mkdir(diskRoot, 0o755); err != nil { + t.Fatal(err) + } + requireDarwinAlias(t, diskRoot, watchRoot) + diskFile, watchFile := filepath.Join(diskRoot, diskName), filepath.Join(watchRoot, watchName) + if err := os.WriteFile(diskFile, nil, 0o644); err != nil { + t.Fatal(err) + } + if c.alias { + requireDarwinAlias(t, diskFile, watchFile) + } else { + f, err := os.OpenFile(watchFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + t.Skip("filesystem aliases these spellings") + } + if err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + a, err := os.Stat(diskFile) + if err != nil { + t.Fatal(err) + } + b, err := os.Stat(watchFile) + if err != nil || os.SameFile(a, b) { + t.Fatalf("expected distinct inodes: %v", err) + } + } + // Subscribe while the target is absent to exercise discovery + // as well as subsequent fd-based updates and deletion. + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + dir, _ := subscribeForOpts(t, watchRoot, impl) + file, _ := subscribeFileFor(t, watchFile, impl) + wantDir := filepath.Join(watchRoot, diskName) + if impl == FSEvents() { + wantDir = canonicalizePath(wantDir) + } + for round := range 3 { + kind := EventUpdate + if round == 2 { + kind = EventDelete + if err := os.Remove(diskFile); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(diskFile, make([]byte, round+1), 0o644); err != nil { + t.Fatal(err) + } + expectContains(t, dir, kind, wantDir) + if c.alias { + events := expectContains(t, file, kind, canonicalizePath(watchFile)) + for _, e := range events { + if e.Path != canonicalizePath(watchFile) { + t.Fatalf("unexpected file event spelling: %q", e.Path) + } + } + } else if events := file.next(400 * time.Millisecond); len(events) != 0 { + t.Fatalf("cross-routed distinct filename: %v", events) + } + } + } + }) + } + } +} From 1adb73b583cfbe7d2b4f3456c437772b1962d9c7 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:34:07 -0700 Subject: [PATCH 5/5] Document macOS watcher path comparison behavior Describe volume-aware matching for FSEvents and kqueue, native Unicode folding, backend-specific event spelling, and cached comparison forms. Clarify the scope of the APFS observations and unchanged platform behavior. --- tsc/internal/fswatch/CHANGES.md | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tsc/internal/fswatch/CHANGES.md b/tsc/internal/fswatch/CHANGES.md index 36d35bb066957..ffa849099e80e 100644 --- a/tsc/internal/fswatch/CHANGES.md +++ b/tsc/internal/fswatch/CHANGES.md @@ -149,12 +149,33 @@ 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. -FSEvents path routing and file filtering use the watched volume's case -sensitivity, queried with `pathconf`, rather than assuming event paths have the -same casing as the subscription. Delivered paths retain the caller's watch-root -casing (or the entire requested path for `WatchFile`), while descendant names -retain the casing reported by FSEvents. Overflow and logical-root deletion -matching use the same comparison rules. +### 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