From b6fb93d09305c2e55a279edacec755dc8c121566 Mon Sep 17 00:00:00 2001 From: David Morgan Date: Mon, 10 Nov 2025 16:22:10 +0100 Subject: [PATCH] Fix Linux directory rename issue. --- pkgs/watcher/CHANGELOG.md | 2 + .../lib/src/directory_watcher/linux.dart | 96 ++++++++++++++++--- .../directory_watcher/end_to_end_tests.dart | 10 -- .../test/directory_watcher/file_tests.dart | 15 +++ 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/pkgs/watcher/CHANGELOG.md b/pkgs/watcher/CHANGELOG.md index 8f53ab91c..4ae096190 100644 --- a/pkgs/watcher/CHANGELOG.md +++ b/pkgs/watcher/CHANGELOG.md @@ -10,6 +10,8 @@ exhaustion, "Directory watcher closed unexpectedly", much less likely. The old implementation which does not use a separate Isolate is available as `DirectoryWatcher(path, runInIsolateOnWindows: false)`. +- Bug fix: fix tracking failure on Linux. Before the fix, renaming a directory + would cause subdirectories of that directory to no longer be tracked. - Bug fix: while listing directories skip symlinks that lead to a directory that has already been listed. This prevents a severe performance regression on MacOS and Linux when there are more than a few symlink loops. diff --git a/pkgs/watcher/lib/src/directory_watcher/linux.dart b/pkgs/watcher/lib/src/directory_watcher/linux.dart index 5989aa293..ad2cccd2a 100644 --- a/pkgs/watcher/lib/src/directory_watcher/linux.dart +++ b/pkgs/watcher/lib/src/directory_watcher/linux.dart @@ -59,10 +59,12 @@ class _LinuxDirectoryWatcher /// All known files recursively within [path]. final PathSet _files; - /// [Directory.watch] streams for [path]'s subdirectories, indexed by name. - /// - /// A stream is in this map if and only if it's also in [_nativeEvents]. - final _subdirStreams = >{}; + /// Watches by directory. + final Map _watches = {}; + + /// Keys of [_watches] as a [PathSet] so they can be quickly retrieved by + /// parent directory. + final PathSet _directoriesWatched; /// A set of all subscriptions that this watcher subscribes to. /// @@ -70,9 +72,11 @@ class _LinuxDirectoryWatcher /// watcher is closed. final _subscriptions = {}; - _LinuxDirectoryWatcher(String path) : _files = PathSet(path) { - _nativeEvents.add(Directory(path) - .watch() + _LinuxDirectoryWatcher(String path) + : _files = PathSet(path), + _directoriesWatched = PathSet(path) { + _nativeEvents.add(_watch(path) + .events .transform(StreamTransformer.fromHandlers(handleDone: (sink) { // Handle the done event here rather than in the call to [_listen] because // [innerStream] won't close until we close the [StreamGroup]. However, if @@ -119,7 +123,6 @@ class _LinuxDirectoryWatcher } _subscriptions.clear(); - _subdirStreams.clear(); _files.clear(); _nativeEvents.close(); _eventsController.close(); @@ -141,8 +144,7 @@ class _LinuxDirectoryWatcher // Directory might no longer exist at the point where we try to // start the watcher. Simply ignore this error and let the stream // close. - var stream = Directory(path).watch().ignoring(); - _subdirStreams[path] = stream; + var stream = _watch(path).events.ignoring(); _nativeEvents.add(stream); } @@ -212,9 +214,6 @@ class _LinuxDirectoryWatcher /// [files] and [dirs]. void _applyChanges(Set files, Set dirs, Set changed) { for (var path in changed) { - var stream = _subdirStreams.remove(path); - if (stream != null) _nativeEvents.add(stream); - // Unless [path] was a file and still is, emit REMOVE events for it or its // contents, if (files.contains(path) && _files.contains(path)) continue; @@ -304,4 +303,75 @@ class _LinuxDirectoryWatcher }, cancelOnError: cancelOnError); _subscriptions.add(subscription); } + + /// Watches [path]. + /// + /// See [_Watch] class comment. + _Watch _watch(String path) { + _watches[path]?.cancel(); + final result = _Watch(path, _cancelWatchesUnderPath); + _watches[path] = result; + + // If [path] is the root watch directory do nothing, that's handled when the + // stream closes and does not need tracking. + if (path != this.path) { + _directoriesWatched.add(path); + } + return result; + } + + /// Cancels all watches under path [path]. + void _cancelWatchesUnderPath(String path) { + // If [path] is the root watch directory do nothing, that's handled when the + // stream closes. + if (path == this.path) return; + + for (final dir in _directoriesWatched.remove(path)) { + _watches.remove(dir)!.cancel(); + } + } +} + +/// Watches [path]. +/// +/// Workaround for issue with watches on Linux following renames +/// https://github.com/dart-lang/sdk/issues/61861. +/// +/// Tracks watches. When a delete or move event indicates that a watch has +/// been removed it is immediately cancelled. This prevents incorrect events +/// if the new location of the directory is also watched. +/// +/// Note that the SDK reports "directory deleted" for a move to outside the +/// watched directory, so actually the most important moves are "deletes". +class _Watch { + final String path; + final void Function(String) _cancelWatchesUnderPath; + final StreamController _controller = + StreamController(); + late final StreamSubscription _subscription; + Stream get events => _controller.stream; + + _Watch(this.path, this._cancelWatchesUnderPath) { + _subscription = _listen(path, _controller); + } + + StreamSubscription _listen( + String path, StreamController controller) { + return Directory(path).watch().listen( + (event) { + if (event is FileSystemDeleteEvent || + (event.isDirectory && event is FileSystemMoveEvent)) { + _cancelWatchesUnderPath(event.path); + } + + controller.add(event); + }, + onError: controller.addError, + onDone: controller.close, + ); + } + + void cancel() { + _subscription.cancel(); + } } diff --git a/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart b/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart index af2a20000..bf62f6fcd 100644 --- a/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart +++ b/pkgs/watcher/test/directory_watcher/end_to_end_tests.dart @@ -53,11 +53,6 @@ void endToEndTests({required bool isNative}) { // Verify for real and fail the test if still not consistent. if (!client.verify(log: true)) { - if (Platform.isLinux && isNative) { - print('Ignoring expected failure for Linux native watcher.'); - return; - } - // Write the file operations before the failure to a log, fail the test. final logTemp = Directory.systemTemp.createTempSync(); final fileChangesLogPath = p.join(logTemp.path, 'changes.txt'); @@ -72,10 +67,5 @@ Files changes: $fileChangesLogPath Client log: $clientLogPath'''); } } - - if (Platform.isLinux && isNative) { - fail('Expected Linux native watcher failure, but test passed!'); - } - // Can't expect the Windows failure as it does sometimes succeed. }); } diff --git a/pkgs/watcher/test/directory_watcher/file_tests.dart b/pkgs/watcher/test/directory_watcher/file_tests.dart index 9d7e9450c..0fe5e8008 100644 --- a/pkgs/watcher/test/directory_watcher/file_tests.dart +++ b/pkgs/watcher/test/directory_watcher/file_tests.dart @@ -431,6 +431,21 @@ void _fileTests({required bool isNative}) { await inAnyOrder(events); }); + test('are still watched after move', () async { + await startWatcher(); + + writeFile('a/b/file.txt'); + await expectAddEvent('a/b/file.txt'); + + renameDir('a', 'c'); + await inAnyOrder( + [isRemoveEvent('a/b/file.txt'), isAddEvent('c/b/file.txt')]); + + writeFile('c/b/file2.txt'); + await expectAddEvent('c/b/file2.txt'); + await expectNoEvents(); + }); + test('subdirectory watching is robust against races', () async { // Make sandboxPath accessible to child isolates created by Isolate.run. final sandboxPath = d.sandbox;