Skip to content
Merged
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
36 changes: 9 additions & 27 deletions tsc/internal/execute/watchmanager/watchmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,21 +177,6 @@ func (wm *WatchManager) CloseAllWatches() {
}
}

func (wm *WatchManager) createDirWatch(dir string, recursive bool) error {
entry := &watchedDir{recursive: recursive}
request := wm.createDirWatchRequest(dir, entry)
watch, err := wm.backend.WatchDirectory(request.Dir, request.Callback, request.Recursive, request.Ignore)
if err != nil {
if wm.DebugLog != nil {
fmt.Fprintf(wm.DebugLog, "[watch] failed to watch directory %s: %v\n", dir, err)
}
return fmt.Errorf("failed to watch directory %s: %w", dir, err)
}
entry.closer = watch
wm.watchedDirs[dir] = entry
return nil
}

func (wm *WatchManager) createDirWatchRequest(dir string, entry *watchedDir) WatchDirectoryRequest {
return WatchDirectoryRequest{
Dir: dir,
Expand Down Expand Up @@ -288,22 +273,19 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error {
requests[i] = wm.createDirWatchRequest(update.dir, entry)
}
closers, err := wm.backend.WatchDirectories(requests)
if err != nil {
if err == nil {
for i, update := range updates {
if wm.DebugLog != nil {
fmt.Fprintf(wm.DebugLog, "[watch] failed to watch directory %s: %v\n", update.dir, err)
}
if i < len(closers) && closers[i] != nil {
closers[i].Close()
}
entries[i].closer = closers[i]
wm.watchedDirs[update.dir] = entries[i]
}
return err
return nil
}
for i, update := range updates {
entries[i].closer = closers[i]
wm.watchedDirs[update.dir] = entries[i]
if wm.DebugLog != nil {
for _, update := range updates {
fmt.Fprintf(wm.DebugLog, "[watch] failed to watch directory %s: %v\n", update.dir, err)
}
}
return nil
return err
}

// DirWatchSet accumulates the set of directories that should be watched while
Expand Down
222 changes: 222 additions & 0 deletions tsc/internal/fswatch/fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package fswatch

import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"testing"
)

type fakeFallbackWatch struct {
watcher *fakeFallbackWatcher
dir string
}

func (w *fakeFallbackWatch) Close() error {
w.watcher.mu.Lock()
defer w.watcher.mu.Unlock()
w.watcher.closed = append(w.watcher.closed, w.dir)
return nil
}

func (w *fakeFallbackWatch) unexported() {}

type fakeFallbackWatcher struct {
mu sync.Mutex
name string
failWith error
failDir func(string) bool
watched []string
closed []string
}

func (w *fakeFallbackWatcher) Name() string { return w.name }
func (w *fakeFallbackWatcher) Available() bool { return true }
func (w *fakeFallbackWatcher) HasFastRecursiveBackend() bool { return false }

func (w *fakeFallbackWatcher) shouldFail(dir string) bool {
return w.failWith != nil && (w.failDir == nil || w.failDir(dir))
}

func (w *fakeFallbackWatcher) WatchDirectory(dir string, _ WatchCallback, _ ...WatchOption) (Watch, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.shouldFail(dir) {
return nil, fmt.Errorf("%s: %w", w.name, w.failWith)
}
w.watched = append(w.watched, dir)
return &fakeFallbackWatch{watcher: w, dir: dir}, nil
}

func (w *fakeFallbackWatcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, error) {
w.mu.Lock()
defer w.mu.Unlock()
for _, request := range requests {
if w.shouldFail(request.Dir) {
return nil, fmt.Errorf("%s: %w", w.name, w.failWith)
}
}
watches := make([]Watch, len(requests))
for i, request := range requests {
w.watched = append(w.watched, request.Dir)
watches[i] = &fakeFallbackWatch{watcher: w, dir: request.Dir}
}
return watches, nil
}

func (w *fakeFallbackWatcher) WatchFile(path string, fn WatchCallback) (Watch, error) {
return w.WatchDirectory(path, fn)
}

func (w *fakeFallbackWatcher) unexported() {}

func (w *fakeFallbackWatcher) watchedDirs() []string {
w.mu.Lock()
defer w.mu.Unlock()
dirs := append([]string(nil), w.watched...)
sort.Strings(dirs)
return dirs
}

func (w *fakeFallbackWatcher) closedDirs() []string {
w.mu.Lock()
defer w.mu.Unlock()
dirs := append([]string(nil), w.closed...)
sort.Strings(dirs)
return dirs
}

func TestFallbackWatcherRoutesUnsupportedDirectories(t *testing.T) {
t.Parallel()

primary := &fakeFallbackWatcher{
name: "fanotify",
failWith: ErrFilesystemUnsupported,
failDir: func(dir string) bool { return strings.HasPrefix(dir, "/mnt/fuse") },
}
secondary := &fakeFallbackWatcher{name: "inotify"}
watcher := &fallbackWatcher{primary: primary, secondary: secondary}

requests := []WatchDirectoryRequest{
{Dir: "/project", Callback: func([]Event, error) {}},
{Dir: "/project/src", Callback: func([]Event, error) {}},
{Dir: "/mnt/fuse/deps", Callback: func([]Event, error) {}},
{Dir: "/mnt/fuse/deps/a", Callback: func([]Event, error) {}},
}
watches, err := watcher.WatchDirectories(requests)
if err != nil {
t.Fatalf("WatchDirectories: %v", err)
}
t.Cleanup(func() {
for _, watch := range watches {
_ = watch.Close()
}
})

if got, want := primary.watchedDirs(), []string{"/project", "/project/src"}; !equalStringSlices(got, want) {
t.Errorf("primary watched %v, want %v", got, want)
}
if got, want := secondary.watchedDirs(), []string{"/mnt/fuse/deps", "/mnt/fuse/deps/a"}; !equalStringSlices(got, want) {
t.Errorf("secondary watched %v, want %v", got, want)
}
}

func TestFallbackWatcherDoesNotFallbackForUnrelatedError(t *testing.T) {
t.Parallel()

primary := &fakeFallbackWatcher{name: "fanotify", failWith: ErrUnavailable}
secondary := &fakeFallbackWatcher{name: "inotify"}
watcher := &fallbackWatcher{primary: primary, secondary: secondary}

_, err := watcher.WatchDirectory("/project", func([]Event, error) {})
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("WatchDirectory error = %v, want ErrUnavailable", err)
}
if got := secondary.watchedDirs(); len(got) != 0 {
t.Errorf("secondary watched %v, want no watches", got)
}
}

func TestFallbackWatcherDoesNotUseSecondaryOnHappyPath(t *testing.T) {
t.Parallel()

primary := &fakeFallbackWatcher{name: "fanotify"}
secondary := &fakeFallbackWatcher{name: "inotify", failWith: errors.New("secondary should not be used")}
watcher := &fallbackWatcher{primary: primary, secondary: secondary}

watches, err := watcher.WatchDirectories([]WatchDirectoryRequest{
{Dir: "/project", Callback: func([]Event, error) {}},
{Dir: "/project/src", Callback: func([]Event, error) {}},
})
if err != nil {
t.Fatalf("WatchDirectories: %v", err)
}
t.Cleanup(func() {
for _, watch := range watches {
_ = watch.Close()
}
})
if got := secondary.watchedDirs(); len(got) != 0 {
t.Errorf("secondary watched %v, want no watches", got)
}
}

func TestFallbackWatcherRollsBackRoutedWatchesOnFailure(t *testing.T) {
t.Parallel()

primary := &fakeFallbackWatcher{
name: "fanotify",
failWith: ErrFilesystemUnsupported,
failDir: func(dir string) bool { return dir != "/project" },
}
secondary := &fakeFallbackWatcher{
name: "inotify",
failWith: ErrUnavailable,
failDir: func(dir string) bool { return dir == "/broken" },
}
watcher := &fallbackWatcher{primary: primary, secondary: secondary}

_, err := watcher.WatchDirectories([]WatchDirectoryRequest{
{Dir: "/project", Callback: func([]Event, error) {}},
{Dir: "/mnt/fuse", Callback: func([]Event, error) {}},
{Dir: "/broken", Callback: func([]Event, error) {}},
})
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("WatchDirectories error = %v, want ErrUnavailable", err)
}
if got, want := primary.closedDirs(), []string{"/project"}; !equalStringSlices(got, want) {
t.Errorf("primary closed %v, want %v", got, want)
}
if got, want := secondary.closedDirs(), []string{"/mnt/fuse"}; !equalStringSlices(got, want) {
t.Errorf("secondary closed %v, want %v", got, want)
}
}

func TestFanotifyUsesInternalInotifyFallback(t *testing.T) {
t.Parallel()

fallback, ok := Fanotify().(*fallbackWatcher)
if !ok {
t.Fatalf("Fanotify() = %T, want *fallbackWatcher", Fanotify())
}
if fallback.primary != fanotifyWatcher {
t.Errorf("Fanotify primary = %T, want package fanotify watcher", fallback.primary)
}
if fallback.secondary != inotifyWatcher {
t.Errorf("Fanotify secondary = %T, want package inotify watcher", fallback.secondary)
}
}

func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
11 changes: 9 additions & 2 deletions tsc/internal/fswatch/fanotify_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,13 +346,13 @@ func (b *fanotifyBackend) subscribe(w *dirWatch) error {

func (b *fanotifyBackend) markDir(w *dirWatch, path string, markPath string) error {
if err := unix.FanotifyMark(b.fanotifyFD, fanotifyMarkAddFlags, b.markMask, unix.AT_FDCWD, markPath); err != nil {
return err
return maybeWrapUnsupportedFilesystem(err)
}
handle, _, err := unix.NameToHandleAt(unix.AT_FDCWD, markPath, 0)
if err != nil {
// Unmark since we can't track this directory without a handle.
_ = unix.FanotifyMark(b.fanotifyFD, unix.FAN_MARK_REMOVE|unix.FAN_MARK_ONLYDIR, b.markMask, unix.AT_FDCWD, markPath)
return fmt.Errorf("name_to_handle_at: %w", err)
return maybeWrapUnsupportedFilesystem(fmt.Errorf("name_to_handle_at: %w", err))
}
var st unix.Statfs_t
if err := unix.Statfs(markPath, &st); err != nil {
Expand All @@ -365,6 +365,13 @@ func (b *fanotifyBackend) markDir(w *dirWatch, path string, markPath string) err
return nil
}

func maybeWrapUnsupportedFilesystem(err error) error {
if errors.Is(err, unix.EOPNOTSUPP) || errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.ENODEV) {
return fmt.Errorf("%w: %w", err, ErrFilesystemUnsupported)
}
return err
}

// handleEvents reads and dispatches fanotify events from the fd.
func (b *fanotifyBackend) handleEvents() error {
buf := b.readBuf
Expand Down
60 changes: 60 additions & 0 deletions tsc/internal/fswatch/fanotify_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package fswatch

import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -119,3 +120,62 @@ func TestFanotifyCrossWatcherSameFs(t *testing.T) {
}
})
}

func TestLinuxFanotifyMaybeWrapUnsupportedFilesystem(t *testing.T) {
t.Parallel()

// Errnos that indicate the filesystem cannot support fanotify FID-based
// watching are tagged with ErrFilesystemUnsupported so higher layers can
// fall back to inotify (issue #63646).
for _, errno := range []error{unix.EOPNOTSUPP, unix.ENODEV} {
wrapped := maybeWrapUnsupportedFilesystem(fmt.Errorf("name_to_handle_at: %w", errno))
if !errors.Is(wrapped, ErrFilesystemUnsupported) {
t.Errorf("expected %v to be tagged ErrFilesystemUnsupported", errno)
}
if !errors.Is(wrapped, errno) {
t.Errorf("expected wrapped error to still unwrap to %v", errno)
}
}

// Unrelated errnos are returned unchanged.
other := maybeWrapUnsupportedFilesystem(unix.EACCES)
if errors.Is(other, ErrFilesystemUnsupported) {
t.Errorf("EACCES should not be tagged ErrFilesystemUnsupported")
}
}

func TestLinuxFanotifyMarkENODEVTagged(t *testing.T) {
t.Parallel()

// On NTFS mounted via fuseblk, fanotify_mark itself fails with ENODEV
// ("no such device") — the bare errno, with no name_to_handle_at wrapping
// (issue #63678). markDir passes that straight to
// maybeWrapUnsupportedFilesystem, so it must be tagged and drive the inotify
// fallback just like the EOPNOTSUPP case.
err := maybeWrapUnsupportedFilesystem(unix.ENODEV)
if !errors.Is(err, ErrFilesystemUnsupported) {
t.Errorf("bare ENODEV from fanotify_mark should be tagged ErrFilesystemUnsupported")
}
if !errors.Is(err, unix.ENODEV) {
t.Errorf("tagged error should still unwrap to ENODEV")
}
}

func TestLinuxFanotifyUnsupportedTagSurvivesDirWatchError(t *testing.T) {
t.Parallel()

// Closes the loop between maybeWrapUnsupportedFilesystem and the higher
// layers: the tag must survive the exact wrapping that markDir + subscribe
// apply (fmt.Errorf with %w, then dirWatchError) so that errors.Is still
// finds ErrFilesystemUnsupported at the WatchDirectories boundary. Catches a
// regression such as a %w->%v change or a dropped dirWatchError.Unwrap.
inner := maybeWrapUnsupportedFilesystem(fmt.Errorf("name_to_handle_at: %w", unix.EOPNOTSUPP))
subErr := error(&dirWatchError{err: fmt.Errorf("fanotify_mark on '%s' failed: %w", "/x", inner)})

if !errors.Is(subErr, ErrFilesystemUnsupported) {
t.Error("ErrFilesystemUnsupported did not survive dirWatchError wrapping")
}
if !errors.Is(subErr, unix.EOPNOTSUPP) {
t.Error("underlying errno did not survive dirWatchError wrapping")
}
}
Loading
Loading