Skip to content
Closed
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
55 changes: 55 additions & 0 deletions tsc/internal/fswatch/fsevents_darwin_case_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
14 changes: 9 additions & 5 deletions tsc/internal/fswatch/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion tsc/internal/nativepath/eintr_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux
//go:build linux || darwin

package nativepath

Expand Down
4 changes: 4 additions & 0 deletions tsc/internal/nativepath/realpath_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
56 changes: 56 additions & 0 deletions tsc/internal/nativepath/realpath_directory_darwin.go
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 34 additions & 0 deletions tsc/internal/nativepath/realpath_directory_darwin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions tsc/internal/nativepath/realpath_directory_other.go
Original file line number Diff line number Diff line change
@@ -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)
}