Skip to content

Commit 8dac3f3

Browse files
committed
Check file sizes as well as "last modified" times.
1 parent ce12670 commit 8dac3f3

File tree

12 files changed

+148
-284
lines changed

12 files changed

+148
-284
lines changed

pkgs/watcher/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
## 1.1.5-wip
22

3+
- Polling watchers now check file sizes as well as "last modified" times, so
4+
they are less likely to miss changes on platforms with low resolution
5+
timestamps.
36
- Bug fix: with `FileWatcher` on MacOS, a modify event was sometimes reported if
47
the file was created immediately before the watcher was created. Now, if the
58
file exists when the watcher is created then this modify event is not sent.

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

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import 'dart:io';
77

88
import '../async_queue.dart';
99
import '../directory_watcher.dart';
10+
import '../polling.dart';
1011
import '../resubscribable.dart';
11-
import '../stat.dart';
1212
import '../utils.dart';
1313
import '../watch_event.dart';
1414

1515
/// Periodically polls a directory for changes.
16+
///
17+
/// Changes are noticed if the "last modified" time of a file changes or if its
18+
/// size changes.
1619
class PollingDirectoryWatcher extends ResubscribableWatcher
1720
implements DirectoryWatcher {
1821
@override
@@ -53,10 +56,7 @@ class _PollingDirectoryWatcher
5356
/// directory contents.
5457
final Duration _pollingDelay;
5558

56-
/// The previous modification times of the files in the directory.
57-
///
58-
/// Used to tell which files have been modified.
59-
final _lastModifieds = <String, DateTime?>{};
59+
final _previousPollResults = <String, PollResult>{};
6060

6161
/// The subscription used while [directory] is being listed.
6262
///
@@ -78,7 +78,8 @@ class _PollingDirectoryWatcher
7878
/// The set of files that have been seen in the current directory listing.
7979
///
8080
/// Used to tell which files have been removed: files that are in
81-
/// [_lastModifieds] but not in here when a poll completes have been removed.
81+
/// [_previousPollResults] but not in here when a poll completes have been
82+
/// removed.
8283
final _polledFiles = <String>{};
8384

8485
_PollingDirectoryWatcher(this.path, this._pollingDelay) {
@@ -95,7 +96,7 @@ class _PollingDirectoryWatcher
9596
// Don't process any remaining files.
9697
_filesToProcess.clear();
9798
_polledFiles.clear();
98-
_lastModifieds.clear();
99+
_previousPollResults.clear();
99100
}
100101

101102
/// Scans the contents of the directory once to see which files have been
@@ -145,14 +146,14 @@ class _PollingDirectoryWatcher
145146
return;
146147
}
147148

148-
final modified = await modificationTime(file);
149+
final pollResult = await PollResult.poll(file);
149150

150151
if (_events.isClosed) return;
151152

152-
var lastModified = _lastModifieds[file];
153+
var previousPollResult = _previousPollResults[file];
153154

154155
// If its modification time hasn't changed, assume the file is unchanged.
155-
if (lastModified != null && lastModified == modified) {
156+
if (previousPollResult != null && previousPollResult == pollResult) {
156157
// The file is still here.
157158
_polledFiles.add(file);
158159
return;
@@ -161,17 +162,17 @@ class _PollingDirectoryWatcher
161162
if (_events.isClosed) return;
162163

163164
_polledFiles.add(file);
164-
if (modified == null) {
165+
if (!pollResult.fileExists) {
165166
// The file was in the directory listing but has been removed since then.
166167
// Don't add to _lastModifieds, it will be reported as a REMOVE.
167168
return;
168169
}
169-
_lastModifieds[file] = modified;
170+
_previousPollResults[file] = pollResult;
170171

171172
// Only notify if we're ready to emit events.
172173
if (!isReady) return;
173174

174-
var type = lastModified == null ? ChangeType.ADD : ChangeType.MODIFY;
175+
var type = previousPollResult == null ? ChangeType.ADD : ChangeType.MODIFY;
175176
_events.add(WatchEvent(type, file));
176177
}
177178

@@ -180,10 +181,11 @@ class _PollingDirectoryWatcher
180181
Future<void> _completePoll() async {
181182
// Any files that were not seen in the last poll but that we have a
182183
// status for must have been removed.
183-
var removedFiles = _lastModifieds.keys.toSet().difference(_polledFiles);
184+
var removedFiles =
185+
_previousPollResults.keys.toSet().difference(_polledFiles);
184186
for (var removed in removedFiles) {
185187
if (isReady) _events.add(WatchEvent(ChangeType.REMOVE, removed));
186-
_lastModifieds.remove(removed);
188+
_previousPollResults.remove(removed);
187189
}
188190

189191
if (!isReady) _readyCompleter.complete();

pkgs/watcher/lib/src/file_watcher/polling.dart

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import 'dart:async';
66
import 'dart:io';
77

88
import '../file_watcher.dart';
9+
import '../polling.dart';
910
import '../resubscribable.dart';
10-
import '../stat.dart';
1111
import '../watch_event.dart';
1212

1313
/// Periodically polls a file for changes.
@@ -37,10 +37,7 @@ class _PollingFileWatcher implements FileWatcher, ManuallyClosedWatcher {
3737
/// The timer that controls polling.
3838
late final Timer _timer;
3939

40-
/// The previous modification time of the file.
41-
///
42-
/// `null` indicates the file does not (or did not on the last poll) exist.
43-
DateTime? _lastModified;
40+
PollResult _previousPollResult = PollResult.notAFile();
4441

4542
_PollingFileWatcher(this.path, Duration pollingDelay) {
4643
_timer = Timer.periodic(pollingDelay, (_) => _poll());
@@ -55,39 +52,40 @@ class _PollingFileWatcher implements FileWatcher, ManuallyClosedWatcher {
5552
var pathExists = await File(path).exists();
5653
if (_eventsController.isClosed) return;
5754

58-
if (_lastModified != null && !pathExists) {
55+
if (_previousPollResult.fileExists && !pathExists) {
5956
_flagReady();
6057
_eventsController.add(WatchEvent(ChangeType.REMOVE, path));
6158
unawaited(close());
6259
return;
6360
}
6461

65-
DateTime? modified;
62+
PollResult pollResult;
6663
try {
67-
modified = await modificationTime(path);
64+
pollResult = await PollResult.poll(path);
6865
} on FileSystemException catch (error, stackTrace) {
6966
if (!_eventsController.isClosed) {
7067
_flagReady();
7168
_eventsController.addError(error, stackTrace);
7269
await close();
7370
}
71+
return;
7472
}
7573
if (_eventsController.isClosed) {
7674
_flagReady();
7775
return;
7876
}
7977

8078
if (!isReady) {
81-
// If this is the first poll, don't emit an event, just set the last mtime
82-
// and complete the completer.
83-
_lastModified = modified;
79+
// If this is the first poll, don't emit an event, just set the poll
80+
// result and complete the completer.
81+
_previousPollResult = pollResult;
8482
_flagReady();
8583
return;
8684
}
8785

88-
if (_lastModified == modified) return;
86+
if (_previousPollResult == pollResult) return;
8987

90-
_lastModified = modified;
88+
_previousPollResult = pollResult;
9189
_eventsController.add(WatchEvent(ChangeType.MODIFY, path));
9290
}
9391

pkgs/watcher/lib/src/polling.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:io';
6+
7+
/// Result of polling a path.
8+
///
9+
/// If it's a file, the result is combined from the file's "last modification"
10+
/// time and size, so that a change to either can be noticed as a change.
11+
///
12+
/// If the path is not a file, [fileExists] return `false`.
13+
extension type PollResult._(int _value) {
14+
/// A [PollResult] with [fileExists] `false`.
15+
factory PollResult.notAFile() => PollResult._(0);
16+
17+
static Future<PollResult> poll(String path) async {
18+
final stat = await FileStat.stat(path);
19+
if (stat.type != FileSystemEntityType.file) return PollResult.notAFile();
20+
21+
// Construct the poll result from the "last modified" time and size.
22+
// It should be very likely to change if either changes. Both are 64 bit
23+
// ints with the interesting bits in the low bits. Swap the 32 bit sections
24+
// of `microseconds` so the interesting bits don't clash, then XOR them.
25+
var microseconds = stat.modified.microsecondsSinceEpoch;
26+
microseconds = microseconds << 32 | microseconds >>> 32;
27+
return PollResult._(microseconds ^ stat.size);
28+
}
29+
30+
/// Whether the path exists and is a file.
31+
bool get fileExists => _value != 0;
32+
}

pkgs/watcher/lib/src/stat.dart

Lines changed: 0 additions & 34 deletions
This file was deleted.

pkgs/watcher/test/directory_watcher/file_tests.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ void _fileTests({required bool isNative}) {
6767
writeFile('b.txt', contents: 'before');
6868
await startWatcher();
6969

70+
if (!isNative) sleepUntilNewModificationTime();
7071
writeFile('a.txt', contents: 'same');
7172
writeFile('b.txt', contents: 'after');
7273
await inAnyOrder([isModifyEvent('a.txt'), isModifyEvent('b.txt')]);
@@ -139,7 +140,7 @@ void _fileTests({required bool isNative}) {
139140

140141
test('notifies when a file is moved onto an existing one', () async {
141142
writeFile('from.txt');
142-
writeFile('to.txt');
143+
writeFile('to.txt', contents: 'different');
143144
await startWatcher();
144145

145146
renameFile('from.txt', 'to.txt');

pkgs/watcher/test/directory_watcher/link_tests.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ void _linkTests({required bool isNative}) {
3232
createDir('targets');
3333
createDir('links');
3434
writeFile('targets/a.target');
35+
sleepUntilNewModificationTime();
3536
writeFile('targets/b.target');
3637
writeLink(link: 'links/a.link', target: 'targets/a.target');
3738
await startWatcher(path: 'links');
@@ -46,7 +47,7 @@ void _linkTests({required bool isNative}) {
4647
'notifies when a link is replaced with a link to a different target '
4748
'with different contents', () async {
4849
writeFile('targets/a.target', contents: 'a');
49-
writeFile('targets/b.target', contents: 'b');
50+
writeFile('targets/b.target', contents: 'ab');
5051
writeLink(link: 'links/a.link', target: 'targets/a.target');
5152
await startWatcher(path: 'links');
5253

pkgs/watcher/test/directory_watcher/polling_test.dart

Lines changed: 39 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,63 +15,45 @@ import 'link_tests.dart';
1515
void main() {
1616
// Use a short delay to make the tests run quickly.
1717
watcherFactory = (dir) => PollingDirectoryWatcher(dir,
18-
pollingDelay: const Duration(milliseconds: 100));
19-
20-
// Filesystem modification times can be low resolution, mock them.
21-
group('with mock mtime', () {
22-
setUp(enableMockModificationTimes);
23-
24-
fileTests(isNative: false);
25-
linkTests(isNative: false);
26-
27-
test('does not notify if the modification time did not change', () async {
28-
writeFile('a.txt', contents: 'before');
29-
writeFile('b.txt', contents: 'before');
30-
await startWatcher();
31-
writeFile('a.txt', contents: 'after', updateModified: false);
32-
writeFile('b.txt', contents: 'after');
33-
await expectModifyEvent('b.txt');
34-
});
35-
36-
// A poll does an async directory list then checks mtime on each file. Check
37-
// handling of a file that is deleted between the two.
38-
test('deletes during poll', () async {
39-
await startWatcher();
40-
41-
for (var i = 0; i != 300; ++i) {
42-
writeFile('$i');
43-
}
44-
// A series of deletes with delays in between for 300ms, which will
45-
// intersect with the 100ms polling multiple times.
46-
for (var i = 0; i != 300; ++i) {
47-
deleteFile('$i');
48-
await Future<void>.delayed(const Duration(milliseconds: 1));
18+
pollingDelay: const Duration(milliseconds: 10));
19+
20+
/// See [enableSleepUntilNewModificationTime] for a note about the "polling"
21+
/// tests.
22+
setUp(enableSleepUntilNewModificationTime);
23+
24+
fileTests(isNative: false);
25+
linkTests(isNative: false);
26+
27+
// A poll does an async directory list that runs "stat" on each file. Check
28+
// handling of a file that is deleted between the two.
29+
test('deletes during poll', () async {
30+
await startWatcher();
31+
32+
for (var i = 0; i != 300; ++i) {
33+
writeFile('$i');
34+
}
35+
// A series of deletes with delays in between for 300ms, which will
36+
// intersect with the 10ms polling multiple times.
37+
for (var i = 0; i != 300; ++i) {
38+
deleteFile('$i');
39+
await Future<void>.delayed(const Duration(milliseconds: 1));
40+
}
41+
42+
final events =
43+
await takeEvents(duration: const Duration(milliseconds: 500));
44+
45+
// Events should be adds and removes that pair up, with no modify events.
46+
final adds = <String>{};
47+
final removes = <String>{};
48+
for (var event in events) {
49+
if (event.type == ChangeType.ADD) {
50+
adds.add(event.path);
51+
} else if (event.type == ChangeType.REMOVE) {
52+
removes.add(event.path);
53+
} else {
54+
fail('Unexpected event: $event');
4955
}
50-
51-
final events =
52-
await takeEvents(duration: const Duration(milliseconds: 500));
53-
54-
// Events should be adds and removes that pair up, with no modify events.
55-
final adds = <String>{};
56-
final removes = <String>{};
57-
for (var event in events) {
58-
if (event.type == ChangeType.ADD) {
59-
adds.add(event.path);
60-
} else if (event.type == ChangeType.REMOVE) {
61-
removes.add(event.path);
62-
} else {
63-
fail('Unexpected event: $event');
64-
}
65-
}
66-
expect(adds, removes);
67-
});
68-
});
69-
70-
// Also test with delayed writes and real mtimes.
71-
group('with real mtime', () {
72-
setUp(enableWaitingForDifferentModificationTimes);
73-
74-
fileTests(isNative: false);
75-
linkTests(isNative: false);
56+
}
57+
expect(adds, removes);
7658
});
7759
}

0 commit comments

Comments
 (0)