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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions tsc/internal/fswatch/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ logical root, physical root, event-ID cutoff, and termination state, so
late-added watches don't receive older queued events and symlinked watch roots
continue reporting caller-visible paths.

### macOS path comparison

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

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

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

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

## New backends

**fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It
Expand Down
23 changes: 17 additions & 6 deletions tsc/internal/fswatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,20 @@ if errors.Is(err, fswatch.ErrWatchTerminated) {
- Event order within a batch is **not guaranteed**.
- The callback runs on a library goroutine, not the caller's. Each watch's
callback is serialized (never concurrent with itself).
- Paths in events are absolute. **Resolve symlinks before subscribing**;
backends report canonical paths:

```go
realDir, err := filepath.EvalSymlinks(dir)
```
- Paths in events are absolute. Subscribing through a directory symlink follows
its target while preserving the caller-visible root in delivered paths.

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

The fold has been compared with actual aliases and distinct names on
case-insensitive APFS. It is not a guarantee of identical Unicode lookup
tables on every filesystem or macOS version. Case-sensitive volumes and
watcher backends on other platforms retain exact comparison.
33 changes: 24 additions & 9 deletions tsc/internal/fswatch/canonicalize_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions tsc/internal/fswatch/canonicalize_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
41 changes: 33 additions & 8 deletions tsc/internal/fswatch/fsevents_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
if path == "" {
continue
}
comparison := comparisonPath{path: path}

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

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

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

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

func fseventsOverflowMatchesPrepared(w *dirWatch, rawPath *comparisonPath) bool {
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
if _, ok := w.comparer.suffixPrepared(physical, rawPath); ok {
return true
}
return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir))
if _, ok := w.comparer.suffixPrepared(*rawPath, &physical); ok {
return true
}
if w.physicalDir != w.dir {
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
if _, ok := w.comparer.suffixPrepared(logical, rawPath); ok {
return true
}
_, ok := w.comparer.suffixPrepared(*rawPath, &logical)
return ok
}
return false
}
117 changes: 117 additions & 0 deletions tsc/internal/fswatch/fsevents_darwin_bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
}
Loading