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..109a8e64b9a6e --- /dev/null +++ b/tsc/internal/fswatch/fsevents_darwin_case_test.go @@ -0,0 +1,55 @@ +//go:build darwin && (amd64 || arm64) + +package fswatch + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +func TestFSEventsDiskCasingDiffersFromSubscription(t *testing.T) { + t.Parallel() + + runWithRetry(t, func(rt testingT) { + parent := newTmpDir(rt) + actualDir := filepath.Join(parent, "MixedCase") + requestedDir := filepath.Join(parent, "mixedcase") + if err := os.Mkdir(actualDir, 0o755); err != nil { + rt.Fatal(err) + } + if _, err := os.Stat(requestedDir); err != nil { + if errors.Is(err, fs.ErrNotExist) { + rt.Skip("filesystem is case-sensitive") + } + rt.Fatal(err) + } + + r, _ := subscribeFor(rt, requestedDir, FSEvents()) + requestedChild := filepath.Join(requestedDir, "hello.txt") + if err := os.WriteFile(requestedChild, []byte("hi"), 0o644); err != nil { + rt.Fatal(err) + } + + got := r.waitForEvent(r.deadline(), func(event Event) bool { + return event.Path == requestedChild + }) + got = append(got, r.next(500*time.Millisecond)...) + actualChild := filepath.Join(actualDir, "hello.txt") + foundRequested := false + for _, event := range got { + if event.Path == requestedChild { + foundRequested = true + } + if event.Path == actualChild { + rt.Fatalf("event path used filesystem casing instead of caller casing: %v", got) + } + } + if !foundRequested { + rt.Fatalf("event for %q not received: %v", requestedChild, got) + } + }) +} diff --git a/tsc/internal/fswatch/watcher.go b/tsc/internal/fswatch/watcher.go index fa8a7489841d7..9f6ec501cb383 100644 --- a/tsc/internal/fswatch/watcher.go +++ b/tsc/internal/fswatch/watcher.go @@ -72,6 +72,9 @@ type Watcher interface { // dir must be an absolute path to an existing directory. If dir is a // symlink or reparse point to a directory, the OS subscription follows // the target directory but delivered event paths remain rooted at dir. + // On case-insensitive filesystems, dir may use different casing from the + // filesystem; the watch-root prefix in delivered paths retains the casing + // supplied by the caller. // Userspace recursive traversal does not follow symlinked descendant // directories. // Returns [ErrUnavailable] if the watcher is not supported on @@ -822,12 +825,13 @@ func newDirWatch(dir string, physicalDir string, db *debounce) *dirWatch { return dw } -// 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. +// physicalDirFor returns the physical path to watch for dir. It resolves +// symlinks and reparse points in dir and its ancestors, and returns the +// filesystem's path spelling where supported so it matches backend event paths. +// Callbacks still use dir. func physicalDirFor(dir string) string { - realpath, err := nativepath.Realpath(dir) - if err != nil { + realpath, err := nativepath.RealpathDirectory(dir) + if err != nil || realpath == "" || !filepath.IsAbs(realpath) { return dir } if realpath == dir { diff --git a/tsc/internal/nativepath/eintr_unix.go b/tsc/internal/nativepath/eintr_unix.go index 00adcf917b6e8..9e2df3108e075 100644 --- a/tsc/internal/nativepath/eintr_unix.go +++ b/tsc/internal/nativepath/eintr_unix.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || darwin package nativepath diff --git a/tsc/internal/nativepath/realpath_darwin_test.go b/tsc/internal/nativepath/realpath_darwin_test.go index a606a8d77c2bc..8dac546178495 100644 --- a/tsc/internal/nativepath/realpath_darwin_test.go +++ b/tsc/internal/nativepath/realpath_darwin_test.go @@ -57,6 +57,10 @@ func TestRealpathHardlinkedFile(t *testing.T) { got, err := Realpath(alias) assert.NilError(t, err) assert.Equal(t, got, want) + + got, err = RealpathDirectory(alias) + assert.NilError(t, err) + assert.Equal(t, got, want) } stopWorker() diff --git a/tsc/internal/nativepath/realpath_directory_darwin.go b/tsc/internal/nativepath/realpath_directory_darwin.go new file mode 100644 index 0000000000000..d47817b3ec42a --- /dev/null +++ b/tsc/internal/nativepath/realpath_directory_darwin.go @@ -0,0 +1,56 @@ +//go:build darwin + +package nativepath + +import ( + "path/filepath" + "unsafe" + + "golang.org/x/sys/unix" +) + +func fcntlGetPath(fd int, buf *[unix.PathMax]byte) (int, error) { + return ignoringEINTR(func() (int, error) { + return fcntlGetPathPtr(uintptr(fd), uintptr(unsafe.Pointer(&buf[0]))) + }) +} + +// Keep the pointer conversion in the call to this function. The compiler +// directive below keeps the buffer alive across fcntl. +// +//go:uintptrescapes +func fcntlGetPathPtr(fd uintptr, buf uintptr) (int, error) { + return unix.FcntlInt(fd, unix.F_GETPATH, int(buf)) +} + +// RealpathDirectory returns the kernel's path spelling for a directory. +// F_GETPATH is restricted to directories because files with multiple hard +// links can have multiple valid kernel paths. Other inputs and failures use +// Realpath. +func RealpathDirectory(path string) (string, error) { + fd, err := ignoringEINTR(func() (int, error) { + return unix.Open(path, unix.O_EVTONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_DIRECTORY, 0) + }) + if err != nil { + return Realpath(path) + } + defer unix.Close(fd) + + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return Realpath(path) + } + if stat.Mode&unix.S_IFMT != unix.S_IFDIR { + return Realpath(path) + } + + var buf [unix.PathMax]byte + if _, err := fcntlGetPath(fd, &buf); err != nil { + return Realpath(path) + } + realpath := unix.ByteSliceToString(buf[:]) + if realpath == "" || !filepath.IsAbs(realpath) { + return Realpath(path) + } + return realpath, nil +} diff --git a/tsc/internal/nativepath/realpath_directory_darwin_test.go b/tsc/internal/nativepath/realpath_directory_darwin_test.go new file mode 100644 index 0000000000000..13b7ebe8085cb --- /dev/null +++ b/tsc/internal/nativepath/realpath_directory_darwin_test.go @@ -0,0 +1,34 @@ +//go:build darwin + +package nativepath + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +func TestRealpathDirectoryPreservesDiskCasing(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + parent, err := filepath.EvalSymlinks(parent) + assert.NilError(t, err) + actual := filepath.Join(parent, "MixedCase") + requested := filepath.Join(parent, "mixedcase") + assert.NilError(t, os.Mkdir(actual, 0o755)) + if _, statErr := os.Stat(requested); statErr != nil { + if errors.Is(statErr, fs.ErrNotExist) { + t.Skip("filesystem is case-sensitive") + } + t.Fatal(statErr) + } + + got, err := RealpathDirectory(requested) + assert.NilError(t, err) + assert.Equal(t, got, actual) +} diff --git a/tsc/internal/nativepath/realpath_directory_other.go b/tsc/internal/nativepath/realpath_directory_other.go new file mode 100644 index 0000000000000..35a2da441a567 --- /dev/null +++ b/tsc/internal/nativepath/realpath_directory_other.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package nativepath + +// RealpathDirectory delegates to Realpath without checking that path is a +// directory. macOS provides a directory-specific implementation. +func RealpathDirectory(path string) (string, error) { + return Realpath(path) +}