Skip to content

Commit 122d9ac

Browse files
authored
Fix Linux watcher for entities recently moved between directories (#2248)
1 parent 62e0887 commit 122d9ac

File tree

8 files changed

+201
-56
lines changed

8 files changed

+201
-56
lines changed

pkgs/watcher/CHANGELOG.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
exhaustion, "Directory watcher closed unexpectedly", much less likely. The old
1111
implementation which does not use a separate Isolate is available as
1212
`DirectoryWatcher(path, runInIsolateOnWindows: false)`.
13-
- Bug fix: fix tracking failure on Linux. Before the fix, renaming a directory
14-
would cause subdirectories of that directory to no longer be tracked.
15-
- Bug fix: while listing directories skip symlinks that lead to a directory
16-
that has already been listed. This prevents a severe performance regression on
17-
MacOS and Linux when there are more than a few symlink loops.
18-
- Bug fix: with `FileWatcher` on MacOS, a modify event was sometimes reported if
19-
the file was created immediately before the watcher was created. Now, if the
20-
file exists when the watcher is created then this modify event is not sent.
21-
This matches the Linux native and polling (Windows) watchers.
13+
- Bug fix: fix `DirectoryWatcher` tracking failure on Linux. Before the fix,
14+
renaming a directory would cause subdirectories of that directory to no
15+
longer be tracked.
16+
- Bug fix: fix `DirectoryWatcher` incorrect events on Linux when a file or
17+
directory is moved between directories then immediately modified or deleted.
18+
- Bug fix: fix `DirectoryWatcher` duplicate ADD events on Linux when a file
19+
is created in a recently moved or created directory.
20+
- Bug fix: in `DirectoryWatcher` while listing directories skip symlinks that
21+
lead to a directory that has already been listed. This prevents a severe
22+
performance regression on MacOS and Linux when there are more than a few symlink loops.
2223
- Bug fix: with `DirectoryWatcher` on Windows, the last of a rapid sequence of
2324
modifications in a newly-created directory was sometimes dropped. Make it
2425
reliably report the last modification.
@@ -30,8 +31,14 @@
3031
- Bug fix: with `DirectoryWatcher` on Windows, new links to direcories were
3132
sometimes incorrectly handled as actual directories. Now they are reported
3233
as files, matching the behavior of the Linux and MacOS watchers.
34+
- Bug fix: with `DirectoryWatcher` on MacOS, fix events for changes in new
35+
directories: don't emit duplicate ADD, don't emit MODIFY without ADD.
3336
- Bug fix: with `PollingDirectoryWatcher`, fix spurious modify event emitted
3437
because of a file delete during polling.
38+
- Bug fix: with `FileWatcher` on MacOS, a modify event was sometimes reported if
39+
the file was created immediately before the watcher was created. Now, if the
40+
file exists when the watcher is created then this modify event is not sent.
41+
This matches the Linux native and polling (Windows) watchers.
3542
- Document behavior on Linux if the system watcher limit is hit.
3643

3744
## 1.1.4

pkgs/watcher/lib/src/directory_watcher/directory_list.dart

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,12 @@ class _ResolvedDirectory {
142142
} on FileSystemException catch (e, s) {
143143
// The first operation on a directory is to resolve symbolic links, which
144144
// fails with a general FileSystemException if the file is not found.
145-
// Convert that into a PathNotFoundException or PathAccessException
146-
// as that makes more sense to the caller, who didn't ask for anything to
147-
// do with symbolic links.
145+
// See https://github.com/dart-lang/sdk/issues/61946. Use a copy of the
146+
// SDK codepath that should convert it to a more specific type.
148147
if (e.message.contains('Cannot resolve symbolic links')) {
149-
if (e.osError?.errorCode == 2) {
150-
throw Error.throwWithStackTrace(
151-
PathNotFoundException(directory.path, e.osError!), s);
152-
} else if (e.osError?.errorCode == 5) {
153-
throw Error.throwWithStackTrace(
154-
PathAccessException(directory.path, e.osError!), s);
148+
if (e.osError != null && e.path != null) {
149+
Error.throwWithStackTrace(
150+
_fromOSError(e.osError!, e.message, e.path!), s);
155151
}
156152
}
157153
rethrow;
@@ -160,3 +156,74 @@ class _ResolvedDirectory {
160156

161157
bool get isCanonical => canonicalPath == directory.path;
162158
}
159+
160+
// Copied from sdk/lib/io/file.dart.
161+
FileSystemException _fromOSError(OSError err, String message, String path) {
162+
if (Platform.isWindows) {
163+
switch (err.errorCode) {
164+
case _errorAccessDenied:
165+
case _errorCurrentDirectory:
166+
case _errorWriteProtect:
167+
case _errorBadLength:
168+
case _errorSharingViolation:
169+
case _errorLockViolation:
170+
case _errorNetworkAccessDenied:
171+
case _errorDriveLocked:
172+
return PathAccessException(path, err, message);
173+
case _errorFileExists:
174+
case _errorAlreadyExists:
175+
return PathExistsException(path, err, message);
176+
case _errorFileNotFound:
177+
case _errorPathNotFound:
178+
case _errorInvalidDrive:
179+
case _errorInvalidName:
180+
case _errorNoMoreFiles:
181+
case _errorBadNetpath:
182+
case _errorBadNetName:
183+
case _errorBadPathName:
184+
case _errorFilenameExedRange:
185+
return PathNotFoundException(path, err, message);
186+
default:
187+
return FileSystemException(message, path, err);
188+
}
189+
} else {
190+
switch (err.errorCode) {
191+
case _ePerm:
192+
case _eAccess:
193+
return PathAccessException(path, err, message);
194+
case _eExist:
195+
return PathExistsException(path, err, message);
196+
case _eNoEnt:
197+
return PathNotFoundException(path, err, message);
198+
default:
199+
return FileSystemException(message, path, err);
200+
}
201+
}
202+
}
203+
204+
// Copied from sdk/lib/io/common.dart. POSIX.
205+
const _ePerm = 1;
206+
const _eNoEnt = 2;
207+
const _eAccess = 13;
208+
const _eExist = 17;
209+
210+
// Copied from sdk/lib/io/common.dart. Windows.
211+
const _errorFileNotFound = 2;
212+
const _errorPathNotFound = 3;
213+
const _errorAccessDenied = 5;
214+
const _errorInvalidDrive = 15;
215+
const _errorCurrentDirectory = 16;
216+
const _errorNoMoreFiles = 18;
217+
const _errorWriteProtect = 19;
218+
const _errorBadLength = 24;
219+
const _errorSharingViolation = 32;
220+
const _errorLockViolation = 33;
221+
const _errorBadNetpath = 53;
222+
const _errorNetworkAccessDenied = 65;
223+
const _errorBadNetName = 67;
224+
const _errorFileExists = 80;
225+
const _errorDriveLocked = 108;
226+
const _errorInvalidName = 123;
227+
const _errorBadPathName = 161;
228+
const _errorAlreadyExists = 183;
229+
const _errorFilenameExedRange = 206;

pkgs/watcher/lib/src/directory_watcher/linux.dart

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,14 @@ class _LinuxDirectoryWatcher
154154
var dirs = <String>{};
155155
var changed = <String>{};
156156

157-
// inotify event batches are ordered by occurrence, so we treat them as a
158-
// log of what happened to a file. We only emit events based on the
159-
// difference between the state before the batch and the state after it, not
160-
// the intermediate state.
157+
// Inotify events are usually ordered by occurrence. But,
158+
// https://github.com/dart-lang/sdk/issues/62014 means that moves between
159+
// directories cause create/delete events to be placed out of order at the
160+
// end of the batch. Catch these cases in order to do a check on the actual
161+
// filesystem state.
162+
var deletes = <String>{};
163+
var creates = <String>{};
164+
161165
for (var event in batch) {
162166
// If the watched directory is deleted or moved, we'll get a deletion
163167
// event for it. Ignore it; we handle closing [this] when the underlying
@@ -168,41 +172,71 @@ class _LinuxDirectoryWatcher
168172

169173
switch (event.type) {
170174
case EventType.moveFile:
175+
deletes.add(event.path);
171176
files.remove(event.path);
172177
dirs.remove(event.path);
173178
var destination = event.destination;
174179
if (destination != null) {
180+
creates.add(destination);
175181
changed.add(destination);
176182
files.add(destination);
177183
dirs.remove(destination);
178184
}
179185

180186
case EventType.moveDirectory:
187+
deletes.add(event.path);
181188
files.remove(event.path);
182189
dirs.remove(event.path);
183190
var destination = event.destination;
184191
if (destination != null) {
192+
creates.add(destination);
185193
changed.add(destination);
186194
files.remove(destination);
187195
dirs.add(destination);
188196
}
189197

190198
case EventType.delete:
199+
deletes.add(event.path);
191200
files.remove(event.path);
192201
dirs.remove(event.path);
193202

194203
case EventType.createDirectory:
204+
creates.add(event.path);
205+
files.remove(event.path);
206+
dirs.add(event.path);
207+
195208
case EventType.modifyDirectory:
196209
files.remove(event.path);
197210
dirs.add(event.path);
198211

199212
case EventType.createFile:
213+
creates.add(event.path);
214+
files.add(event.path);
215+
dirs.remove(event.path);
216+
200217
case EventType.modifyFile:
201218
files.add(event.path);
202219
dirs.remove(event.path);
203220
}
204221
}
205222

223+
// Check paths that might have been affected by out-of-order events, set
224+
// the correct state in [files] and [dirs].
225+
for (final path in deletes.intersection(creates)) {
226+
final type = FileSystemEntity.typeSync(path, followLinks: false);
227+
if (type == FileSystemEntityType.file ||
228+
type == FileSystemEntityType.link) {
229+
files.add(path);
230+
dirs.remove(path);
231+
} else if (type == FileSystemEntityType.directory) {
232+
dirs.add(path);
233+
files.remove(path);
234+
} else {
235+
files.remove(path);
236+
dirs.remove(path);
237+
}
238+
}
239+
206240
_applyChanges(files, dirs, changed);
207241
}
208242

@@ -244,8 +278,12 @@ class _LinuxDirectoryWatcher
244278
if (entity is Directory) {
245279
_watchSubdir(entity.path);
246280
} else {
247-
_files.add(entity.path);
248-
_emitEvent(ChangeType.ADD, entity.path);
281+
// Only emit ADD if it hasn't already been emitted due to the file being
282+
// modified or added after the directory was added.
283+
if (!_files.contains(entity.path)) {
284+
_files.add(entity.path);
285+
_emitEvent(ChangeType.ADD, entity.path);
286+
}
249287
}
250288
}, onError: (Object error, StackTrace stackTrace) {
251289
// Ignore an exception caused by the dir not existing. It's fine if it

pkgs/watcher/lib/src/directory_watcher/mac_os.dart

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,10 @@ class _MacOSDirectoryWatcher
136136
for (var event in events) {
137137
switch (event.type) {
138138
case EventType.createFile:
139-
// If we already know about the file, treat it like a modification.
140-
// This can happen if a file is copied on top of an existing one.
141-
// We'll see an ADD event for the latter file when from the user's
142-
// perspective, the file's contents just changed.
139+
case EventType.modifyFile:
140+
// The type can be incorrect due to a race with listing a new
141+
// directory or due to a file being copied over an existing one.
142+
// Choose the type to emit based on the previous emitted state.
143143
var type =
144144
_files.contains(path) ? ChangeType.MODIFY : ChangeType.ADD;
145145

@@ -152,7 +152,7 @@ class _MacOSDirectoryWatcher
152152
var stream = Directory(path).listRecursivelyIgnoringErrors();
153153
var subscription = stream.listen((entity) {
154154
if (entity is Directory) return;
155-
if (_files.contains(path)) return;
155+
if (_files.contains(entity.path)) return;
156156

157157
_emitEvent(ChangeType.ADD, entity.path);
158158
_files.add(entity.path);
@@ -163,9 +163,6 @@ class _MacOSDirectoryWatcher
163163
subscription.onError(_emitError);
164164
_listSubscriptions.add(subscription);
165165

166-
case EventType.modifyFile:
167-
_emitEvent(ChangeType.MODIFY, path);
168-
169166
case EventType.delete:
170167
for (var removedPath in _files.remove(path)) {
171168
_emitEvent(ChangeType.REMOVE, removedPath);

pkgs/watcher/test/directory_watcher/end_to_end_tests.dart

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ void endToEndTests({required bool isNative}) {
2727
final temp = Directory.systemTemp.createTempSync();
2828
addTearDown(() => temp.deleteSync(recursive: true));
2929

30-
// Start with some files.
31-
final changer = FileChanger(temp.path);
32-
await changer.changeFiles(times: 100);
33-
3430
// Create the watcher and [ClientSimulator].
3531
final watcher = createWatcher(path: temp.path);
3632
final client = await ClientSimulator.watch(watcher);
3733
addTearDown(client.close);
3834

3935
// 20 iterations of making changes, waiting for events to settle, and
4036
// checking for consistency.
41-
for (var i = 0; i != 20; ++i) {
37+
final changer = FileChanger(temp.path);
38+
for (var i = 0; i != 40; ++i) {
39+
for (final entity in temp.listSync()) {
40+
entity.deleteSync(recursive: true);
41+
}
4242
// File changes.
43-
final messages = await changer.changeFiles(times: 100);
43+
final messages = await changer.changeFiles(times: 200);
4444

4545
// Give time for events to arrive. To allow tests to run quickly when the
4646
// events are handled quickly, poll and continue if verification passes.

pkgs/watcher/test/directory_watcher/file_changer.dart

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import 'dart:math';
88

99
import 'package:path/path.dart' as p;
1010

11+
import '../utils.dart';
12+
1113
/// Changes files randomly.
1214
///
1315
/// Writes are done in an isolate so as to not block the watcher code being
@@ -23,16 +25,21 @@ import 'package:path/path.dart' as p;
2325
class FileChanger {
2426
final String path;
2527

26-
final Random _random = Random(0);
28+
Random _random = Random(0);
2729
final List<String> _messages = [];
2830

2931
FileChanger(this.path);
3032

3133
/// Changes files under [path], [times] times.
3234
///
3335
/// Returns a log of the changes made.
34-
Future<List<String>> changeFiles({required int times}) async =>
35-
await Isolate.run(() => _changeFiles(times: times));
36+
Future<List<String>> changeFiles({required int times}) async {
37+
final result = await Isolate.run(() => _changeFiles(times: times));
38+
// The `Random` instance gets copied to the isolate on every run, so by
39+
// default it will produce the same numbers. Update it to get new numbers.
40+
_random = Random(_random.nextInt(0xffffffff));
41+
return result;
42+
}
3643

3744
Future<List<String>> _changeFiles({required int times}) async {
3845
_messages.clear();
@@ -82,7 +89,7 @@ class FileChanger {
8289
final existingPath2 = _randomExistingFilePath()!;
8390
_log('move file over file,$existingPath,$existingPath2');
8491
// Fails sometimes on Windows, so guard+retry.
85-
_retryForPathAccessException(
92+
retryForPathAccessException(
8693
() => File(existingPath).renameSync(existingPath2));
8794

8895
case 6:
@@ -94,7 +101,7 @@ class FileChanger {
94101
_ensureParent(newDirectory);
95102
_log('move directory to new,$existingDirectory,$newDirectory');
96103
// Fails sometimes on Windows, so guard+retry.
97-
_retryForPathAccessException(
104+
retryForPathAccessException(
98105
() => Directory(existingDirectory).renameSync(newDirectory));
99106

100107
case 7:
@@ -133,6 +140,7 @@ class FileChanger {
133140
/// Returns the path to an already-created file, or `null` if none exists.
134141
String? _randomExistingFilePath() =>
135142
(Directory(path).listSync(recursive: true).whereType<File>().toList()
143+
..sort((a, b) => a.path.compareTo(b.path))
136144
..shuffle(_random))
137145
.firstOrNull
138146
?.path;
@@ -142,6 +150,7 @@ class FileChanger {
142150
String? _randomExistingDirectoryPath() => (Directory(
143151
path,
144152
).listSync(recursive: true).whereType<Directory>().toList()
153+
..sort((a, b) => a.path.compareTo(b.path))
145154
..shuffle(_random))
146155
.firstOrNull
147156
?.path;
@@ -156,16 +165,4 @@ class FileChanger {
156165
message = message.replaceAll(',$path${Platform.pathSeparator}', ',');
157166
_messages.add(message);
158167
}
159-
160-
/// Retries [action] until it does not throw [PathAccessException].
161-
void _retryForPathAccessException(void Function() action) {
162-
while (true) {
163-
try {
164-
action();
165-
return;
166-
} on PathAccessException catch (e) {
167-
print('Temporary failure, retrying: $e');
168-
}
169-
}
170-
}
171168
}

0 commit comments

Comments
 (0)