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
3 changes: 3 additions & 0 deletions pkgs/watcher/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## 1.1.5-wip

- Polling watchers now check file sizes as well as "last modified" times, so
they are less likely to miss changes on platforms with low resolution
timestamps.
- Bug fix: with `FileWatcher` on MacOS, a modify event was sometimes reported if
the file was created immediately before the watcher was created. Now, if the
file exists when the watcher is created then this modify event is not sent.
Expand Down
34 changes: 18 additions & 16 deletions pkgs/watcher/lib/src/directory_watcher/polling.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import 'dart:io';

import '../async_queue.dart';
import '../directory_watcher.dart';
import '../polling.dart';
import '../resubscribable.dart';
import '../stat.dart';
import '../utils.dart';
import '../watch_event.dart';

/// Periodically polls a directory for changes.
///
/// Changes are noticed if the "last modified" time of a file changes or if its
/// size changes.
class PollingDirectoryWatcher extends ResubscribableWatcher
implements DirectoryWatcher {
@override
Expand Down Expand Up @@ -53,10 +56,7 @@ class _PollingDirectoryWatcher
/// directory contents.
final Duration _pollingDelay;

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

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

_PollingDirectoryWatcher(this.path, this._pollingDelay) {
Expand All @@ -95,7 +96,7 @@ class _PollingDirectoryWatcher
// Don't process any remaining files.
_filesToProcess.clear();
_polledFiles.clear();
_lastModifieds.clear();
_previousPollResults.clear();
}

/// Scans the contents of the directory once to see which files have been
Expand Down Expand Up @@ -145,14 +146,14 @@ class _PollingDirectoryWatcher
return;
}

final modified = await modificationTime(file);
final pollResult = await PollResult.poll(file);

if (_events.isClosed) return;

var lastModified = _lastModifieds[file];
var previousPollResult = _previousPollResults[file];

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

_polledFiles.add(file);
if (modified == null) {
if (!pollResult.fileExists) {
// The file was in the directory listing but has been removed since then.
// Don't add to _lastModifieds, it will be reported as a REMOVE.
// Don't add to _previousPollResults, it will be reported as a REMOVE.
return;
}
_lastModifieds[file] = modified;
_previousPollResults[file] = pollResult;

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

var type = lastModified == null ? ChangeType.ADD : ChangeType.MODIFY;
var type = previousPollResult == null ? ChangeType.ADD : ChangeType.MODIFY;
_events.add(WatchEvent(type, file));
}

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

if (!isReady) _readyCompleter.complete();
Expand Down
24 changes: 11 additions & 13 deletions pkgs/watcher/lib/src/file_watcher/polling.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import 'dart:async';
import 'dart:io';

import '../file_watcher.dart';
import '../polling.dart';
import '../resubscribable.dart';
import '../stat.dart';
import '../watch_event.dart';

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

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

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

if (_lastModified != null && !pathExists) {
if (_previousPollResult.fileExists && !pathExists) {
_flagReady();
_eventsController.add(WatchEvent(ChangeType.REMOVE, path));
unawaited(close());
return;
}

DateTime? modified;
PollResult pollResult;
try {
modified = await modificationTime(path);
pollResult = await PollResult.poll(path);
} on FileSystemException catch (error, stackTrace) {
if (!_eventsController.isClosed) {
_flagReady();
_eventsController.addError(error, stackTrace);
await close();
}
return;
}
if (_eventsController.isClosed) {
_flagReady();
return;
}

if (!isReady) {
// If this is the first poll, don't emit an event, just set the last mtime
// and complete the completer.
_lastModified = modified;
// If this is the first poll, don't emit an event, just set the poll
// result and complete the completer.
_previousPollResult = pollResult;
_flagReady();
return;
}

if (_lastModified == modified) return;
if (_previousPollResult == pollResult) return;

_lastModified = modified;
_previousPollResult = pollResult;
_eventsController.add(WatchEvent(ChangeType.MODIFY, path));
}

Expand Down
32 changes: 32 additions & 0 deletions pkgs/watcher/lib/src/polling.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';

/// Result of polling a path.
///
/// If it's a file, the result is combined from the file's "last modification"
/// time and size, so that a change to either can be noticed as a change.
///
/// If the path is not a file, [fileExists] return `false`.
extension type PollResult._(int _value) {
/// A [PollResult] with [fileExists] `false`.
factory PollResult.notAFile() => PollResult._(0);

static Future<PollResult> poll(String path) async {
final stat = await FileStat.stat(path);
if (stat.type != FileSystemEntityType.file) return PollResult.notAFile();

// Construct the poll result from the "last modified" time and size.
// It should be very likely to change if either changes. Both are 64 bit
// ints with the interesting bits in the low bits. Swap the 32 bit sections
// of `microseconds` so the interesting bits don't clash, then XOR them.
var microseconds = stat.modified.microsecondsSinceEpoch;
microseconds = microseconds << 32 | microseconds >>> 32;
return PollResult._(microseconds ^ stat.size);
}

/// Whether the path exists and is a file.
bool get fileExists => _value != 0;
}
34 changes: 0 additions & 34 deletions pkgs/watcher/lib/src/stat.dart

This file was deleted.

3 changes: 2 additions & 1 deletion pkgs/watcher/test/directory_watcher/file_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ void _fileTests({required bool isNative}) {
writeFile('b.txt', contents: 'before');
await startWatcher();

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

test('notifies when a file is moved onto an existing one', () async {
writeFile('from.txt');
writeFile('to.txt');
writeFile('to.txt', contents: 'different');
await startWatcher();

renameFile('from.txt', 'to.txt');
Expand Down
3 changes: 2 additions & 1 deletion pkgs/watcher/test/directory_watcher/link_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ void _linkTests({required bool isNative}) {
createDir('targets');
createDir('links');
writeFile('targets/a.target');
sleepUntilNewModificationTime();
writeFile('targets/b.target');
writeLink(link: 'links/a.link', target: 'targets/a.target');
await startWatcher(path: 'links');
Expand All @@ -46,7 +47,7 @@ void _linkTests({required bool isNative}) {
'notifies when a link is replaced with a link to a different target '
'with different contents', () async {
writeFile('targets/a.target', contents: 'a');
writeFile('targets/b.target', contents: 'b');
writeFile('targets/b.target', contents: 'ab');
writeLink(link: 'links/a.link', target: 'targets/a.target');
await startWatcher(path: 'links');

Expand Down
96 changes: 39 additions & 57 deletions pkgs/watcher/test/directory_watcher/polling_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,63 +15,45 @@ import 'link_tests.dart';
void main() {
// Use a short delay to make the tests run quickly.
watcherFactory = (dir) => PollingDirectoryWatcher(dir,
pollingDelay: const Duration(milliseconds: 100));

// Filesystem modification times can be low resolution, mock them.
group('with mock mtime', () {
setUp(enableMockModificationTimes);

fileTests(isNative: false);
linkTests(isNative: false);

test('does not notify if the modification time did not change', () async {
writeFile('a.txt', contents: 'before');
writeFile('b.txt', contents: 'before');
await startWatcher();
writeFile('a.txt', contents: 'after', updateModified: false);
writeFile('b.txt', contents: 'after');
await expectModifyEvent('b.txt');
});

// A poll does an async directory list then checks mtime on each file. Check
// handling of a file that is deleted between the two.
test('deletes during poll', () async {
await startWatcher();

for (var i = 0; i != 300; ++i) {
writeFile('$i');
}
// A series of deletes with delays in between for 300ms, which will
// intersect with the 100ms polling multiple times.
for (var i = 0; i != 300; ++i) {
deleteFile('$i');
await Future<void>.delayed(const Duration(milliseconds: 1));
pollingDelay: const Duration(milliseconds: 10));

/// See [enableSleepUntilNewModificationTime] for a note about the "polling"
/// tests.
setUp(enableSleepUntilNewModificationTime);

fileTests(isNative: false);
linkTests(isNative: false);

// A poll does an async directory list that runs "stat" on each file. Check
// handling of a file that is deleted between the two.
test('deletes during poll', () async {
await startWatcher();

for (var i = 0; i != 300; ++i) {
writeFile('$i');
}
// A series of deletes with delays in between for 300ms, which will
// intersect with the 10ms polling multiple times.
for (var i = 0; i != 300; ++i) {
deleteFile('$i');
await Future<void>.delayed(const Duration(milliseconds: 1));
}

final events =
await takeEvents(duration: const Duration(milliseconds: 500));

// Events should be adds and removes that pair up, with no modify events.
final adds = <String>{};
final removes = <String>{};
for (var event in events) {
if (event.type == ChangeType.ADD) {
adds.add(event.path);
} else if (event.type == ChangeType.REMOVE) {
removes.add(event.path);
} else {
fail('Unexpected event: $event');
}

final events =
await takeEvents(duration: const Duration(milliseconds: 500));

// Events should be adds and removes that pair up, with no modify events.
final adds = <String>{};
final removes = <String>{};
for (var event in events) {
if (event.type == ChangeType.ADD) {
adds.add(event.path);
} else if (event.type == ChangeType.REMOVE) {
removes.add(event.path);
} else {
fail('Unexpected event: $event');
}
}
expect(adds, removes);
});
});

// Also test with delayed writes and real mtimes.
group('with real mtime', () {
setUp(enableWaitingForDifferentModificationTimes);

fileTests(isNative: false);
linkTests(isNative: false);
}
expect(adds, removes);
});
}
Loading