Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Optimize FSEvents path matching and watch setup
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
  • Loading branch information
jakebailey committed Sep 7, 2026
commit eee9f292580c1a0fb0cbfd68191639b73fc67f54
70 changes: 70 additions & 0 deletions tsc/internal/fswatch/fsevents_darwin_bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
48 changes: 44 additions & 4 deletions tsc/internal/fswatch/pathcompare.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package fswatch

import "strings"
import (
"strings"
"unicode/utf8"
)

type pathComparer struct {
ignoreCase bool
Expand All @@ -11,15 +14,49 @@ 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
}
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, "/")
Expand Down Expand Up @@ -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
}
Expand Down
52 changes: 51 additions & 1 deletion tsc/internal/fswatch/pathcompare_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package fswatch

import "testing"
import (
"strings"
"testing"
)

func TestPathComparer(t *testing.T) {
t.Parallel()
Expand All @@ -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} {
Expand All @@ -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)
}
}
}
}
}
}
Expand Down
20 changes: 15 additions & 5 deletions tsc/internal/fswatch/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down