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
2 changes: 2 additions & 0 deletions pkgs/watcher/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
96 changes: 83 additions & 13 deletions pkgs/watcher/lib/src/directory_watcher/linux.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,24 @@ 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 = <String, Stream<FileSystemEvent>>{};
/// Watches by directory.
final Map<String, _Watch> _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.
///
/// These are gathered together so that they may all be canceled when the
/// watcher is closed.
final _subscriptions = <StreamSubscription>{};

_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
Expand Down Expand Up @@ -119,7 +123,6 @@ class _LinuxDirectoryWatcher
}

_subscriptions.clear();
_subdirStreams.clear();
_files.clear();
_nativeEvents.close();
_eventsController.close();
Expand All @@ -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<PathNotFoundException>();
_subdirStreams[path] = stream;
var stream = _watch(path).events.ignoring<PathNotFoundException>();
_nativeEvents.add(stream);
}

Expand Down Expand Up @@ -212,9 +214,6 @@ class _LinuxDirectoryWatcher
/// [files] and [dirs].
void _applyChanges(Set<String> files, Set<String> dirs, Set<String> 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;
Expand Down Expand Up @@ -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<FileSystemEvent> _controller =
StreamController<FileSystemEvent>();
late final StreamSubscription<FileSystemEvent> _subscription;
Stream<FileSystemEvent> get events => _controller.stream;

_Watch(this.path, this._cancelWatchesUnderPath) {
_subscription = _listen(path, _controller);
}

StreamSubscription<FileSystemEvent> _listen(
String path, StreamController<FileSystemEvent> 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();
}
}
10 changes: 0 additions & 10 deletions pkgs/watcher/test/directory_watcher/end_to_end_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.
});
}
15 changes: 15 additions & 0 deletions pkgs/watcher/test/directory_watcher/file_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down