diff --git a/tsc/internal/fswatch/CHANGES.md b/tsc/internal/fswatch/CHANGES.md index 3f3fb4153991b..ffa849099e80e 100644 --- a/tsc/internal/fswatch/CHANGES.md +++ b/tsc/internal/fswatch/CHANGES.md @@ -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 diff --git a/tsc/internal/fswatch/README.md b/tsc/internal/fswatch/README.md index 82f2759224a4d..c53f8a8328ed8 100644 --- a/tsc/internal/fswatch/README.md +++ b/tsc/internal/fswatch/README.md @@ -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. diff --git a/tsc/internal/fswatch/canonicalize_darwin.go b/tsc/internal/fswatch/canonicalize_darwin.go index 9a81ffdfb5b77..00cd83342d582 100644 --- a/tsc/internal/fswatch/canonicalize_darwin.go +++ b/tsc/internal/fswatch/canonicalize_darwin.go @@ -2,13 +2,28 @@ 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 + } + // _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..5444d290b05e8 100644 --- a/tsc/internal/fswatch/canonicalize_other.go +++ b/tsc/internal/fswatch/canonicalize_other.go @@ -2,7 +2,17 @@ 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 +} diff --git a/tsc/internal/fswatch/fsevents_darwin.go b/tsc/internal/fswatch/fsevents_darwin.go index 6e0614ef0aad6..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 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 } 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..047610c4e28d0 --- /dev/null +++ b/tsc/internal/fswatch/fsevents_darwin_bench_test.go @@ -0,0 +1,117 @@ +//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}, + {"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) + } + 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}} + watches[i].setComparer(watches[i].comparer) + } + 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() { + event := comparisonPath{path: path} + for i := range watches { + 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_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/fsevents_darwin_ffi.go b/tsc/internal/fswatch/fsevents_darwin_ffi.go index 30294b9a43f51..d3932f8a340ad 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" @@ -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 @@ -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 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..c52b8a0e0f573 --- /dev/null +++ b/tsc/internal/fswatch/fsevents_darwin_fold_test.go @@ -0,0 +1,337 @@ +//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) + } +} + +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) + } + requireDarwinAlias(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 new file mode 100644 index 0000000000000..32ebc332fe8e7 --- /dev/null +++ b/tsc/internal/fswatch/pathcompare.go @@ -0,0 +1,186 @@ +package fswatch + +import ( + "strings" + "unicode/utf8" +) + +type pathComparer struct { + ignoreCase bool +} + +// 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) { + 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.path == "" { + return "", false + } + suffix, ok, unicode := pathSuffixASCII(root.path, path.path) + if !unicode { + return suffix, ok + } + return c.suffixUnicode(root, path) +} + +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. + 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 { + return "", false, true + } + if a == b { + continue + } + a |= 0x20 + b |= 0x20 + if a != b || a < 'a' || a > 'z' { + return "", false, false + } + } + if i == len(root) && (i == len(path) || path[i] == '/') { + return path[i:], true, 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 +// 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, "/") + 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) { + 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.path == "" { + return "", false + } + suffix, ok, unicode := pathSuffixASCII(from.path, path.path) + if unicode { + suffix, ok = c.suffixUnicode(from, path) + } + if !ok { + return "", false + } + return joinPathSuffix(to, suffix), true +} 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) + } + } + } + }) + } + } +} diff --git a/tsc/internal/fswatch/pathcompare_test.go b/tsc/internal/fswatch/pathcompare_test.go new file mode 100644 index 0000000000000..49b810f22c1ef --- /dev/null +++ b/tsc/internal/fswatch/pathcompare_test.go @@ -0,0 +1,114 @@ +package fswatch + +import ( + "strings" + "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}, + {"/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", "/File.ts", false, nativePathFolding}, + {"/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} { + 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) + } + 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) + } + } + } + } + } +} + +func TestFileCallbackCaseSensitivity(t *testing.T) { + t.Parallel() + for _, ignoreCase := range []bool{false, true} { + var got []Event + dw := newDirectWatcher(t, "/root") + dw.setComparer(pathComparer{ignoreCase: ignoreCase}) + dw.addCallback("/root", "/root", false, func(events []Event, err error) { + got = append(got, events...) + }, 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) + } + } 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..cfaaacd1b15cc 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 { @@ -371,10 +382,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 +427,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 +438,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.setComparer(comparer) dw.sequence = w.sequence dw.recursive = recursive w.dirWatches[key] = dw - return dw + return dw, nil } func (w *watcher) removeDirWatch(dw *dirWatch) { @@ -524,8 +542,17 @@ func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, e o.applyWatchOption(&sopts) } - dw := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive) - id, _ := dw.watch(dir, physicalDir, sopts.recursive, fn, sopts.ignore) + 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.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{}{} @@ -578,25 +605,7 @@ func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) { return nil, errRootPath } - return w.WatchDirectory(dir, fileCallback(path, fn)) -} - -// 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 { - return func(events []Event, err error) { - var filtered []Event - for _, e := range events { - if e.Path == target { - filtered = append(filtered, e) - } - } - if len(filtered) > 0 || err != nil { - fn(filtered, err) - } - } + return w.WatchDirectory(dir, fn, fileOption{path: path}) } type watch struct { @@ -771,17 +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 + 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. @@ -800,9 +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 + physicalDir string + recursive bool + events eventList + comparer pathComparer + dirFold string + physicalDirFold string // state stores per-directory platform-specific bookkeeping (fsevents, windows). state any @@ -822,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. @@ -968,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 } @@ -1002,10 +1036,18 @@ func (dw *dirWatch) triggerCallbacks() { } func (cb callback) mapEvent(e Event) Event { - if cb.physicalDir != "" && cb.physicalDir != cb.dir { - physicalPath := cb.eventPhysicalPath(e.Path) - if isInDirectoryOrSelf(cb.physicalDir, physicalPath) { - e.Path = rebasePath(physicalPath, cb.physicalDir, cb.dir) + 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 := 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 } } return e @@ -1022,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 isInDirectoryOrSelf(path, cb.dir) || (cb.physicalDir != cb.dir && isInDirectoryOrSelf(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 } @@ -1074,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++ @@ -1082,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}) + 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 577c6acb2f4da..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}) - }) + }, 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)