diff --git a/tsc/internal/execute/watchmanager/watchmanager.go b/tsc/internal/execute/watchmanager/watchmanager.go index 5ac80d6f89704..f037d93e2f863 100644 --- a/tsc/internal/execute/watchmanager/watchmanager.go +++ b/tsc/internal/execute/watchmanager/watchmanager.go @@ -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, @@ -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 diff --git a/tsc/internal/fswatch/fallback_test.go b/tsc/internal/fswatch/fallback_test.go new file mode 100644 index 0000000000000..3f8ce990a5376 --- /dev/null +++ b/tsc/internal/fswatch/fallback_test.go @@ -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 +} diff --git a/tsc/internal/fswatch/fanotify_linux.go b/tsc/internal/fswatch/fanotify_linux.go index 82fe3eb80628e..899e64ad3051b 100644 --- a/tsc/internal/fswatch/fanotify_linux.go +++ b/tsc/internal/fswatch/fanotify_linux.go @@ -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 { @@ -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 diff --git a/tsc/internal/fswatch/fanotify_linux_test.go b/tsc/internal/fswatch/fanotify_linux_test.go index 9ca06f5b5bd79..7dfe9f5825bbb 100644 --- a/tsc/internal/fswatch/fanotify_linux_test.go +++ b/tsc/internal/fswatch/fanotify_linux_test.go @@ -4,6 +4,7 @@ package fswatch import ( "errors" + "fmt" "os" "path/filepath" "testing" @@ -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") + } +} diff --git a/tsc/internal/fswatch/watcher.go b/tsc/internal/fswatch/watcher.go index 55d6084ef4435..cb55372734d30 100644 --- a/tsc/internal/fswatch/watcher.go +++ b/tsc/internal/fswatch/watcher.go @@ -40,6 +40,15 @@ var ErrWatchTerminated = errors.New("fswatch: watch terminated") // available on the current platform. var ErrUnavailable = errors.New("fswatch: watcher not available on this platform") +// ErrFilesystemUnsupported indicates that the active watcher backend cannot +// operate on the target filesystem, even though the backend is available on +// the current platform. This happens, for example, with the fanotify backend +// on filesystems that do not support FID-based watching: name_to_handle_at +// returning EOPNOTSUPP (some Docker bind mounts backed by virtiofs, gRPC FUSE, +// or overlayfs) or fanotify_mark returning ENODEV (e.g. NTFS mounted via +// fuseblk). +var ErrFilesystemUnsupported = errors.New("fswatch: watcher backend unsupported on this filesystem") + // Watcher represents a filesystem watching implementation. // Use one of the constructor functions ([Inotify], [FSEvents], [Kqueue], // [Windows]) to obtain a value, or [Default] for the platform default. @@ -166,11 +175,15 @@ type WatchCallback func(events []Event, err error) // Package-level watcher instances. Platform init() functions set the factory. var ( - inotifyWatcher = &watcher{name: "inotify"} - fseventsWatcher = &watcher{name: "fsevents"} - kqueueWatcher = &watcher{name: "kqueue"} - windowsWatcher = &watcher{name: "windows"} - fanotifyWatcher = &watcher{name: "fanotify"} + inotifyWatcher = &watcher{name: "inotify"} + fseventsWatcher = &watcher{name: "fsevents"} + kqueueWatcher = &watcher{name: "kqueue"} + windowsWatcher = &watcher{name: "windows"} + fanotifyWatcher = &watcher{name: "fanotify"} + fanotifyFallbackWatcher = &fallbackWatcher{ + primary: fanotifyWatcher, + secondary: inotifyWatcher, + } ) // AllWatchers returns a fresh slice listing every watcher backend the package @@ -182,7 +195,7 @@ func AllWatchers() []Watcher { fseventsWatcher, kqueueWatcher, windowsWatcher, - fanotifyWatcher, + fanotifyFallbackWatcher, } } @@ -198,8 +211,9 @@ func Kqueue() Watcher { return kqueueWatcher } // Windows returns the ReadDirectoryChangesW watcher (Windows). func Windows() Watcher { return windowsWatcher } -// Fanotify returns the fanotify watcher (Linux, kernel ≥ 5.13). -func Fanotify() Watcher { return fanotifyWatcher } +// Fanotify returns the fanotify watcher (Linux, kernel ≥ 5.13). Directories on +// filesystems that don't support fanotify watches automatically use inotify instead. +func Fanotify() Watcher { return fanotifyFallbackWatcher } // Default returns the recommended watcher for the current OS. func Default() Watcher { @@ -223,6 +237,65 @@ func Default() Watcher { } } +// fallbackWatcher keeps the primary backend for supported filesystems while +// routing individual unsupported watches to the secondary backend. +type fallbackWatcher struct { + primary Watcher + secondary Watcher +} + +func (w *fallbackWatcher) Name() string { return w.primary.Name() } +func (w *fallbackWatcher) Available() bool { return w.primary.Available() } +func (w *fallbackWatcher) HasFastRecursiveBackend() bool { return w.primary.HasFastRecursiveBackend() } + +func (w *fallbackWatcher) WatchDirectory(dir string, fn WatchCallback, opts ...WatchOption) (Watch, error) { + watches, err := w.WatchDirectories([]WatchDirectoryRequest{{ + Dir: dir, + Callback: fn, + Options: opts, + }}) + if err != nil { + return nil, err + } + return watches[0], nil +} + +func (w *fallbackWatcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, error) { + watches, err := w.primary.WatchDirectories(requests) + if err == nil || !errors.Is(err, ErrFilesystemUnsupported) { + return watches, err + } + + watches = make([]Watch, 0, len(requests)) + rollback := func() { + for i := len(watches) - 1; i >= 0; i-- { + _ = watches[i].Close() + } + } + for _, request := range requests { + watch, err := w.primary.WatchDirectory(request.Dir, request.Callback, request.Options...) + if errors.Is(err, ErrFilesystemUnsupported) { + watch, err = w.secondary.WatchDirectory(request.Dir, request.Callback, request.Options...) + } + if err != nil { + rollback() + return nil, fmt.Errorf("fswatch: failed to watch directory %q: %w", request.Dir, err) + } + watches = append(watches, watch) + } + return watches, nil +} + +func (w *fallbackWatcher) WatchFile(path string, fn WatchCallback) (Watch, error) { + watch, err := w.primary.WatchFile(path, fn) + if errors.Is(err, ErrFilesystemUnsupported) { + return w.secondary.WatchFile(path, fn) + } + return watch, err +} + +func (w *fallbackWatcher) unexported() {} + // watcher is the concrete implementation of [Watcher]. Each platform // watcher is a package-level *watcher whose factory is set by the // platform's init() function.