diff --git a/.github/ISSUE_TEMPLATE/watcher.md b/.github/ISSUE_TEMPLATE/watcher.md new file mode 100644 index 000000000..2578819b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/watcher.md @@ -0,0 +1,5 @@ +--- +name: "package:watcher" +about: "Create a bug or file a feature request against package:watcher." +labels: "package:watcher" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index 1cc4b2058..f69c1bc27 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -111,3 +111,7 @@ 'package:unified_analytics': - changed-files: - any-glob-to-any-file: 'pkgs/unified_analytics/**' + +'package:watcher': + - changed-files: + - any-glob-to-any-file: 'pkgs/watcher/**' diff --git a/.github/workflows/watcher.yaml b/.github/workflows/watcher.yaml new file mode 100644 index 000000000..a04483c4d --- /dev/null +++ b/.github/workflows/watcher.yaml @@ -0,0 +1,71 @@ +name: package:watcher + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/watcher.yaml' + - 'pkgs/watcher/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/watcher.yaml' + - 'pkgs/watcher/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + + +defaults: + run: + working-directory: pkgs/watcher/ + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, macos-latest, windows-latest + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + sdk: [3.1, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index 79d1dde21..11ff12977 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ don't naturally belong to other topic monorepos (like | [source_span](pkgs/source_span/) | Provides a standard representation for source code locations and spans. | [![package issues](https://img.shields.io/badge/package:source_span-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asource_span) | [![pub package](https://img.shields.io/pub/v/source_span.svg)](https://pub.dev/packages/source_span) | | [sse](pkgs/sse/) | Provides client and server functionality for setting up bi-directional communication through Server Sent Events (SSE) and corresponding POST requests. | [![package issues](https://img.shields.io/badge/package:sse-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Asse) | [![pub package](https://img.shields.io/pub/v/sse.svg)](https://pub.dev/packages/sse) | | [unified_analytics](pkgs/unified_analytics/) | A package for logging analytics for all Dart and Flutter related tooling to Google Analytics. | [![package issues](https://img.shields.io/badge/package:unified_analytics-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aunified_analytics) | [![pub package](https://img.shields.io/pub/v/unified_analytics.svg)](https://pub.dev/packages/unified_analytics) | +| [watcher](pkgs/watcher/) | Monitor directories and send notifications when the contents change. | [![package issues](https://img.shields.io/badge/package:watcher-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Awatcher) | [![pub package](https://img.shields.io/pub/v/watcher.svg)](https://pub.dev/packages/watcher) | ## Publishing automation diff --git a/pkgs/watcher/.gitignore b/pkgs/watcher/.gitignore new file mode 100644 index 000000000..ac98e87d1 --- /dev/null +++ b/pkgs/watcher/.gitignore @@ -0,0 +1,4 @@ +# Don’t commit the following directories created by pub. +.dart_tool +.packages +pubspec.lock diff --git a/pkgs/watcher/.test_config b/pkgs/watcher/.test_config new file mode 100644 index 000000000..531426abb --- /dev/null +++ b/pkgs/watcher/.test_config @@ -0,0 +1,5 @@ +{ + "test_package": { + "platforms": ["vm"] + } +} \ No newline at end of file diff --git a/pkgs/watcher/CHANGELOG.md b/pkgs/watcher/CHANGELOG.md new file mode 100644 index 000000000..ef3a7e2d3 --- /dev/null +++ b/pkgs/watcher/CHANGELOG.md @@ -0,0 +1,130 @@ +## 1.1.1 + +- Ensure `PollingFileWatcher.ready` completes for files that do not exist. +- Require Dart SDK `^3.1.0` +- Move to `dart-lang/tools` monorepo. + +## 1.1.0 + +- Require Dart SDK >= 3.0.0 +- Remove usage of redundant ConstructableFileSystemEvent classes. + +## 1.0.3-dev + +- Require Dart SDK >= 2.19 + +## 1.0.2 + +- Require Dart SDK >= 2.14 +- Ensure `DirectoryWatcher.ready` completes even when errors occur that close the watcher. +- Add markdown badges to the readme. + +## 1.0.1 + +* Drop package:pedantic and use package:lints instead. + +## 1.0.0 + +* Require Dart SDK >= 2.12 +* Add the ability to create custom Watcher types for specific file paths. + +## 0.9.7+15 + +* Fix a bug on Mac where modifying a directory with a path exactly matching a + prefix of a modified file would suppress change events for that file. + +## 0.9.7+14 + +* Prepare for breaking change in SDK where modified times for not found files + becomes meaningless instead of null. + +## 0.9.7+13 + +* Catch & forward `FileSystemException` from unexpectedly closed file watchers + on windows; the watcher will also be automatically restarted when this occurs. + +## 0.9.7+12 + +* Catch `FileSystemException` during `existsSync()` on Windows. +* Internal cleanup. + +## 0.9.7+11 + +* Fix an analysis hint. + +## 0.9.7+10 + +* Set max SDK version to `<3.0.0`, and adjust other dependencies. + +## 0.9.7+9 + +* Internal changes only. + +## 0.9.7+8 + +* Fix Dart 2.0 type issues on Mac and Windows. + +## 0.9.7+7 + +* Updates to support Dart 2.0 core library changes (wave 2.2). + See [issue 31847][sdk#31847] for details. + + [sdk#31847]: https://github.com/dart-lang/sdk/issues/31847 + + +## 0.9.7+6 + +* Internal changes only, namely removing dep on scheduled test. + +## 0.9.7+5 + +* Fix an analysis warning. + +## 0.9.7+4 + +* Declare support for `async` 2.0.0. + +## 0.9.7+3 + +* Fix a crashing bug on Linux. + +## 0.9.7+2 + +* Narrow the constraint on `async` to reflect the APIs this package is actually + using. + +## 0.9.7+1 + +* Fix all strong-mode warnings. + +## 0.9.7 + +* Fix a bug in `FileWatcher` where events could be added after watchers were + closed. + +## 0.9.6 + +* Add a `Watcher` interface that encompasses watching both files and + directories. + +* Add `FileWatcher` and `PollingFileWatcher` classes for watching changes to + individual files. + +* Deprecate `DirectoryWatcher.directory`. Use `DirectoryWatcher.path` instead. + +## 0.9.5 + +* Fix bugs where events could be added after watchers were closed. + +## 0.9.4 + +* Treat add events for known files as modifications instead of discarding them + on Mac OS. + +## 0.9.3 + +* Improved support for Windows via `WindowsDirectoryWatcher`. + +* Simplified `PollingDirectoryWatcher`. + +* Fixed bugs in `MacOSDirectoryWatcher` diff --git a/pkgs/watcher/LICENSE b/pkgs/watcher/LICENSE new file mode 100644 index 000000000..000cd7bec --- /dev/null +++ b/pkgs/watcher/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/watcher/README.md b/pkgs/watcher/README.md new file mode 100644 index 000000000..83a0324b4 --- /dev/null +++ b/pkgs/watcher/README.md @@ -0,0 +1,10 @@ +[![Build Status](https://github.com/dart-lang/tools/actions/workflows/watcher.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/watcher.yaml) +[![pub package](https://img.shields.io/pub/v/watcher.svg)](https://pub.dev/packages/watcher) +[![package publisher](https://img.shields.io/pub/publisher/watcher.svg)](https://pub.dev/packages/watcher/publisher) + +A file system watcher. + +## What's this? + +`package:watcher` monitors changes to contents of directories and sends +notifications when files have been added, removed, or modified. diff --git a/pkgs/watcher/analysis_options.yaml b/pkgs/watcher/analysis_options.yaml new file mode 100644 index 000000000..d978f811c --- /dev/null +++ b/pkgs/watcher/analysis_options.yaml @@ -0,0 +1 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml diff --git a/pkgs/watcher/benchmark/path_set.dart b/pkgs/watcher/benchmark/path_set.dart new file mode 100644 index 000000000..e7929d8ec --- /dev/null +++ b/pkgs/watcher/benchmark/path_set.dart @@ -0,0 +1,158 @@ +// Copyright (c) 2015, 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. + +/// Benchmarks for the PathSet class. +library; + +import 'dart:io'; +import 'dart:math' as math; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:path/path.dart' as p; +import 'package:watcher/src/path_set.dart'; + +final String root = Platform.isWindows ? r'C:\root' : '/root'; + +/// Base class for benchmarks on [PathSet]. +abstract class PathSetBenchmark extends BenchmarkBase { + PathSetBenchmark(String method) : super('PathSet.$method'); + + final PathSet pathSet = PathSet(root); + + /// Use a fixed [math.Random] with a constant seed to ensure the tests are + /// deterministic. + final math.Random random = math.Random(1234); + + /// Walks over a virtual directory [depth] levels deep invoking [callback] + /// for each "file". + /// + /// Each virtual directory contains ten entries: either subdirectories or + /// files. + void walkTree(int depth, void Function(String) callback) { + void recurse(String path, int remainingDepth) { + for (var i = 0; i < 10; i++) { + var padded = i.toString().padLeft(2, '0'); + if (remainingDepth == 0) { + callback(p.join(path, 'file_$padded.txt')); + } else { + var subdir = p.join(path, 'subdirectory_$padded'); + recurse(subdir, remainingDepth - 1); + } + } + } + + recurse(root, depth); + } +} + +class AddBenchmark extends PathSetBenchmark { + AddBenchmark() : super('add()'); + + final List paths = []; + + @override + void setup() { + // Make a bunch of paths in about the same order we expect to get them from + // Directory.list(). + walkTree(3, paths.add); + } + + @override + void run() { + for (var path in paths) { + pathSet.add(path); + } + } +} + +class ContainsBenchmark extends PathSetBenchmark { + ContainsBenchmark() : super('contains()'); + + final List paths = []; + + @override + void setup() { + // Add a bunch of paths to the set. + walkTree(3, (path) { + pathSet.add(path); + paths.add(path); + }); + + // Add some non-existent paths to test the false case. + for (var i = 0; i < 100; i++) { + paths.addAll([ + '/nope', + '/root/nope', + '/root/subdirectory_04/nope', + '/root/subdirectory_04/subdirectory_04/nope', + '/root/subdirectory_04/subdirectory_04/subdirectory_04/nope', + '/root/subdirectory_04/subdirectory_04/subdirectory_04/nope/file_04.txt', + ]); + } + } + + @override + void run() { + var contained = 0; + for (var path in paths) { + if (pathSet.contains(path)) contained++; + } + + if (contained != 10000) throw StateError('Wrong result: $contained'); + } +} + +class PathsBenchmark extends PathSetBenchmark { + PathsBenchmark() : super('toSet()'); + + @override + void setup() { + walkTree(3, pathSet.add); + } + + @override + void run() { + var count = 0; + for (var _ in pathSet.paths) { + count++; + } + + if (count != 10000) throw StateError('Wrong result: $count'); + } +} + +class RemoveBenchmark extends PathSetBenchmark { + RemoveBenchmark() : super('remove()'); + + final List paths = []; + + @override + void setup() { + // Make a bunch of paths. Do this here so that we don't spend benchmarked + // time synthesizing paths. + walkTree(3, (path) { + pathSet.add(path); + paths.add(path); + }); + + // Shuffle the paths so that we delete them in a random order that + // hopefully mimics real-world file system usage. Do the shuffling here so + // that we don't spend benchmarked time shuffling. + paths.shuffle(random); + } + + @override + void run() { + for (var path in paths) { + pathSet.remove(path); + } + } +} + +void main() { + AddBenchmark().report(); + ContainsBenchmark().report(); + PathsBenchmark().report(); + RemoveBenchmark().report(); +} diff --git a/pkgs/watcher/example/watch.dart b/pkgs/watcher/example/watch.dart new file mode 100644 index 000000000..37931d396 --- /dev/null +++ b/pkgs/watcher/example/watch.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2013, 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. + +/// Watches the given directory and prints each modification to it. +library; + +import 'package:path/path.dart' as p; +import 'package:watcher/watcher.dart'; + +void main(List arguments) { + if (arguments.length != 1) { + print('Usage: watch '); + return; + } + + var watcher = DirectoryWatcher(p.absolute(arguments[0])); + watcher.events.listen(print); +} diff --git a/pkgs/watcher/lib/src/async_queue.dart b/pkgs/watcher/lib/src/async_queue.dart new file mode 100644 index 000000000..f6c76a9cb --- /dev/null +++ b/pkgs/watcher/lib/src/async_queue.dart @@ -0,0 +1,70 @@ +// Copyright (c) 2013, 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:collection'; + +typedef ItemProcessor = Future Function(T item); + +/// A queue of items that are sequentially, asynchronously processed. +/// +/// Unlike [Stream.map] or [Stream.forEach], the callback used to process each +/// item returns a [Future], and it will not advance to the next item until the +/// current item is finished processing. +/// +/// Items can be added at any point in time and processing will be started as +/// needed. When all items are processed, it stops processing until more items +/// are added. +class AsyncQueue { + final _items = Queue(); + + /// Whether or not the queue is currently waiting on a processing future to + /// complete. + bool _isProcessing = false; + + /// The callback to invoke on each queued item. + /// + /// The next item in the queue will not be processed until the [Future] + /// returned by this completes. + final ItemProcessor _processor; + + /// The handler for errors thrown during processing. + /// + /// Used to avoid top-leveling asynchronous errors. + final void Function(Object, StackTrace) _errorHandler; + + AsyncQueue(this._processor, + {required void Function(Object, StackTrace) onError}) + : _errorHandler = onError; + + /// Enqueues [item] to be processed and starts asynchronously processing it + /// if a process isn't already running. + void add(T item) { + _items.add(item); + + // Start up the asynchronous processing if not already running. + if (_isProcessing) return; + _isProcessing = true; + + _processNextItem().catchError(_errorHandler); + } + + /// Removes all remaining items to be processed. + void clear() { + _items.clear(); + } + + /// Pulls the next item off [_items] and processes it. + /// + /// When complete, recursively calls itself to continue processing unless + /// the process was cancelled. + Future _processNextItem() async { + var item = _items.removeFirst(); + await _processor(item); + if (_items.isNotEmpty) return _processNextItem(); + + // We have drained the queue, stop processing and wait until something + // has been enqueued. + _isProcessing = false; + } +} diff --git a/pkgs/watcher/lib/src/custom_watcher_factory.dart b/pkgs/watcher/lib/src/custom_watcher_factory.dart new file mode 100644 index 000000000..fc4e3fb90 --- /dev/null +++ b/pkgs/watcher/lib/src/custom_watcher_factory.dart @@ -0,0 +1,88 @@ +// Copyright (c) 2020, 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 '../watcher.dart'; + +/// A factory to produce custom watchers for specific file paths. +class _CustomWatcherFactory { + final String id; + final DirectoryWatcher? Function(String path, {Duration? pollingDelay}) + createDirectoryWatcher; + final FileWatcher? Function(String path, {Duration? pollingDelay}) + createFileWatcher; + + _CustomWatcherFactory( + this.id, this.createDirectoryWatcher, this.createFileWatcher); +} + +/// Registers a custom watcher. +/// +/// Each custom watcher must have a unique [id] and the same watcher may not be +/// registered more than once. +/// [createDirectoryWatcher] and [createFileWatcher] should return watchers for +/// the file paths they are able to handle. If the custom watcher is not able to +/// handle the path it should return null. +/// The paths handled by each custom watch may not overlap, at most one custom +/// matcher may return a non-null watcher for a given path. +/// +/// When a file or directory watcher is created the path is checked against each +/// registered custom watcher, and if exactly one custom watcher is available it +/// will be used instead of the default. +void registerCustomWatcher( + String id, + DirectoryWatcher? Function(String path, {Duration? pollingDelay})? + createDirectoryWatcher, + FileWatcher? Function(String path, {Duration? pollingDelay})? + createFileWatcher, +) { + if (_customWatcherFactories.containsKey(id)) { + throw ArgumentError('A custom watcher with id `$id` ' + 'has already been registered'); + } + _customWatcherFactories[id] = _CustomWatcherFactory( + id, + createDirectoryWatcher ?? (_, {pollingDelay}) => null, + createFileWatcher ?? (_, {pollingDelay}) => null); +} + +/// Tries to create a custom [DirectoryWatcher] and returns it. +/// +/// Returns `null` if no custom watcher was applicable and throws a [StateError] +/// if more than one was. +DirectoryWatcher? createCustomDirectoryWatcher(String path, + {Duration? pollingDelay}) { + DirectoryWatcher? customWatcher; + String? customFactoryId; + for (var watcherFactory in _customWatcherFactories.values) { + if (customWatcher != null) { + throw StateError('Two `CustomWatcherFactory`s applicable: ' + '`$customFactoryId` and `${watcherFactory.id}` for `$path`'); + } + customWatcher = + watcherFactory.createDirectoryWatcher(path, pollingDelay: pollingDelay); + customFactoryId = watcherFactory.id; + } + return customWatcher; +} + +/// Tries to create a custom [FileWatcher] and returns it. +/// +/// Returns `null` if no custom watcher was applicable and throws a [StateError] +/// if more than one was. +FileWatcher? createCustomFileWatcher(String path, {Duration? pollingDelay}) { + FileWatcher? customWatcher; + String? customFactoryId; + for (var watcherFactory in _customWatcherFactories.values) { + if (customWatcher != null) { + throw StateError('Two `CustomWatcherFactory`s applicable: ' + '`$customFactoryId` and `${watcherFactory.id}` for `$path`'); + } + customWatcher = + watcherFactory.createFileWatcher(path, pollingDelay: pollingDelay); + customFactoryId = watcherFactory.id; + } + return customWatcher; +} + +final _customWatcherFactories = {}; diff --git a/pkgs/watcher/lib/src/directory_watcher.dart b/pkgs/watcher/lib/src/directory_watcher.dart new file mode 100644 index 000000000..158b86b05 --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher.dart @@ -0,0 +1,41 @@ +// Copyright (c) 2013, 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'; + +import '../watcher.dart'; +import 'custom_watcher_factory.dart'; +import 'directory_watcher/linux.dart'; +import 'directory_watcher/mac_os.dart'; +import 'directory_watcher/windows.dart'; + +/// Watches the contents of a directory and emits [WatchEvent]s when something +/// in the directory has changed. +abstract class DirectoryWatcher implements Watcher { + /// The directory whose contents are being monitored. + @Deprecated('Expires in 1.0.0. Use DirectoryWatcher.path instead.') + String get directory; + + /// Creates a new [DirectoryWatcher] monitoring [directory]. + /// + /// If a native directory watcher is available for this platform, this will + /// use it. Otherwise, it will fall back to a [PollingDirectoryWatcher]. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the directory contents. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. Ignored for non-polling + /// watchers. + factory DirectoryWatcher(String directory, {Duration? pollingDelay}) { + if (FileSystemEntity.isWatchSupported) { + var customWatcher = + createCustomDirectoryWatcher(directory, pollingDelay: pollingDelay); + if (customWatcher != null) return customWatcher; + if (Platform.isLinux) return LinuxDirectoryWatcher(directory); + if (Platform.isMacOS) return MacOSDirectoryWatcher(directory); + if (Platform.isWindows) return WindowsDirectoryWatcher(directory); + } + return PollingDirectoryWatcher(directory, pollingDelay: pollingDelay); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/linux.dart b/pkgs/watcher/lib/src/directory_watcher/linux.dart new file mode 100644 index 000000000..cb1d07781 --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/linux.dart @@ -0,0 +1,294 @@ +// Copyright (c) 2013, 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:async'; +import 'dart:io'; + +import 'package:async/async.dart'; + +import '../directory_watcher.dart'; +import '../path_set.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Uses the inotify subsystem to watch for filesystem events. +/// +/// Inotify doesn't suport recursively watching subdirectories, nor does +/// [Directory.watch] polyfill that functionality. This class polyfills it +/// instead. +/// +/// This class also compensates for the non-inotify-specific issues of +/// [Directory.watch] producing multiple events for a single logical action +/// (issue 14372) and providing insufficient information about move events +/// (issue 14424). +class LinuxDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + LinuxDirectoryWatcher(String directory) + : super(directory, () => _LinuxDirectoryWatcher(directory)); +} + +class _LinuxDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => _files.root; + @override + String get path => _files.root; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// A stream group for the [Directory.watch] events of [path] and all its + /// subdirectories. + final _nativeEvents = StreamGroup(); + + /// 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 = >{}; + + /// 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 = {}; + + _LinuxDirectoryWatcher(String path) : _files = PathSet(path) { + _nativeEvents.add(Directory(path) + .watch() + .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 + // we close the [StreamGroup] here, we run the risk of new-directory + // events being fired after the group is closed, since batching delays + // those events. See b/30768513. + _onDone(); + }))); + + // Batch the inotify changes together so that we can dedup events. + var innerStream = _nativeEvents.stream.batchEvents(); + _listen(innerStream, _onBatch, + onError: (Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + }); + + _listen( + Directory(path).list(recursive: true), + (FileSystemEntity entity) { + if (entity is Directory) { + _watchSubdir(entity.path); + } else { + _files.add(entity.path); + } + }, + onError: _emitError, + onDone: () { + if (!isReady) { + _readyCompleter.complete(); + } + }, + cancelOnError: true, + ); + } + + @override + void close() { + for (var subscription in _subscriptions) { + subscription.cancel(); + } + + _subscriptions.clear(); + _subdirStreams.clear(); + _files.clear(); + _nativeEvents.close(); + _eventsController.close(); + } + + /// Watch a subdirectory of [directory] for changes. + void _watchSubdir(String path) { + // TODO(nweiz): Right now it's possible for the watcher to emit an event for + // a file before the directory list is complete. This could lead to the user + // seeing a MODIFY or REMOVE event for a file before they see an ADD event, + // which is bad. We should handle that. + // + // One possibility is to provide a general means (e.g. + // `DirectoryWatcher.eventsAndExistingFiles`) to tell a watcher to emit + // events for all the files that already exist. This would be useful for + // top-level clients such as barback as well, and could be implemented with + // a wrapper similar to how listening/canceling works now. + + // TODO(nweiz): Catch any errors here that indicate that the directory in + // question doesn't exist and silently stop watching it instead of + // propagating the errors. + var stream = Directory(path).watch(); + _subdirStreams[path] = stream; + _nativeEvents.add(stream); + } + + /// The callback that's run when a batch of changes comes in. + void _onBatch(List batch) { + var files = {}; + var dirs = {}; + var changed = {}; + + // inotify event batches are ordered by occurrence, so we treat them as a + // log of what happened to a file. We only emit events based on the + // difference between the state before the batch and the state after it, not + // the intermediate state. + for (var event in batch) { + // If the watched directory is deleted or moved, we'll get a deletion + // event for it. Ignore it; we handle closing [this] when the underlying + // stream is closed. + if (event.path == path) continue; + + changed.add(event.path); + + if (event is FileSystemMoveEvent) { + files.remove(event.path); + dirs.remove(event.path); + + var destination = event.destination; + if (destination == null) continue; + + changed.add(destination); + if (event.isDirectory) { + files.remove(destination); + dirs.add(destination); + } else { + files.add(destination); + dirs.remove(destination); + } + } else if (event is FileSystemDeleteEvent) { + files.remove(event.path); + dirs.remove(event.path); + } else if (event.isDirectory) { + files.remove(event.path); + dirs.add(event.path); + } else { + files.add(event.path); + dirs.remove(event.path); + } + } + + _applyChanges(files, dirs, changed); + } + + /// Applies the net changes computed for a batch. + /// + /// The [files] and [dirs] sets contain the files and directories that now + /// exist, respectively. The [changed] set contains all files and directories + /// that have changed (including being removed), and so is a superset of + /// [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; + for (var file in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, file); + } + } + + for (var file in files) { + if (_files.contains(file)) { + _emitEvent(ChangeType.MODIFY, file); + } else { + _emitEvent(ChangeType.ADD, file); + _files.add(file); + } + } + + for (var dir in dirs) { + _watchSubdir(dir); + _addSubdir(dir); + } + } + + /// Emits [ChangeType.ADD] events for the recursive contents of [path]. + void _addSubdir(String path) { + _listen(Directory(path).list(recursive: true), (FileSystemEntity entity) { + if (entity is Directory) { + _watchSubdir(entity.path); + } else { + _files.add(entity.path); + _emitEvent(ChangeType.ADD, entity.path); + } + }, onError: (Object error, StackTrace stackTrace) { + // Ignore an exception caused by the dir not existing. It's fine if it + // was added and then quickly removed. + if (error is FileSystemException) return; + + _emitError(error, stackTrace); + }, cancelOnError: true); + } + + /// Handles the underlying event stream closing, indicating that the directory + /// being watched was removed. + void _onDone() { + // Most of the time when a directory is removed, its contents will get + // individual REMOVE events before the watch stream is closed -- in that + // case, [_files] will be empty here. However, if the directory's removal is + // caused by a MOVE, we need to manually emit events. + if (isReady) { + for (var file in _files.paths) { + _emitEvent(ChangeType.REMOVE, file); + } + } + + close(); + } + + /// Emits a [WatchEvent] with [type] and [path] if this watcher is in a state + /// to emit events. + void _emitEvent(ChangeType type, String path) { + if (!isReady) return; + if (_eventsController.isClosed) return; + _eventsController.add(WatchEvent(type, path)); + } + + /// Emit an error, then close the watcher. + void _emitError(Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + close(); + } + + /// Like [Stream.listen], but automatically adds the subscription to + /// [_subscriptions] so that it can be canceled when [close] is called. + void _listen(Stream stream, void Function(T) onData, + {Function? onError, + void Function()? onDone, + bool cancelOnError = false}) { + late StreamSubscription subscription; + subscription = stream.listen(onData, onError: onError, onDone: () { + _subscriptions.remove(subscription); + onDone?.call(); + }, cancelOnError: cancelOnError); + _subscriptions.add(subscription); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/mac_os.dart b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart new file mode 100644 index 000000000..b46138347 --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/mac_os.dart @@ -0,0 +1,410 @@ +// Copyright (c) 2013, 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:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../directory_watcher.dart'; +import '../path_set.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Uses the FSEvents subsystem to watch for filesystem events. +/// +/// FSEvents has two main idiosyncrasies that this class works around. First, it +/// will occasionally report events that occurred before the filesystem watch +/// was initiated. Second, if multiple events happen to the same file in close +/// succession, it won't report them in the order they occurred. See issue +/// 14373. +/// +/// This also works around issues 16003 and 14849 in the implementation of +/// [Directory.watch]. +class MacOSDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + MacOSDirectoryWatcher(String directory) + : super(directory, () => _MacOSDirectoryWatcher(directory)); +} + +class _MacOSDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => path; + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// The set of files that are known to exist recursively within the watched + /// directory. + /// + /// The state of files on the filesystem is compared against this to determine + /// the real change that occurred when working around issue 14373. This is + /// also used to emit REMOVE events when subdirectories are moved out of the + /// watched directory. + final PathSet _files; + + /// The subscription to the stream returned by [Directory.watch]. + /// + /// This is separate from [_listSubscriptions] because this stream + /// occasionally needs to be resubscribed in order to work around issue 14849. + StreamSubscription>? _watchSubscription; + + /// The subscription to the [Directory.list] call for the initial listing of + /// the directory to determine its initial state. + StreamSubscription? _initialListSubscription; + + /// The subscriptions to [Directory.list] calls for listing the contents of a + /// subdirectory that was moved into the watched directory. + final _listSubscriptions = >{}; + + /// The timer for tracking how long we wait for an initial batch of bogus + /// events (see issue 14373). + late Timer _bogusEventTimer; + + _MacOSDirectoryWatcher(this.path) : _files = PathSet(path) { + _startWatch(); + + // Before we're ready to emit events, wait for [_listDir] to complete and + // for enough time to elapse that if bogus events (issue 14373) would be + // emitted, they will be. + // + // If we do receive a batch of events, [_onBatch] will ensure that these + // futures don't fire and that the directory is re-listed. + Future.wait([_listDir(), _waitForBogusEvents()]).then((_) { + if (!isReady) { + _readyCompleter.complete(); + } + }); + } + + @override + void close() { + _watchSubscription?.cancel(); + _initialListSubscription?.cancel(); + _watchSubscription = null; + _initialListSubscription = null; + + for (var subscription in _listSubscriptions) { + subscription.cancel(); + } + _listSubscriptions.clear(); + + _eventsController.close(); + } + + /// The callback that's run when [Directory.watch] emits a batch of events. + void _onBatch(List batch) { + // If we get a batch of events before we're ready to begin emitting events, + // it's probable that it's a batch of pre-watcher events (see issue 14373). + // Ignore those events and re-list the directory. + if (!isReady) { + // Cancel the timer because bogus events only occur in the first batch, so + // we can fire [ready] as soon as we're done listing the directory. + _bogusEventTimer.cancel(); + _listDir().then((_) { + if (!isReady) { + _readyCompleter.complete(); + } + }); + return; + } + + _sortEvents(batch).forEach((path, eventSet) { + var canonicalEvent = _canonicalEvent(eventSet); + var events = canonicalEvent == null + ? _eventsBasedOnFileSystem(path) + : [canonicalEvent]; + + for (var event in events) { + if (event is FileSystemCreateEvent) { + if (!event.isDirectory) { + // If we already know about the file, treat it like a modification. + // This can happen if a file is copied on top of an existing one. + // We'll see an ADD event for the latter file when from the user's + // perspective, the file's contents just changed. + var type = + _files.contains(path) ? ChangeType.MODIFY : ChangeType.ADD; + + _emitEvent(type, path); + _files.add(path); + continue; + } + + if (_files.containsDir(path)) continue; + + var stream = Directory(path).list(recursive: true); + var subscription = stream.listen((entity) { + if (entity is Directory) return; + if (_files.contains(path)) return; + + _emitEvent(ChangeType.ADD, entity.path); + _files.add(entity.path); + }, cancelOnError: true); + subscription.onDone(() { + _listSubscriptions.remove(subscription); + }); + subscription.onError(_emitError); + _listSubscriptions.add(subscription); + } else if (event is FileSystemModifyEvent) { + assert(!event.isDirectory); + _emitEvent(ChangeType.MODIFY, path); + } else { + assert(event is FileSystemDeleteEvent); + for (var removedPath in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, removedPath); + } + } + } + }); + } + + /// Sort all the events in a batch into sets based on their path. + /// + /// A single input event may result in multiple events in the returned map; + /// for example, a MOVE event becomes a DELETE event for the source and a + /// CREATE event for the destination. + /// + /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it + /// contain any events relating to [path]. + Map> _sortEvents(List batch) { + var eventsForPaths = >{}; + + // FSEvents can report past events, including events on the root directory + // such as it being created. We want to ignore these. If the directory is + // really deleted, that's handled by [_onDone]. + batch = batch.where((event) => event.path != path).toList(); + + // Events within directories that already have events are superfluous; the + // directory's full contents will be examined anyway, so we ignore such + // events. Emitting them could cause useless or out-of-order events. + var directories = unionAll(batch.map((event) { + if (!event.isDirectory) return {}; + if (event is FileSystemMoveEvent) { + var destination = event.destination; + if (destination != null) { + return {event.path, destination}; + } + } + return {event.path}; + })); + + bool isInModifiedDirectory(String path) => + directories.any((dir) => path != dir && p.isWithin(dir, path)); + + void addEvent(String path, FileSystemEvent event) { + if (isInModifiedDirectory(path)) return; + eventsForPaths.putIfAbsent(path, () => {}).add(event); + } + + for (var event in batch) { + // The Mac OS watcher doesn't emit move events. See issue 14806. + assert(event is! FileSystemMoveEvent); + addEvent(event.path, event); + } + + return eventsForPaths; + } + + /// Returns the canonical event from a batch of events on the same path, if + /// one exists. + /// + /// If [batch] doesn't contain any contradictory events (e.g. DELETE and + /// CREATE, or events with different values for `isDirectory`), this returns a + /// single event that describes what happened to the path in question. + /// + /// If [batch] does contain contradictory events, this returns `null` to + /// indicate that the state of the path on the filesystem should be checked to + /// determine what occurred. + FileSystemEvent? _canonicalEvent(Set batch) { + // An empty batch indicates that we've learned earlier that the batch is + // contradictory (e.g. because of a move). + if (batch.isEmpty) return null; + + var type = batch.first.type; + var isDir = batch.first.isDirectory; + var hadModifyEvent = false; + + for (var event in batch.skip(1)) { + // If one event reports that the file is a directory and another event + // doesn't, that's a contradiction. + if (isDir != event.isDirectory) return null; + + // Modify events don't contradict either CREATE or REMOVE events. We can + // safely assume the file was modified after a CREATE or before the + // REMOVE; otherwise there will also be a REMOVE or CREATE event + // (respectively) that will be contradictory. + if (event is FileSystemModifyEvent) { + hadModifyEvent = true; + continue; + } + assert(event is FileSystemCreateEvent || event is FileSystemDeleteEvent); + + // If we previously thought this was a MODIFY, we now consider it to be a + // CREATE or REMOVE event. This is safe for the same reason as above. + if (type == FileSystemEvent.modify) { + type = event.type; + continue; + } + + // A CREATE event contradicts a REMOVE event and vice versa. + assert(type == FileSystemEvent.create || type == FileSystemEvent.delete); + if (type != event.type) return null; + } + + // If we got a CREATE event for a file we already knew about, that comes + // from FSEvents reporting an add that happened prior to the watch + // beginning. If we also received a MODIFY event, we want to report that, + // but not the CREATE. + if (type == FileSystemEvent.create && + hadModifyEvent && + _files.contains(batch.first.path)) { + type = FileSystemEvent.modify; + } + + switch (type) { + case FileSystemEvent.create: + // Issue 16003 means that a CREATE event for a directory can indicate + // that the directory was moved and then re-created. + // [_eventsBasedOnFileSystem] will handle this correctly by producing a + // DELETE event followed by a CREATE event if the directory exists. + if (isDir) return null; + return FileSystemCreateEvent(batch.first.path, false); + case FileSystemEvent.delete: + return FileSystemDeleteEvent(batch.first.path, isDir); + case FileSystemEvent.modify: + return FileSystemModifyEvent(batch.first.path, isDir, false); + default: + throw StateError('unreachable'); + } + } + + /// Returns one or more events that describe the change between the last known + /// state of [path] and its current state on the filesystem. + /// + /// This returns a list whose order should be reflected in the events emitted + /// to the user, unlike the batched events from [Directory.watch]. The + /// returned list may be empty, indicating that no changes occurred to [path] + /// (probably indicating that it was created and then immediately deleted). + List _eventsBasedOnFileSystem(String path) { + var fileExisted = _files.contains(path); + var dirExisted = _files.containsDir(path); + var fileExists = File(path).existsSync(); + var dirExists = Directory(path).existsSync(); + + var events = []; + if (fileExisted) { + if (fileExists) { + events.add(FileSystemModifyEvent(path, false, false)); + } else { + events.add(FileSystemDeleteEvent(path, false)); + } + } else if (dirExisted) { + if (dirExists) { + // If we got contradictory events for a directory that used to exist and + // still exists, we need to rescan the whole thing in case it was + // replaced with a different directory. + events.add(FileSystemDeleteEvent(path, true)); + events.add(FileSystemCreateEvent(path, true)); + } else { + events.add(FileSystemDeleteEvent(path, true)); + } + } + + if (!fileExisted && fileExists) { + events.add(FileSystemCreateEvent(path, false)); + } else if (!dirExisted && dirExists) { + events.add(FileSystemCreateEvent(path, true)); + } + + return events; + } + + /// The callback that's run when the [Directory.watch] stream is closed. + void _onDone() { + _watchSubscription = null; + + // If the directory still exists and we're still expecting bogus events, + // this is probably issue 14849 rather than a real close event. We should + // just restart the watcher. + if (!isReady && Directory(path).existsSync()) { + _startWatch(); + return; + } + + // FSEvents can fail to report the contents of the directory being removed + // when the directory itself is removed, so we need to manually mark the + // files as removed. + for (var file in _files.paths) { + _emitEvent(ChangeType.REMOVE, file); + } + _files.clear(); + close(); + } + + /// Start or restart the underlying [Directory.watch] stream. + void _startWatch() { + // Batch the FSEvent changes together so that we can dedup events. + var innerStream = Directory(path).watch(recursive: true).batchEvents(); + _watchSubscription = innerStream.listen(_onBatch, + onError: _eventsController.addError, onDone: _onDone); + } + + /// Starts or restarts listing the watched directory to get an initial picture + /// of its state. + Future _listDir() { + assert(!isReady); + _initialListSubscription?.cancel(); + + _files.clear(); + var completer = Completer(); + var stream = Directory(path).list(recursive: true); + _initialListSubscription = stream.listen((entity) { + if (entity is! Directory) _files.add(entity.path); + }, onError: _emitError, onDone: completer.complete, cancelOnError: true); + return completer.future; + } + + /// Wait 200ms for a batch of bogus events (issue 14373) to come in. + /// + /// 200ms is short in terms of human interaction, but longer than any Mac OS + /// watcher tests take on the bots, so it should be safe to assume that any + /// bogus events will be signaled in that time frame. + Future _waitForBogusEvents() { + var completer = Completer(); + _bogusEventTimer = + Timer(const Duration(milliseconds: 200), completer.complete); + return completer.future; + } + + /// Emit an event with the given [type] and [path]. + void _emitEvent(ChangeType type, String path) { + if (!isReady) return; + _eventsController.add(WatchEvent(type, path)); + } + + /// Emit an error, then close the watcher. + void _emitError(Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + close(); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/polling.dart b/pkgs/watcher/lib/src/directory_watcher/polling.dart new file mode 100644 index 000000000..207679b1a --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/polling.dart @@ -0,0 +1,191 @@ +// Copyright (c) 2013, 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:async'; +import 'dart:io'; + +import '../async_queue.dart'; +import '../directory_watcher.dart'; +import '../resubscribable.dart'; +import '../stat.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Periodically polls a directory for changes. +class PollingDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + /// Creates a new polling watcher monitoring [directory]. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the directory contents. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. + PollingDirectoryWatcher(String directory, {Duration? pollingDelay}) + : super(directory, () { + return _PollingDirectoryWatcher( + directory, pollingDelay ?? const Duration(seconds: 1)); + }); +} + +class _PollingDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => path; + @override + final String path; + + @override + Stream get events => _events.stream; + final _events = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// The amount of time the watcher pauses between successive polls of the + /// 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 = {}; + + /// The subscription used while [directory] is being listed. + /// + /// Will be `null` if a list is not currently happening. + StreamSubscription? _listSubscription; + + /// The queue of files waiting to be processed to see if they have been + /// modified. + /// + /// Processing a file is asynchronous, as is listing the directory, so the + /// queue exists to let each of those proceed at their own rate. The lister + /// will enqueue files as quickly as it can. Meanwhile, files are dequeued + /// and processed sequentially. + late final AsyncQueue _filesToProcess = + AsyncQueue(_processFile, onError: (error, stackTrace) { + if (!_events.isClosed) _events.addError(error, stackTrace); + }); + + /// 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. + final _polledFiles = {}; + + _PollingDirectoryWatcher(this.path, this._pollingDelay) { + _poll(); + } + + @override + void close() { + _events.close(); + + // If we're in the middle of listing the directory, stop. + _listSubscription?.cancel(); + + // Don't process any remaining files. + _filesToProcess.clear(); + _polledFiles.clear(); + _lastModifieds.clear(); + } + + /// Scans the contents of the directory once to see which files have been + /// added, removed, and modified. + void _poll() { + _filesToProcess.clear(); + _polledFiles.clear(); + + void endListing() { + assert(!_events.isClosed); + _listSubscription = null; + + // Null tells the queue consumer that we're done listing. + _filesToProcess.add(null); + } + + var stream = Directory(path).list(recursive: true); + _listSubscription = stream.listen((entity) { + assert(!_events.isClosed); + + if (entity is! File) return; + _filesToProcess.add(entity.path); + }, onError: (Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + if (!isDirectoryNotFoundException(error)) { + // It's some unknown error. Pipe it over to the event stream so the + // user can see it. + _events.addError(error, stackTrace); + } + + // When an error occurs, we end the listing normally, which has the + // desired effect of marking all files that were in the directory as + // being removed. + endListing(); + }, onDone: endListing, cancelOnError: true); + } + + /// Processes [file] to determine if it has been modified since the last + /// time it was scanned. + Future _processFile(String? file) async { + // `null` is the sentinel which means the directory listing is complete. + if (file == null) { + await _completePoll(); + return; + } + + final modified = await modificationTime(file); + + if (_events.isClosed) return; + + var lastModified = _lastModifieds[file]; + + // If its modification time hasn't changed, assume the file is unchanged. + if (lastModified != null && lastModified == modified) { + // The file is still here. + _polledFiles.add(file); + return; + } + + if (_events.isClosed) return; + + _lastModifieds[file] = modified; + _polledFiles.add(file); + + // Only notify if we're ready to emit events. + if (!isReady) return; + + var type = lastModified == null ? ChangeType.ADD : ChangeType.MODIFY; + _events.add(WatchEvent(type, file)); + } + + /// After the directory listing is complete, this determines which files were + /// removed and then restarts the next poll. + Future _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); + for (var removed in removedFiles) { + if (isReady) _events.add(WatchEvent(ChangeType.REMOVE, removed)); + _lastModifieds.remove(removed); + } + + if (!isReady) _readyCompleter.complete(); + + // Wait and then poll again. + await Future.delayed(_pollingDelay); + if (_events.isClosed) return; + _poll(); + } +} diff --git a/pkgs/watcher/lib/src/directory_watcher/windows.dart b/pkgs/watcher/lib/src/directory_watcher/windows.dart new file mode 100644 index 000000000..d1c98be1f --- /dev/null +++ b/pkgs/watcher/lib/src/directory_watcher/windows.dart @@ -0,0 +1,437 @@ +// Copyright (c) 2014, 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. +// TODO(rnystrom): Merge with mac_os version. + +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../directory_watcher.dart'; +import '../path_set.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +class WindowsDirectoryWatcher extends ResubscribableWatcher + implements DirectoryWatcher { + @override + String get directory => path; + + WindowsDirectoryWatcher(String directory) + : super(directory, () => _WindowsDirectoryWatcher(directory)); +} + +class _EventBatcher { + static const Duration _batchDelay = Duration(milliseconds: 100); + final List events = []; + Timer? timer; + + void addEvent(FileSystemEvent event, void Function() callback) { + events.add(event); + timer?.cancel(); + timer = Timer(_batchDelay, callback); + } + + void cancelTimer() { + timer?.cancel(); + } +} + +class _WindowsDirectoryWatcher + implements DirectoryWatcher, ManuallyClosedWatcher { + @override + String get directory => path; + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + final Map _eventBatchers = + HashMap(); + + /// The set of files that are known to exist recursively within the watched + /// directory. + /// + /// The state of files on the filesystem is compared against this to determine + /// the real change that occurred. This is also used to emit REMOVE events + /// when subdirectories are moved out of the watched directory. + final PathSet _files; + + /// The subscription to the stream returned by [Directory.watch]. + StreamSubscription? _watchSubscription; + + /// The subscription to the stream returned by [Directory.watch] of the + /// parent directory to [directory]. This is needed to detect changes to + /// [directory], as they are not included on Windows. + StreamSubscription? _parentWatchSubscription; + + /// The subscription to the [Directory.list] call for the initial listing of + /// the directory to determine its initial state. + StreamSubscription? _initialListSubscription; + + /// The subscriptions to the [Directory.list] calls for listing the contents + /// of subdirectories that were moved into the watched directory. + final Set> _listSubscriptions = + HashSet>(); + + _WindowsDirectoryWatcher(this.path) : _files = PathSet(path) { + // Before we're ready to emit events, wait for [_listDir] to complete. + _listDir().then((_) { + _startWatch(); + _startParentWatcher(); + if (!isReady) { + _readyCompleter.complete(); + } + }); + } + + @override + void close() { + _watchSubscription?.cancel(); + _parentWatchSubscription?.cancel(); + _initialListSubscription?.cancel(); + for (var sub in _listSubscriptions) { + sub.cancel(); + } + _listSubscriptions.clear(); + for (var batcher in _eventBatchers.values) { + batcher.cancelTimer(); + } + _eventBatchers.clear(); + _watchSubscription = null; + _parentWatchSubscription = null; + _initialListSubscription = null; + _eventsController.close(); + } + + /// On Windows, if [directory] is deleted, we will not receive any event. + /// + /// Instead, we add a watcher on the parent folder (if any), that can notify + /// us about [path]. This also includes events such as moves. + void _startParentWatcher() { + var absoluteDir = p.absolute(path); + var parent = p.dirname(absoluteDir); + // Check if [path] is already the root directory. + if (FileSystemEntity.identicalSync(parent, path)) return; + var parentStream = Directory(parent).watch(recursive: false); + _parentWatchSubscription = parentStream.listen((event) { + // Only look at events for 'directory'. + if (p.basename(event.path) != p.basename(absoluteDir)) return; + // Test if the directory is removed. FileSystemEntity.typeSync will + // return NOT_FOUND if it's unable to decide upon the type, including + // access denied issues, which may happen when the directory is deleted. + // FileSystemMoveEvent and FileSystemDeleteEvent events will always mean + // the directory is now gone. + if (event is FileSystemMoveEvent || + event is FileSystemDeleteEvent || + (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound)) { + for (var path in _files.paths) { + _emitEvent(ChangeType.REMOVE, path); + } + _files.clear(); + close(); + } + }, onError: (error) { + // Ignore errors, simply close the stream. The user listens on + // [directory], and while it can fail to listen on the parent, we may + // still be able to listen on the path requested. + _parentWatchSubscription?.cancel(); + _parentWatchSubscription = null; + }); + } + + void _onEvent(FileSystemEvent event) { + assert(isReady); + final batcher = _eventBatchers.putIfAbsent(event.path, _EventBatcher.new); + batcher.addEvent(event, () { + _eventBatchers.remove(event.path); + _onBatch(batcher.events); + }); + } + + /// The callback that's run when [Directory.watch] emits a batch of events. + void _onBatch(List batch) { + _sortEvents(batch).forEach((path, eventSet) { + var canonicalEvent = _canonicalEvent(eventSet); + var events = canonicalEvent == null + ? _eventsBasedOnFileSystem(path) + : [canonicalEvent]; + + for (var event in events) { + if (event is FileSystemCreateEvent) { + if (!event.isDirectory) { + if (_files.contains(path)) continue; + + _emitEvent(ChangeType.ADD, path); + _files.add(path); + continue; + } + + if (_files.containsDir(path)) continue; + + var stream = Directory(path).list(recursive: true); + var subscription = stream.listen((entity) { + if (entity is Directory) return; + if (_files.contains(path)) return; + + _emitEvent(ChangeType.ADD, entity.path); + _files.add(entity.path); + }, cancelOnError: true); + subscription.onDone(() { + _listSubscriptions.remove(subscription); + }); + subscription.onError((Object e, StackTrace stackTrace) { + _listSubscriptions.remove(subscription); + _emitError(e, stackTrace); + }); + _listSubscriptions.add(subscription); + } else if (event is FileSystemModifyEvent) { + if (!event.isDirectory) { + _emitEvent(ChangeType.MODIFY, path); + } + } else { + assert(event is FileSystemDeleteEvent); + for (var removedPath in _files.remove(path)) { + _emitEvent(ChangeType.REMOVE, removedPath); + } + } + } + }); + } + + /// Sort all the events in a batch into sets based on their path. + /// + /// A single input event may result in multiple events in the returned map; + /// for example, a MOVE event becomes a DELETE event for the source and a + /// CREATE event for the destination. + /// + /// The returned events won't contain any [FileSystemMoveEvent]s, nor will it + /// contain any events relating to [path]. + Map> _sortEvents(List batch) { + var eventsForPaths = >{}; + + // Events within directories that already have events are superfluous; the + // directory's full contents will be examined anyway, so we ignore such + // events. Emitting them could cause useless or out-of-order events. + var directories = unionAll(batch.map((event) { + if (!event.isDirectory) return {}; + if (event is FileSystemMoveEvent) { + var destination = event.destination; + if (destination != null) { + return {event.path, destination}; + } + } + return {event.path}; + })); + + bool isInModifiedDirectory(String path) => + directories.any((dir) => path != dir && p.isWithin(dir, path)); + + void addEvent(String path, FileSystemEvent event) { + if (isInModifiedDirectory(path)) return; + eventsForPaths.putIfAbsent(path, () => {}).add(event); + } + + for (var event in batch) { + if (event is FileSystemMoveEvent) { + var destination = event.destination; + if (destination != null) { + addEvent(destination, event); + } + } + addEvent(event.path, event); + } + + return eventsForPaths; + } + + /// Returns the canonical event from a batch of events on the same path, if + /// one exists. + /// + /// If [batch] doesn't contain any contradictory events (e.g. DELETE and + /// CREATE, or events with different values for `isDirectory`), this returns a + /// single event that describes what happened to the path in question. + /// + /// If [batch] does contain contradictory events, this returns `null` to + /// indicate that the state of the path on the filesystem should be checked to + /// determine what occurred. + FileSystemEvent? _canonicalEvent(Set batch) { + // An empty batch indicates that we've learned earlier that the batch is + // contradictory (e.g. because of a move). + if (batch.isEmpty) return null; + + var type = batch.first.type; + var isDir = batch.first.isDirectory; + + for (var event in batch.skip(1)) { + // If one event reports that the file is a directory and another event + // doesn't, that's a contradiction. + if (isDir != event.isDirectory) return null; + + // Modify events don't contradict either CREATE or REMOVE events. We can + // safely assume the file was modified after a CREATE or before the + // REMOVE; otherwise there will also be a REMOVE or CREATE event + // (respectively) that will be contradictory. + if (event is FileSystemModifyEvent) continue; + assert(event is FileSystemCreateEvent || + event is FileSystemDeleteEvent || + event is FileSystemMoveEvent); + + // If we previously thought this was a MODIFY, we now consider it to be a + // CREATE or REMOVE event. This is safe for the same reason as above. + if (type == FileSystemEvent.modify) { + type = event.type; + continue; + } + + // A CREATE event contradicts a REMOVE event and vice versa. + assert(type == FileSystemEvent.create || + type == FileSystemEvent.delete || + type == FileSystemEvent.move); + if (type != event.type) return null; + } + + switch (type) { + case FileSystemEvent.create: + return FileSystemCreateEvent(batch.first.path, isDir); + case FileSystemEvent.delete: + return FileSystemDeleteEvent(batch.first.path, isDir); + case FileSystemEvent.modify: + return FileSystemModifyEvent(batch.first.path, isDir, false); + case FileSystemEvent.move: + return null; + default: + throw StateError('unreachable'); + } + } + + /// Returns zero or more events that describe the change between the last + /// known state of [path] and its current state on the filesystem. + /// + /// This returns a list whose order should be reflected in the events emitted + /// to the user, unlike the batched events from [Directory.watch]. The + /// returned list may be empty, indicating that no changes occurred to [path] + /// (probably indicating that it was created and then immediately deleted). + List _eventsBasedOnFileSystem(String path) { + var fileExisted = _files.contains(path); + var dirExisted = _files.containsDir(path); + + bool fileExists; + bool dirExists; + try { + fileExists = File(path).existsSync(); + dirExists = Directory(path).existsSync(); + } on FileSystemException { + return const []; + } + + var events = []; + if (fileExisted) { + if (fileExists) { + events.add(FileSystemModifyEvent(path, false, false)); + } else { + events.add(FileSystemDeleteEvent(path, false)); + } + } else if (dirExisted) { + if (dirExists) { + // If we got contradictory events for a directory that used to exist and + // still exists, we need to rescan the whole thing in case it was + // replaced with a different directory. + events.add(FileSystemDeleteEvent(path, true)); + events.add(FileSystemCreateEvent(path, true)); + } else { + events.add(FileSystemDeleteEvent(path, true)); + } + } + + if (!fileExisted && fileExists) { + events.add(FileSystemCreateEvent(path, false)); + } else if (!dirExisted && dirExists) { + events.add(FileSystemCreateEvent(path, true)); + } + + return events; + } + + /// The callback that's run when the [Directory.watch] stream is closed. + /// Note that this is unlikely to happen on Windows, unless the system itself + /// closes the handle. + void _onDone() { + _watchSubscription = null; + + // Emit remove events for any remaining files. + for (var file in _files.paths) { + _emitEvent(ChangeType.REMOVE, file); + } + _files.clear(); + close(); + } + + /// Start or restart the underlying [Directory.watch] stream. + void _startWatch() { + // Note: "watcher closed" exceptions do not get sent over the stream + // returned by watch, and must be caught via a zone handler. + runZonedGuarded(() { + var innerStream = Directory(path).watch(recursive: true); + _watchSubscription = innerStream.listen(_onEvent, + onError: _eventsController.addError, onDone: _onDone); + }, (error, stackTrace) { + if (error is FileSystemException && + error.message.startsWith('Directory watcher closed unexpectedly')) { + _watchSubscription?.cancel(); + _eventsController.addError(error, stackTrace); + _startWatch(); + } else { + // ignore: only_throw_errors + throw error; + } + }); + } + + /// Starts or restarts listing the watched directory to get an initial picture + /// of its state. + Future _listDir() { + assert(!isReady); + _initialListSubscription?.cancel(); + + _files.clear(); + var completer = Completer(); + var stream = Directory(path).list(recursive: true); + void handleEntity(FileSystemEntity entity) { + if (entity is! Directory) _files.add(entity.path); + } + + _initialListSubscription = stream.listen(handleEntity, + onError: _emitError, onDone: completer.complete, cancelOnError: true); + return completer.future; + } + + /// Emit an event with the given [type] and [path]. + void _emitEvent(ChangeType type, String path) { + if (!isReady) return; + + _eventsController.add(WatchEvent(type, path)); + } + + /// Emit an error, then close the watcher. + void _emitError(Object error, StackTrace stackTrace) { + // Guarantee that ready always completes. + if (!isReady) { + _readyCompleter.complete(); + } + _eventsController.addError(error, stackTrace); + close(); + } +} diff --git a/pkgs/watcher/lib/src/file_watcher.dart b/pkgs/watcher/lib/src/file_watcher.dart new file mode 100644 index 000000000..143aa3172 --- /dev/null +++ b/pkgs/watcher/lib/src/file_watcher.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2015, 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'; + +import '../watcher.dart'; +import 'custom_watcher_factory.dart'; +import 'file_watcher/native.dart'; + +/// Watches a file and emits [WatchEvent]s when the file has changed. +/// +/// Note that since each watcher only watches a single file, it will only emit +/// [ChangeType.MODIFY] events, except when the file is deleted at which point +/// it will emit a single [ChangeType.REMOVE] event and then close the stream. +/// +/// If the file is deleted and quickly replaced (when a new file is moved in its +/// place, for example) this will emit a [ChangeType.MODIFY] event. +abstract class FileWatcher implements Watcher { + /// Creates a new [FileWatcher] monitoring [file]. + /// + /// If a native file watcher is available for this platform, this will use it. + /// Otherwise, it will fall back to a [PollingFileWatcher]. Notably, native + /// file watching is *not* supported on Windows. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the directory contents. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. Ignored for non-polling + /// watchers. + factory FileWatcher(String file, {Duration? pollingDelay}) { + var customWatcher = + createCustomFileWatcher(file, pollingDelay: pollingDelay); + if (customWatcher != null) return customWatcher; + + // [File.watch] doesn't work on Windows, but + // [FileSystemEntity.isWatchSupported] is still true because directory + // watching does work. + if (FileSystemEntity.isWatchSupported && !Platform.isWindows) { + return NativeFileWatcher(file); + } + return PollingFileWatcher(file, pollingDelay: pollingDelay); + } +} diff --git a/pkgs/watcher/lib/src/file_watcher/native.dart b/pkgs/watcher/lib/src/file_watcher/native.dart new file mode 100644 index 000000000..502aa1095 --- /dev/null +++ b/pkgs/watcher/lib/src/file_watcher/native.dart @@ -0,0 +1,90 @@ +// Copyright (c) 2015, 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:async'; +import 'dart:io'; + +import '../file_watcher.dart'; +import '../resubscribable.dart'; +import '../utils.dart'; +import '../watch_event.dart'; + +/// Uses the native file system notifications to watch for filesystem events. +/// +/// Single-file notifications are much simpler than those for multiple files, so +/// this doesn't need to be split out into multiple OS-specific classes. +class NativeFileWatcher extends ResubscribableWatcher implements FileWatcher { + NativeFileWatcher(String path) : super(path, () => _NativeFileWatcher(path)); +} + +class _NativeFileWatcher implements FileWatcher, ManuallyClosedWatcher { + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + StreamSubscription>? _subscription; + + _NativeFileWatcher(this.path) { + _listen(); + + // We don't need to do any initial set-up, so we're ready immediately after + // being listened to. + _readyCompleter.complete(); + } + + void _listen() { + // Batch the events together so that we can dedup them. + _subscription = File(path) + .watch() + .batchEvents() + .listen(_onBatch, onError: _eventsController.addError, onDone: _onDone); + } + + void _onBatch(List batch) { + if (batch.any((event) => event.type == FileSystemEvent.delete)) { + // If the file is deleted, the underlying stream will close. We handle + // emitting our own REMOVE event in [_onDone]. + return; + } + + _eventsController.add(WatchEvent(ChangeType.MODIFY, path)); + } + + void _onDone() async { + var fileExists = await File(path).exists(); + + // Check for this after checking whether the file exists because it's + // possible that [close] was called between [File.exists] being called and + // it completing. + if (_eventsController.isClosed) return; + + if (fileExists) { + // If the file exists now, it was probably removed and quickly replaced; + // this can happen for example when another file is moved on top of it. + // Re-subscribe and report a modify event. + _eventsController.add(WatchEvent(ChangeType.MODIFY, path)); + _listen(); + } else { + _eventsController.add(WatchEvent(ChangeType.REMOVE, path)); + close(); + } + } + + @override + void close() { + _subscription?.cancel(); + _subscription = null; + _eventsController.close(); + } +} diff --git a/pkgs/watcher/lib/src/file_watcher/polling.dart b/pkgs/watcher/lib/src/file_watcher/polling.dart new file mode 100644 index 000000000..15ff9ab8e --- /dev/null +++ b/pkgs/watcher/lib/src/file_watcher/polling.dart @@ -0,0 +1,106 @@ +// Copyright (c) 2015, 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:async'; +import 'dart:io'; + +import '../file_watcher.dart'; +import '../resubscribable.dart'; +import '../stat.dart'; +import '../watch_event.dart'; + +/// Periodically polls a file for changes. +class PollingFileWatcher extends ResubscribableWatcher implements FileWatcher { + PollingFileWatcher(String path, {Duration? pollingDelay}) + : super(path, () { + return _PollingFileWatcher( + path, pollingDelay ?? const Duration(seconds: 1)); + }); +} + +class _PollingFileWatcher implements FileWatcher, ManuallyClosedWatcher { + @override + final String path; + + @override + Stream get events => _eventsController.stream; + final _eventsController = StreamController.broadcast(); + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + final _readyCompleter = Completer(); + + /// 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; + + _PollingFileWatcher(this.path, Duration pollingDelay) { + _timer = Timer.periodic(pollingDelay, (_) => _poll()); + _poll(); + } + + /// Checks the mtime of the file and whether it's been removed. + Future _poll() async { + // We don't mark the file as removed if this is the first poll. Instead, + // below we forward the dart:io error that comes from trying to read the + // mtime below. + var pathExists = await File(path).exists(); + if (_eventsController.isClosed) return; + + if (_lastModified != null && !pathExists) { + _flagReady(); + _eventsController.add(WatchEvent(ChangeType.REMOVE, path)); + unawaited(close()); + return; + } + + DateTime? modified; + try { + modified = await modificationTime(path); + } on FileSystemException catch (error, stackTrace) { + if (!_eventsController.isClosed) { + _flagReady(); + _eventsController.addError(error, stackTrace); + await close(); + } + } + 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; + _flagReady(); + return; + } + + if (_lastModified == modified) return; + + _lastModified = modified; + _eventsController.add(WatchEvent(ChangeType.MODIFY, path)); + } + + /// Flags this watcher as ready if it has not already been done. + void _flagReady() { + if (!isReady) { + _readyCompleter.complete(); + } + } + + @override + Future close() async { + _timer.cancel(); + await _eventsController.close(); + } +} diff --git a/pkgs/watcher/lib/src/path_set.dart b/pkgs/watcher/lib/src/path_set.dart new file mode 100644 index 000000000..4f41cf924 --- /dev/null +++ b/pkgs/watcher/lib/src/path_set.dart @@ -0,0 +1,190 @@ +// Copyright (c) 2013, 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:collection'; + +import 'package:path/path.dart' as p; + +/// A set of paths, organized into a directory hierarchy. +/// +/// When a path is [add]ed, it creates an implicit directory structure above +/// that path. Directories can be inspected using [containsDir] and removed +/// using [remove]. If they're removed, their contents are removed as well. +/// +/// The paths in the set are normalized so that they all begin with [root]. +class PathSet { + /// The root path, which all paths in the set must be under. + final String root; + + /// The path set's directory hierarchy. + /// + /// Each entry represents a directory or file. It may be a file or directory + /// that was explicitly added, or a parent directory that was implicitly + /// added in order to add a child. + final _Entry _entries = _Entry(); + + PathSet(this.root); + + /// Adds [path] to the set. + void add(String path) { + path = _normalize(path); + + var parts = p.split(path); + var entry = _entries; + for (var part in parts) { + entry = entry.contents.putIfAbsent(part, _Entry.new); + } + + entry.isExplicit = true; + } + + /// Removes [path] and any paths beneath it from the set and returns the + /// removed paths. + /// + /// Even if [path] itself isn't in the set, if it's a directory containing + /// paths that are in the set those paths will be removed and returned. + /// + /// If neither [path] nor any paths beneath it are in the set, returns an + /// empty set. + Set remove(String path) { + path = _normalize(path); + var parts = Queue.of(p.split(path)); + + // Remove the children of [dir], as well as [dir] itself if necessary. + // + // [partialPath] is the path to [dir], and a prefix of [path]; the remaining + // components of [path] are in [parts]. + Set recurse(_Entry dir, String partialPath) { + if (parts.length > 1) { + // If there's more than one component left in [path], recurse down to + // the next level. + var part = parts.removeFirst(); + var entry = dir.contents[part]; + if (entry == null || entry.contents.isEmpty) return {}; + + partialPath = p.join(partialPath, part); + var paths = recurse(entry, partialPath); + // After removing this entry's children, if it has no more children and + // it's not in the set in its own right, remove it as well. + if (entry.contents.isEmpty && !entry.isExplicit) { + dir.contents.remove(part); + } + return paths; + } + + // If there's only one component left in [path], we should remove it. + var entry = dir.contents.remove(parts.first); + if (entry == null) return {}; + + if (entry.contents.isEmpty) { + return {p.join(root, path)}; + } + + var set = _explicitPathsWithin(entry, path); + if (entry.isExplicit) { + set.add(p.join(root, path)); + } + + return set; + } + + return recurse(_entries, root); + } + + /// Recursively lists all of the explicit paths within [dir]. + /// + /// [dirPath] should be the path to [dir]. + Set _explicitPathsWithin(_Entry dir, String dirPath) { + var paths = {}; + void recurse(_Entry dir, String path) { + dir.contents.forEach((name, entry) { + var entryPath = p.join(path, name); + if (entry.isExplicit) paths.add(p.join(root, entryPath)); + + recurse(entry, entryPath); + }); + } + + recurse(dir, dirPath); + return paths; + } + + /// Returns whether this set contains [path]. + /// + /// This only returns true for paths explicitly added to this set. + /// Implicitly-added directories can be inspected using [containsDir]. + bool contains(String path) { + path = _normalize(path); + var entry = _entries; + + for (var part in p.split(path)) { + var child = entry.contents[part]; + if (child == null) return false; + entry = child; + } + + return entry.isExplicit; + } + + /// Returns whether this set contains paths beneath [path]. + bool containsDir(String path) { + path = _normalize(path); + var entry = _entries; + + for (var part in p.split(path)) { + var child = entry.contents[part]; + if (child == null) return false; + entry = child; + } + + return entry.contents.isNotEmpty; + } + + /// All of the paths explicitly added to this set. + List get paths { + var result = []; + + void recurse(_Entry dir, String path) { + for (var mapEntry in dir.contents.entries) { + var entry = mapEntry.value; + var entryPath = p.join(path, mapEntry.key); + if (entry.isExplicit) result.add(entryPath); + recurse(entry, entryPath); + } + } + + recurse(_entries, root); + return result; + } + + /// Removes all paths from this set. + void clear() { + _entries.contents.clear(); + } + + /// Returns a normalized version of [path]. + /// + /// This removes any extra ".." or "."s and ensure that the returned path + /// begins with [root]. It's an error if [path] isn't within [root]. + String _normalize(String path) { + assert(p.isWithin(root, path)); + + return p.relative(p.normalize(path), from: root); + } +} + +/// A virtual file system entity tracked by the [PathSet]. +/// +/// It may have child entries in [contents], which implies it's a directory. +class _Entry { + /// The child entries contained in this directory. + final Map contents = {}; + + /// If this entry was explicitly added as a leaf file system entity, this + /// will be true. + /// + /// Otherwise, it represents a parent directory that was implicitly added + /// when added some child of it. + bool isExplicit = false; +} diff --git a/pkgs/watcher/lib/src/resubscribable.dart b/pkgs/watcher/lib/src/resubscribable.dart new file mode 100644 index 000000000..b99e9d7b4 --- /dev/null +++ b/pkgs/watcher/lib/src/resubscribable.dart @@ -0,0 +1,79 @@ +// Copyright (c) 2013, 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:async'; + +import '../watcher.dart'; + +/// A wrapper for [ManuallyClosedWatcher] that encapsulates support for closing +/// the watcher when it has no subscribers and re-opening it when it's +/// re-subscribed. +/// +/// It's simpler to implement watchers without worrying about this behavior. +/// This class wraps a watcher class which can be written with the simplifying +/// assumption that it can continue emitting events until an explicit `close` +/// method is called, at which point it will cease emitting events entirely. The +/// [ManuallyClosedWatcher] interface is used for these watchers. +/// +/// This would be more cleanly implemented as a function that takes a class and +/// emits a new class, but Dart doesn't support that sort of thing. Instead it +/// takes a factory function that produces instances of the inner class. +abstract class ResubscribableWatcher implements Watcher { + /// The factory function that produces instances of the inner class. + final ManuallyClosedWatcher Function() _factory; + + @override + final String path; + + @override + Stream get events => _eventsController.stream; + late StreamController _eventsController; + + @override + bool get isReady => _readyCompleter.isCompleted; + + @override + Future get ready => _readyCompleter.future; + var _readyCompleter = Completer(); + + /// Creates a new [ResubscribableWatcher] wrapping the watchers + /// emitted by [_factory]. + ResubscribableWatcher(this.path, this._factory) { + late ManuallyClosedWatcher watcher; + late StreamSubscription subscription; + + _eventsController = StreamController.broadcast( + onListen: () async { + watcher = _factory(); + subscription = watcher.events.listen(_eventsController.add, + onError: _eventsController.addError, + onDone: _eventsController.close); + + // It's important that we complete the value of [_readyCompleter] at + // the time [onListen] is called, as opposed to the value when + // [watcher.ready] fires. A new completer may be created by that time. + await watcher.ready; + _readyCompleter.complete(); + }, + onCancel: () { + // Cancel the subscription before closing the watcher so that the + // watcher's `onDone` event doesn't close [events]. + subscription.cancel(); + watcher.close(); + _readyCompleter = Completer(); + }, + sync: true); + } +} + +/// An interface for watchers with an explicit, manual [close] method. +/// +/// See [ResubscribableWatcher]. +abstract class ManuallyClosedWatcher implements Watcher { + /// Closes the watcher. + /// + /// Subclasses should close their [events] stream and release any internal + /// resources. + void close(); +} diff --git a/pkgs/watcher/lib/src/stat.dart b/pkgs/watcher/lib/src/stat.dart new file mode 100644 index 000000000..fe0f15578 --- /dev/null +++ b/pkgs/watcher/lib/src/stat.dart @@ -0,0 +1,34 @@ +// Copyright (c) 2013, 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'; + +/// A function that takes a file path and returns the last modified time for +/// the file at that path. +typedef MockTimeCallback = DateTime? Function(String path); + +MockTimeCallback? _mockTimeCallback; + +/// Overrides the default behavior for accessing a file's modification time +/// with [callback]. +/// +/// The OS file modification time has pretty rough granularity (like a few +/// seconds) which can make for slow tests that rely on modtime. This lets you +/// replace it with something you control. +void mockGetModificationTime(MockTimeCallback callback) { + _mockTimeCallback = callback; +} + +/// Gets the modification time for the file at [path]. +/// Completes with `null` if the file does not exist. +Future modificationTime(String path) async { + var mockTimeCallback = _mockTimeCallback; + if (mockTimeCallback != null) { + return mockTimeCallback(path); + } + + final stat = await FileStat.stat(path); + if (stat.type == FileSystemEntityType.notFound) return null; + return stat.modified; +} diff --git a/pkgs/watcher/lib/src/utils.dart b/pkgs/watcher/lib/src/utils.dart new file mode 100644 index 000000000..c2e71b3c1 --- /dev/null +++ b/pkgs/watcher/lib/src/utils.dart @@ -0,0 +1,52 @@ +// Copyright (c) 2013, 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:async'; +import 'dart:collection'; +import 'dart:io'; + +/// Returns `true` if [error] is a [FileSystemException] for a missing +/// directory. +bool isDirectoryNotFoundException(Object error) { + if (error is! FileSystemException) return false; + + // See dartbug.com/12461 and tests/standalone/io/directory_error_test.dart. + var notFoundCode = Platform.operatingSystem == 'windows' ? 3 : 2; + return error.osError?.errorCode == notFoundCode; +} + +/// Returns the union of all elements in each set in [sets]. +Set unionAll(Iterable> sets) => + sets.fold({}, (union, set) => union.union(set)); + +extension BatchEvents on Stream { + /// Batches all events that are sent at the same time. + /// + /// When multiple events are synchronously added to a stream controller, the + /// [StreamController] implementation uses [scheduleMicrotask] to schedule the + /// asynchronous firing of each event. In order to recreate the synchronous + /// batches, this collates all the events that are received in "nearby" + /// microtasks. + Stream> batchEvents() { + var batch = Queue(); + return StreamTransformer>.fromHandlers( + handleData: (event, sink) { + batch.add(event); + + // [Timer.run] schedules an event that runs after any microtasks that have + // been scheduled. + Timer.run(() { + if (batch.isEmpty) return; + sink.add(batch.toList()); + batch.clear(); + }); + }, handleDone: (sink) { + if (batch.isNotEmpty) { + sink.add(batch.toList()); + batch.clear(); + } + sink.close(); + }).bind(this); + } +} diff --git a/pkgs/watcher/lib/src/watch_event.dart b/pkgs/watcher/lib/src/watch_event.dart new file mode 100644 index 000000000..b65afc2bd --- /dev/null +++ b/pkgs/watcher/lib/src/watch_event.dart @@ -0,0 +1,38 @@ +// Copyright (c) 2013, 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. + +/// An event describing a single change to the file system. +class WatchEvent { + /// The manner in which the file at [path] has changed. + final ChangeType type; + + /// The path of the file that changed. + final String path; + + WatchEvent(this.type, this.path); + + @override + String toString() => '$type $path'; +} + +/// Enum for what kind of change has happened to a file. +class ChangeType { + /// A new file has been added. + // ignore: constant_identifier_names + static const ADD = ChangeType('add'); + + /// A file has been removed. + // ignore: constant_identifier_names + static const REMOVE = ChangeType('remove'); + + /// The contents of a file have changed. + // ignore: constant_identifier_names + static const MODIFY = ChangeType('modify'); + + final String _name; + const ChangeType(this._name); + + @override + String toString() => _name; +} diff --git a/pkgs/watcher/lib/watcher.dart b/pkgs/watcher/lib/watcher.dart new file mode 100644 index 000000000..12a536961 --- /dev/null +++ b/pkgs/watcher/lib/watcher.dart @@ -0,0 +1,70 @@ +// Copyright (c) 2013, 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'; + +import 'src/directory_watcher.dart'; +import 'src/file_watcher.dart'; +import 'src/watch_event.dart'; + +export 'src/custom_watcher_factory.dart' show registerCustomWatcher; +export 'src/directory_watcher.dart'; +export 'src/directory_watcher/polling.dart'; +export 'src/file_watcher.dart'; +export 'src/file_watcher/polling.dart'; +export 'src/watch_event.dart'; + +abstract class Watcher { + /// The path to the file or directory whose contents are being monitored. + String get path; + + /// The broadcast [Stream] of events that have occurred to the watched file or + /// files in the watched directory. + /// + /// Changes will only be monitored while this stream has subscribers. Any + /// changes that occur during periods when there are no subscribers will not + /// be reported the next time a subscriber is added. + Stream get events; + + /// Whether the watcher is initialized and watching for changes. + /// + /// This is true if and only if [ready] is complete. + bool get isReady; + + /// A [Future] that completes when the watcher is initialized and watching for + /// changes. + /// + /// If the watcher is not currently monitoring the file or directory (because + /// there are no subscribers to [events]), this returns a future that isn't + /// complete yet. It will complete when a subscriber starts listening and the + /// watcher finishes any initialization work it needs to do. + /// + /// If the watcher is already monitoring, this returns an already complete + /// future. + /// + /// This future always completes successfully as errors are provided through + /// the [events] stream. + Future get ready; + + /// Creates a new [DirectoryWatcher] or [FileWatcher] monitoring [path], + /// depending on whether it's a file or directory. + /// + /// If a native watcher is available for this platform, this will use it. + /// Otherwise, it will fall back to a polling watcher. Notably, watching + /// individual files is not natively supported on Windows, although watching + /// directories is. + /// + /// If [pollingDelay] is passed, it specifies the amount of time the watcher + /// will pause between successive polls of the contents of [path]. Making this + /// shorter will give more immediate feedback at the expense of doing more IO + /// and higher CPU usage. Defaults to one second. Ignored for non-polling + /// watchers. + factory Watcher(String path, {Duration? pollingDelay}) { + if (File(path).existsSync()) { + return FileWatcher(path, pollingDelay: pollingDelay); + } else { + return DirectoryWatcher(path, pollingDelay: pollingDelay); + } + } +} diff --git a/pkgs/watcher/pubspec.yaml b/pkgs/watcher/pubspec.yaml new file mode 100644 index 000000000..7781bd445 --- /dev/null +++ b/pkgs/watcher/pubspec.yaml @@ -0,0 +1,19 @@ +name: watcher +version: 1.1.1 +description: >- + A file system watcher. It monitors changes to contents of directories and + sends notifications when files have been added, removed, or modified. +repository: https://github.com/dart-lang/tools/tree/main/pkgs/watcher + +environment: + sdk: ^3.1.0 + +dependencies: + async: ^2.5.0 + path: ^1.8.0 + +dev_dependencies: + benchmark_harness: ^2.0.0 + dart_flutter_team_lints: ^3.0.0 + test: ^1.16.6 + test_descriptor: ^2.0.0 diff --git a/pkgs/watcher/test/custom_watcher_factory_test.dart b/pkgs/watcher/test/custom_watcher_factory_test.dart new file mode 100644 index 000000000..e9d65bb55 --- /dev/null +++ b/pkgs/watcher/test/custom_watcher_factory_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +void main() { + late _MemFs memFs; + final memFsFactoryId = 'MemFs'; + final noOpFactoryId = 'NoOp'; + + setUpAll(() { + memFs = _MemFs(); + var memFsWatcherFactory = _MemFsWatcherFactory(memFs); + var noOpWatcherFactory = _NoOpWatcherFactory(); + registerCustomWatcher( + noOpFactoryId, + noOpWatcherFactory.createDirectoryWatcher, + noOpWatcherFactory.createFileWatcher); + registerCustomWatcher( + memFsFactoryId, + memFsWatcherFactory.createDirectoryWatcher, + memFsWatcherFactory.createFileWatcher); + }); + + test('notifies for files', () async { + var watcher = FileWatcher('file.txt'); + + var completer = Completer(); + watcher.events.listen((event) => completer.complete(event)); + await watcher.ready; + memFs.add('file.txt'); + var event = await completer.future; + + expect(event.type, ChangeType.ADD); + expect(event.path, 'file.txt'); + }); + + test('notifies for directories', () async { + var watcher = DirectoryWatcher('dir'); + + var completer = Completer(); + watcher.events.listen((event) => completer.complete(event)); + await watcher.ready; + memFs.add('dir'); + var event = await completer.future; + + expect(event.type, ChangeType.ADD); + expect(event.path, 'dir'); + }); + + test('registering twice throws', () async { + expect( + () => registerCustomWatcher( + memFsFactoryId, + (_, {pollingDelay}) => throw UnimplementedError(), + (_, {pollingDelay}) => throw UnimplementedError()), + throwsA(isA()), + ); + }); + + test('finding two applicable factories throws', () async { + // Note that _MemFsWatcherFactory always returns a watcher, so having two + // will always produce a conflict. + var watcherFactory = _MemFsWatcherFactory(memFs); + registerCustomWatcher('Different id', watcherFactory.createDirectoryWatcher, + watcherFactory.createFileWatcher); + expect(() => FileWatcher('file.txt'), throwsA(isA())); + expect(() => DirectoryWatcher('dir'), throwsA(isA())); + }); +} + +class _MemFs { + final _streams = >>{}; + + StreamController watchStream(String path) { + var controller = StreamController(); + _streams + .putIfAbsent(path, () => >{}) + .add(controller); + return controller; + } + + void add(String path) { + var controllers = _streams[path]; + if (controllers != null) { + for (var controller in controllers) { + controller.add(WatchEvent(ChangeType.ADD, path)); + } + } + } + + void remove(String path) { + var controllers = _streams[path]; + if (controllers != null) { + for (var controller in controllers) { + controller.add(WatchEvent(ChangeType.REMOVE, path)); + } + } + } +} + +class _MemFsWatcher implements FileWatcher, DirectoryWatcher, Watcher { + final String _path; + final StreamController _controller; + + _MemFsWatcher(this._path, this._controller); + + @override + String get path => _path; + + @override + String get directory => throw UnsupportedError('directory is not supported'); + + @override + Stream get events => _controller.stream; + + @override + bool get isReady => true; + + @override + Future get ready async {} +} + +class _MemFsWatcherFactory { + final _MemFs _memFs; + _MemFsWatcherFactory(this._memFs); + + DirectoryWatcher? createDirectoryWatcher(String path, + {Duration? pollingDelay}) => + _MemFsWatcher(path, _memFs.watchStream(path)); + + FileWatcher? createFileWatcher(String path, {Duration? pollingDelay}) => + _MemFsWatcher(path, _memFs.watchStream(path)); +} + +class _NoOpWatcherFactory { + DirectoryWatcher? createDirectoryWatcher(String path, + {Duration? pollingDelay}) => + null; + + FileWatcher? createFileWatcher(String path, {Duration? pollingDelay}) => null; +} diff --git a/pkgs/watcher/test/directory_watcher/linux_test.dart b/pkgs/watcher/test/directory_watcher/linux_test.dart new file mode 100644 index 000000000..a10a72c33 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/linux_test.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2013, 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. + +@TestOn('linux') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/linux.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = LinuxDirectoryWatcher.new; + + sharedTests(); + + test('DirectoryWatcher creates a LinuxDirectoryWatcher on Linux', () { + expect(DirectoryWatcher('.'), const TypeMatcher()); + }); + + test('emits events for many nested files moved out then immediately back in', + () async { + withPermutations( + (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); + await startWatcher(path: 'dir'); + + renameDir('dir/sub', 'sub'); + renameDir('sub', 'dir/sub'); + + await allowEither(() { + inAnyOrder(withPermutations( + (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + + inAnyOrder(withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }, () { + inAnyOrder(withPermutations( + (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/mac_os_test.dart b/pkgs/watcher/test/directory_watcher/mac_os_test.dart new file mode 100644 index 000000000..337662646 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/mac_os_test.dart @@ -0,0 +1,69 @@ +// Copyright (c) 2013, 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. + +@TestOn('mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/mac_os.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = MacOSDirectoryWatcher.new; + + sharedTests(); + + test('DirectoryWatcher creates a MacOSDirectoryWatcher on Mac OS', () { + expect(DirectoryWatcher('.'), const TypeMatcher()); + }); + + test( + 'does not notify about the watched directory being deleted and ' + 'recreated immediately before watching', () async { + createDir('dir'); + writeFile('dir/old.txt'); + deleteDir('dir'); + createDir('dir'); + + await startWatcher(path: 'dir'); + writeFile('dir/newer.txt'); + await expectAddEvent('dir/newer.txt'); + }); + + test('emits events for many nested files moved out then immediately back in', + () async { + withPermutations( + (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); + + await startWatcher(path: 'dir'); + + renameDir('dir/sub', 'sub'); + renameDir('sub', 'dir/sub'); + + await allowEither(() { + inAnyOrder(withPermutations( + (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + + inAnyOrder(withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }, () { + inAnyOrder(withPermutations( + (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + }); + test('does not suppress files with the same prefix as a directory', () async { + // Regression test for https://github.com/dart-lang/watcher/issues/83 + writeFile('some_name.txt'); + + await startWatcher(); + + writeFile('some_name/some_name.txt'); + deleteFile('some_name.txt'); + + await expectRemoveEvent('some_name.txt'); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/polling_test.dart b/pkgs/watcher/test/directory_watcher/polling_test.dart new file mode 100644 index 000000000..f4ec8f48a --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/polling_test.dart @@ -0,0 +1,26 @@ +// Copyright (c) 2013, 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 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + // Use a short delay to make the tests run quickly. + watcherFactory = (dir) => PollingDirectoryWatcher(dir, + pollingDelay: const Duration(milliseconds: 100)); + + sharedTests(); + + 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'); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/shared.dart b/pkgs/watcher/test/directory_watcher/shared.dart new file mode 100644 index 000000000..1ebc78d4b --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/shared.dart @@ -0,0 +1,344 @@ +// Copyright (c) 2012, 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 'package:test/test.dart'; +import 'package:watcher/src/utils.dart'; + +import '../utils.dart'; + +void sharedTests() { + test('does not notify for files that already exist when started', () async { + // Make some pre-existing files. + writeFile('a.txt'); + writeFile('b.txt'); + + await startWatcher(); + + // Change one after the watcher is running. + writeFile('b.txt', contents: 'modified'); + + // We should get a modify event for the changed file, but no add events + // for them before this. + await expectModifyEvent('b.txt'); + }); + + test('notifies when a file is added', () async { + await startWatcher(); + writeFile('file.txt'); + await expectAddEvent('file.txt'); + }); + + test('notifies when a file is modified', () async { + writeFile('file.txt'); + await startWatcher(); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + }); + + test('notifies when a file is removed', () async { + writeFile('file.txt'); + await startWatcher(); + deleteFile('file.txt'); + await expectRemoveEvent('file.txt'); + }); + + test('notifies when a file is modified multiple times', () async { + writeFile('file.txt'); + await startWatcher(); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + writeFile('file.txt', contents: 'modified again'); + await expectModifyEvent('file.txt'); + }); + + test('notifies even if the file contents are unchanged', () async { + writeFile('a.txt', contents: 'same'); + writeFile('b.txt', contents: 'before'); + await startWatcher(); + + writeFile('a.txt', contents: 'same'); + writeFile('b.txt', contents: 'after'); + await inAnyOrder([isModifyEvent('a.txt'), isModifyEvent('b.txt')]); + }); + + test('when the watched directory is deleted, removes all files', () async { + writeFile('dir/a.txt'); + writeFile('dir/b.txt'); + + await startWatcher(path: 'dir'); + + deleteDir('dir'); + await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); + }); + + test('when the watched directory is moved, removes all files', () async { + writeFile('dir/a.txt'); + writeFile('dir/b.txt'); + + await startWatcher(path: 'dir'); + + renameDir('dir', 'moved_dir'); + createDir('dir'); + await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); + }); + + // Regression test for b/30768513. + test( + "doesn't crash when the directory is moved immediately after a subdir " + 'is added', () async { + writeFile('dir/a.txt'); + writeFile('dir/b.txt'); + + await startWatcher(path: 'dir'); + + createDir('dir/subdir'); + renameDir('dir', 'moved_dir'); + createDir('dir'); + await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]); + }); + + group('moves', () { + test('notifies when a file is moved within the watched directory', + () async { + writeFile('old.txt'); + await startWatcher(); + renameFile('old.txt', 'new.txt'); + + await inAnyOrder([isAddEvent('new.txt'), isRemoveEvent('old.txt')]); + }); + + test('notifies when a file is moved from outside the watched directory', + () async { + writeFile('old.txt'); + createDir('dir'); + await startWatcher(path: 'dir'); + + renameFile('old.txt', 'dir/new.txt'); + await expectAddEvent('dir/new.txt'); + }); + + test('notifies when a file is moved outside the watched directory', + () async { + writeFile('dir/old.txt'); + await startWatcher(path: 'dir'); + + renameFile('dir/old.txt', 'new.txt'); + await expectRemoveEvent('dir/old.txt'); + }); + + test('notifies when a file is moved onto an existing one', () async { + writeFile('from.txt'); + writeFile('to.txt'); + await startWatcher(); + + renameFile('from.txt', 'to.txt'); + await inAnyOrder([isRemoveEvent('from.txt'), isModifyEvent('to.txt')]); + }, onPlatform: { + 'windows': const Skip('https://github.com/dart-lang/watcher/issues/125') + }); + }); + + // Most of the time, when multiple filesystem actions happen in sequence, + // they'll be batched together and the watcher will see them all at once. + // These tests verify that the watcher normalizes and combine these events + // properly. However, very occasionally the events will be reported in + // separate batches, and the watcher will report them as though they occurred + // far apart in time, so each of these tests has a "backup case" to allow for + // that as well. + group('clustered changes', () { + test("doesn't notify when a file is created and then immediately removed", + () async { + writeFile('test.txt'); + await startWatcher(); + writeFile('file.txt'); + deleteFile('file.txt'); + + // Backup case. + startClosingEventStream(); + await allowEvents(() { + expectAddEvent('file.txt'); + expectRemoveEvent('file.txt'); + }); + }); + + test( + 'reports a modification when a file is deleted and then immediately ' + 'recreated', () async { + writeFile('file.txt'); + await startWatcher(); + + deleteFile('file.txt'); + writeFile('file.txt', contents: 're-created'); + + await allowEither(() { + expectModifyEvent('file.txt'); + }, () { + // Backup case. + expectRemoveEvent('file.txt'); + expectAddEvent('file.txt'); + }); + }); + + test( + 'reports a modification when a file is moved and then immediately ' + 'recreated', () async { + writeFile('old.txt'); + await startWatcher(); + + renameFile('old.txt', 'new.txt'); + writeFile('old.txt', contents: 're-created'); + + await allowEither(() { + inAnyOrder([isModifyEvent('old.txt'), isAddEvent('new.txt')]); + }, () { + // Backup case. + expectRemoveEvent('old.txt'); + expectAddEvent('new.txt'); + expectAddEvent('old.txt'); + }); + }); + + test( + 'reports a removal when a file is modified and then immediately ' + 'removed', () async { + writeFile('file.txt'); + await startWatcher(); + + writeFile('file.txt', contents: 'modified'); + deleteFile('file.txt'); + + // Backup case. + await allowModifyEvent('file.txt'); + + await expectRemoveEvent('file.txt'); + }); + + test('reports an add when a file is added and then immediately modified', + () async { + await startWatcher(); + + writeFile('file.txt'); + writeFile('file.txt', contents: 'modified'); + + await expectAddEvent('file.txt'); + + // Backup case. + startClosingEventStream(); + await allowModifyEvent('file.txt'); + }); + }); + + group('subdirectories', () { + test('watches files in subdirectories', () async { + await startWatcher(); + writeFile('a/b/c/d/file.txt'); + await expectAddEvent('a/b/c/d/file.txt'); + }); + + test( + 'notifies when a subdirectory is moved within the watched directory ' + 'and then its contents are modified', () async { + writeFile('old/file.txt'); + await startWatcher(); + + renameDir('old', 'new'); + await inAnyOrder( + [isRemoveEvent('old/file.txt'), isAddEvent('new/file.txt')]); + + writeFile('new/file.txt', contents: 'modified'); + await expectModifyEvent('new/file.txt'); + }); + + test('notifies when a file is replaced by a subdirectory', () async { + writeFile('new'); + writeFile('old/file.txt'); + await startWatcher(); + + deleteFile('new'); + renameDir('old', 'new'); + await inAnyOrder([ + isRemoveEvent('new'), + isRemoveEvent('old/file.txt'), + isAddEvent('new/file.txt') + ]); + }); + + test('notifies when a subdirectory is replaced by a file', () async { + writeFile('old'); + writeFile('new/file.txt'); + await startWatcher(); + + renameDir('new', 'newer'); + renameFile('old', 'new'); + await inAnyOrder([ + isRemoveEvent('new/file.txt'), + isAddEvent('newer/file.txt'), + isRemoveEvent('old'), + isAddEvent('new') + ]); + }, onPlatform: { + 'windows': const Skip('https://github.com/dart-lang/watcher/issues/21') + }); + + test('emits events for many nested files added at once', () async { + withPermutations((i, j, k) => writeFile('sub/sub-$i/sub-$j/file-$k.txt')); + + createDir('dir'); + await startWatcher(path: 'dir'); + renameDir('sub', 'dir/sub'); + + await inAnyOrder(withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + + test('emits events for many nested files removed at once', () async { + withPermutations( + (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt')); + + createDir('dir'); + await startWatcher(path: 'dir'); + + // Rename the directory rather than deleting it because native watchers + // report a rename as a single DELETE event for the directory, whereas + // they report recursive deletion with DELETE events for every file in the + // directory. + renameDir('dir/sub', 'sub'); + + await inAnyOrder(withPermutations( + (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'))); + }); + + test('emits events for many nested files moved at once', () async { + withPermutations( + (i, j, k) => writeFile('dir/old/sub-$i/sub-$j/file-$k.txt')); + + createDir('dir'); + await startWatcher(path: 'dir'); + renameDir('dir/old', 'dir/new'); + + await inAnyOrder(unionAll(withPermutations((i, j, k) { + return { + isRemoveEvent('dir/old/sub-$i/sub-$j/file-$k.txt'), + isAddEvent('dir/new/sub-$i/sub-$j/file-$k.txt') + }; + }))); + }); + + test( + 'emits events for many files added at once in a subdirectory with the ' + 'same name as a removed file', () async { + writeFile('dir/sub'); + withPermutations((i, j, k) => writeFile('old/sub-$i/sub-$j/file-$k.txt')); + await startWatcher(path: 'dir'); + + deleteFile('dir/sub'); + renameDir('old', 'dir/sub'); + + var events = withPermutations( + (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')); + events.add(isRemoveEvent('dir/sub')); + await inAnyOrder(events); + }); + }); +} diff --git a/pkgs/watcher/test/directory_watcher/windows_test.dart b/pkgs/watcher/test/directory_watcher/windows_test.dart new file mode 100644 index 000000000..499e7fb16 --- /dev/null +++ b/pkgs/watcher/test/directory_watcher/windows_test.dart @@ -0,0 +1,23 @@ +// Copyright (c) 2014, 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. + +@TestOn('windows') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/windows.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = WindowsDirectoryWatcher.new; + + group('Shared Tests:', sharedTests); + + test('DirectoryWatcher creates a WindowsDirectoryWatcher on Windows', () { + expect(DirectoryWatcher('.'), const TypeMatcher()); + }); +} diff --git a/pkgs/watcher/test/file_watcher/native_test.dart b/pkgs/watcher/test/file_watcher/native_test.dart new file mode 100644 index 000000000..0d4ad6394 --- /dev/null +++ b/pkgs/watcher/test/file_watcher/native_test.dart @@ -0,0 +1,22 @@ +// Copyright (c) 2015, 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. + +@TestOn('linux || mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/file_watcher/native.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = NativeFileWatcher.new; + + setUp(() { + writeFile('file.txt'); + }); + + sharedTests(); +} diff --git a/pkgs/watcher/test/file_watcher/polling_test.dart b/pkgs/watcher/test/file_watcher/polling_test.dart new file mode 100644 index 000000000..861fcb222 --- /dev/null +++ b/pkgs/watcher/test/file_watcher/polling_test.dart @@ -0,0 +1,20 @@ +// Copyright (c) 2015, 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 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = (file) => + PollingFileWatcher(file, pollingDelay: const Duration(milliseconds: 100)); + + setUp(() { + writeFile('file.txt'); + }); + + sharedTests(); +} diff --git a/pkgs/watcher/test/file_watcher/shared.dart b/pkgs/watcher/test/file_watcher/shared.dart new file mode 100644 index 000000000..081b92e11 --- /dev/null +++ b/pkgs/watcher/test/file_watcher/shared.dart @@ -0,0 +1,73 @@ +// Copyright (c) 2015, 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 'package:test/test.dart'; + +import '../utils.dart'; + +void sharedTests() { + test("doesn't notify if the file isn't modified", () async { + await startWatcher(path: 'file.txt'); + await pumpEventQueue(); + deleteFile('file.txt'); + await expectRemoveEvent('file.txt'); + }); + + test('notifies when a file is modified', () async { + await startWatcher(path: 'file.txt'); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + }); + + test('notifies when a file is removed', () async { + await startWatcher(path: 'file.txt'); + deleteFile('file.txt'); + await expectRemoveEvent('file.txt'); + }); + + test('notifies when a file is modified multiple times', () async { + await startWatcher(path: 'file.txt'); + writeFile('file.txt', contents: 'modified'); + await expectModifyEvent('file.txt'); + writeFile('file.txt', contents: 'modified again'); + await expectModifyEvent('file.txt'); + }); + + test('notifies even if the file contents are unchanged', () async { + await startWatcher(path: 'file.txt'); + writeFile('file.txt'); + await expectModifyEvent('file.txt'); + }); + + test('emits a remove event when the watched file is moved away', () async { + await startWatcher(path: 'file.txt'); + renameFile('file.txt', 'new.txt'); + await expectRemoveEvent('file.txt'); + }); + + test( + 'emits a modify event when another file is moved on top of the watched ' + 'file', () async { + writeFile('old.txt'); + await startWatcher(path: 'file.txt'); + renameFile('old.txt', 'file.txt'); + await expectModifyEvent('file.txt'); + }); + + // Regression test for a race condition. + test('closes the watcher immediately after deleting the file', () async { + writeFile('old.txt'); + var watcher = createWatcher(path: 'file.txt'); + var sub = watcher.events.listen(null); + + deleteFile('file.txt'); + await Future.delayed(const Duration(milliseconds: 10)); + await sub.cancel(); + }); + + test('ready completes even if file does not exist', () async { + // startWatcher awaits 'ready' + await startWatcher(path: 'foo/bar/baz'); + }); +} diff --git a/pkgs/watcher/test/no_subscription/linux_test.dart b/pkgs/watcher/test/no_subscription/linux_test.dart new file mode 100644 index 000000000..aac08101b --- /dev/null +++ b/pkgs/watcher/test/no_subscription/linux_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, 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. + +@TestOn('linux') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/linux.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = LinuxDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/no_subscription/mac_os_test.dart b/pkgs/watcher/test/no_subscription/mac_os_test.dart new file mode 100644 index 000000000..55a83087c --- /dev/null +++ b/pkgs/watcher/test/no_subscription/mac_os_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, 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. + +@TestOn('mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/mac_os.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = MacOSDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/no_subscription/polling_test.dart b/pkgs/watcher/test/no_subscription/polling_test.dart new file mode 100644 index 000000000..bfd29588f --- /dev/null +++ b/pkgs/watcher/test/no_subscription/polling_test.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2013, 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 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = PollingDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/no_subscription/shared.dart b/pkgs/watcher/test/no_subscription/shared.dart new file mode 100644 index 000000000..e7a614454 --- /dev/null +++ b/pkgs/watcher/test/no_subscription/shared.dart @@ -0,0 +1,54 @@ +// Copyright (c) 2012, 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:async'; + +import 'package:async/async.dart'; +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +import '../utils.dart'; + +void sharedTests() { + test('does not notify for changes when there are no subscribers', () async { + // Note that this test doesn't rely as heavily on the test functions in + // utils.dart because it needs to be very explicit about when the event + // stream is and is not subscribed. + var watcher = createWatcher(); + var queue = StreamQueue(watcher.events); + unawaited(queue.hasNext); + + var future = + expectLater(queue, emits(isWatchEvent(ChangeType.ADD, 'file.txt'))); + expect(queue, neverEmits(anything)); + + await watcher.ready; + + writeFile('file.txt'); + + await future; + + // Unsubscribe. + await queue.cancel(immediate: true); + + // Now write a file while we aren't listening. + writeFile('unwatched.txt'); + + queue = StreamQueue(watcher.events); + future = + expectLater(queue, emits(isWatchEvent(ChangeType.ADD, 'added.txt'))); + expect(queue, neverEmits(isWatchEvent(ChangeType.ADD, 'unwatched.txt'))); + + // Wait until the watcher is ready to dispatch events again. + await watcher.ready; + + // And add a third file. + writeFile('added.txt'); + + // Wait until we get an event for the third file. + await future; + + await queue.cancel(immediate: true); + }); +} diff --git a/pkgs/watcher/test/no_subscription/windows_test.dart b/pkgs/watcher/test/no_subscription/windows_test.dart new file mode 100644 index 000000000..9f9e5a9c3 --- /dev/null +++ b/pkgs/watcher/test/no_subscription/windows_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2022, 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. + +@TestOn('windows') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/windows.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = WindowsDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/path_set_test.dart b/pkgs/watcher/test/path_set_test.dart new file mode 100644 index 000000000..61ab2cd64 --- /dev/null +++ b/pkgs/watcher/test/path_set_test.dart @@ -0,0 +1,228 @@ +// Copyright (c) 2013, 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 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:watcher/src/path_set.dart'; + +Matcher containsPath(String path) => predicate( + (paths) => paths is PathSet && paths.contains(path), + 'set contains "$path"'); + +Matcher containsDir(String path) => predicate( + (paths) => paths is PathSet && paths.containsDir(path), + 'set contains directory "$path"'); + +void main() { + late PathSet paths; + setUp(() => paths = PathSet('root')); + + group('adding a path', () { + test('stores the path in the set', () { + paths.add('root/path/to/file'); + expect(paths, containsPath('root/path/to/file')); + }); + + test("that's a subdir of another path keeps both in the set", () { + paths.add('root/path'); + paths.add('root/path/to/file'); + expect(paths, containsPath('root/path')); + expect(paths, containsPath('root/path/to/file')); + }); + + test("that's not normalized normalizes the path before storing it", () { + paths.add('root/../root/path/to/../to/././file'); + expect(paths, containsPath('root/path/to/file')); + }); + + test("that's absolute normalizes the path before storing it", () { + paths.add(p.absolute('root/path/to/file')); + expect(paths, containsPath('root/path/to/file')); + }); + }); + + group('removing a path', () { + test("that's in the set removes and returns that path", () { + paths.add('root/path/to/file'); + expect(paths.remove('root/path/to/file'), + unorderedEquals([p.normalize('root/path/to/file')])); + expect(paths, isNot(containsPath('root/path/to/file'))); + }); + + test("that's not in the set returns an empty set", () { + paths.add('root/path/to/file'); + expect(paths.remove('root/path/to/nothing'), isEmpty); + }); + + test("that's a directory removes and returns all files beneath it", () { + paths.add('root/outside'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + paths.add('root/path/to/sub/three'); + + expect( + paths.remove('root/path'), + unorderedEquals([ + 'root/path/to/one', + 'root/path/to/two', + 'root/path/to/sub/three' + ].map(p.normalize))); + + expect(paths, containsPath('root/outside')); + expect(paths, isNot(containsPath('root/path/to/one'))); + expect(paths, isNot(containsPath('root/path/to/two'))); + expect(paths, isNot(containsPath('root/path/to/sub/three'))); + }); + + test( + "that's a directory in the set removes and returns it and all files " + 'beneath it', () { + paths.add('root/path'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + paths.add('root/path/to/sub/three'); + + expect( + paths.remove('root/path'), + unorderedEquals([ + 'root/path', + 'root/path/to/one', + 'root/path/to/two', + 'root/path/to/sub/three' + ].map(p.normalize))); + + expect(paths, isNot(containsPath('root/path'))); + expect(paths, isNot(containsPath('root/path/to/one'))); + expect(paths, isNot(containsPath('root/path/to/two'))); + expect(paths, isNot(containsPath('root/path/to/sub/three'))); + }); + + test("that's not normalized removes and returns the normalized path", () { + paths.add('root/path/to/file'); + expect(paths.remove('root/../root/path/to/../to/./file'), + unorderedEquals([p.normalize('root/path/to/file')])); + }); + + test("that's absolute removes and returns the normalized path", () { + paths.add('root/path/to/file'); + expect(paths.remove(p.absolute('root/path/to/file')), + unorderedEquals([p.normalize('root/path/to/file')])); + }); + }); + + group('containsPath()', () { + test('returns false for a non-existent path', () { + paths.add('root/path/to/file'); + expect(paths, isNot(containsPath('root/path/to/nothing'))); + }); + + test("returns false for a directory that wasn't added explicitly", () { + paths.add('root/path/to/file'); + expect(paths, isNot(containsPath('root/path'))); + }); + + test('returns true for a directory that was added explicitly', () { + paths.add('root/path'); + paths.add('root/path/to/file'); + expect(paths, containsPath('root/path')); + }); + + test('with a non-normalized path normalizes the path before looking it up', + () { + paths.add('root/path/to/file'); + expect(paths, containsPath('root/../root/path/to/../to/././file')); + }); + + test('with an absolute path normalizes the path before looking it up', () { + paths.add('root/path/to/file'); + expect(paths, containsPath(p.absolute('root/path/to/file'))); + }); + }); + + group('containsDir()', () { + test('returns true for a directory that was added implicitly', () { + paths.add('root/path/to/file'); + expect(paths, containsDir('root/path')); + expect(paths, containsDir('root/path/to')); + }); + + test('returns true for a directory that was added explicitly', () { + paths.add('root/path'); + paths.add('root/path/to/file'); + expect(paths, containsDir('root/path')); + }); + + test("returns false for a directory that wasn't added", () { + expect(paths, isNot(containsDir('root/nothing'))); + }); + + test('returns false for a non-directory path that was added', () { + paths.add('root/path/to/file'); + expect(paths, isNot(containsDir('root/path/to/file'))); + }); + + test( + 'returns false for a directory that was added implicitly and then ' + 'removed implicitly', () { + paths.add('root/path/to/file'); + paths.remove('root/path/to/file'); + expect(paths, isNot(containsDir('root/path'))); + }); + + test( + 'returns false for a directory that was added explicitly whose ' + 'children were then removed', () { + paths.add('root/path'); + paths.add('root/path/to/file'); + paths.remove('root/path/to/file'); + expect(paths, isNot(containsDir('root/path'))); + }); + + test('with a non-normalized path normalizes the path before looking it up', + () { + paths.add('root/path/to/file'); + expect(paths, containsDir('root/../root/path/to/../to/.')); + }); + + test('with an absolute path normalizes the path before looking it up', () { + paths.add('root/path/to/file'); + expect(paths, containsDir(p.absolute('root/path'))); + }); + }); + + group('paths', () { + test('returns paths added to the set', () { + paths.add('root/path'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + + expect( + paths.paths, + unorderedEquals([ + 'root/path', + 'root/path/to/one', + 'root/path/to/two', + ].map(p.normalize))); + }); + + test("doesn't return paths removed from the set", () { + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + paths.remove('root/path/to/two'); + + expect(paths.paths, unorderedEquals([p.normalize('root/path/to/one')])); + }); + }); + + group('clear', () { + test('removes all paths from the set', () { + paths.add('root/path'); + paths.add('root/path/to/one'); + paths.add('root/path/to/two'); + + paths.clear(); + expect(paths.paths, isEmpty); + }); + }); +} diff --git a/pkgs/watcher/test/ready/linux_test.dart b/pkgs/watcher/test/ready/linux_test.dart new file mode 100644 index 000000000..aac08101b --- /dev/null +++ b/pkgs/watcher/test/ready/linux_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, 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. + +@TestOn('linux') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/linux.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = LinuxDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/ready/mac_os_test.dart b/pkgs/watcher/test/ready/mac_os_test.dart new file mode 100644 index 000000000..55a83087c --- /dev/null +++ b/pkgs/watcher/test/ready/mac_os_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2013, 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. + +@TestOn('mac-os') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/mac_os.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = MacOSDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/ready/polling_test.dart b/pkgs/watcher/test/ready/polling_test.dart new file mode 100644 index 000000000..bfd29588f --- /dev/null +++ b/pkgs/watcher/test/ready/polling_test.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2013, 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 'package:watcher/watcher.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = PollingDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/ready/shared.dart b/pkgs/watcher/test/ready/shared.dart new file mode 100644 index 000000000..ab2c3e162 --- /dev/null +++ b/pkgs/watcher/test/ready/shared.dart @@ -0,0 +1,84 @@ +// Copyright (c) 2012, 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:async'; + +import 'package:test/test.dart'; + +import '../utils.dart'; + +void sharedTests() { + test('ready does not complete until after subscription', () async { + var watcher = createWatcher(); + + var ready = false; + unawaited(watcher.ready.then((_) { + ready = true; + })); + await pumpEventQueue(); + + expect(ready, isFalse); + + // Subscribe to the events. + var subscription = watcher.events.listen((event) {}); + + await watcher.ready; + + // Should eventually be ready. + expect(watcher.isReady, isTrue); + + await subscription.cancel(); + }); + + test('ready completes immediately when already ready', () async { + var watcher = createWatcher(); + + // Subscribe to the events. + var subscription = watcher.events.listen((event) {}); + + // Allow watcher to become ready + await watcher.ready; + + // Ensure ready completes immediately + expect( + watcher.ready.timeout( + const Duration(milliseconds: 0), + onTimeout: () => throw StateError('Does not complete immediately'), + ), + completes, + ); + + await subscription.cancel(); + }); + + test('ready returns a future that does not complete after unsubscribing', + () async { + var watcher = createWatcher(); + + // Subscribe to the events. + var subscription = watcher.events.listen((event) {}); + + // Wait until ready. + await watcher.ready; + + // Now unsubscribe. + await subscription.cancel(); + + // Should be back to not ready. + expect(watcher.ready, doesNotComplete); + }); + + test('ready completes even if directory does not exist', () async { + var watcher = createWatcher(path: 'does/not/exist'); + + // Subscribe to the events (else ready will never fire). + var subscription = watcher.events.listen((event) {}, onError: (error) {}); + + // Expect ready still completes. + await watcher.ready; + + // Now unsubscribe. + await subscription.cancel(); + }); +} diff --git a/pkgs/watcher/test/ready/windows_test.dart b/pkgs/watcher/test/ready/windows_test.dart new file mode 100644 index 000000000..9f9e5a9c3 --- /dev/null +++ b/pkgs/watcher/test/ready/windows_test.dart @@ -0,0 +1,18 @@ +// Copyright (c) 2022, 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. + +@TestOn('windows') +library; + +import 'package:test/test.dart'; +import 'package:watcher/src/directory_watcher/windows.dart'; + +import '../utils.dart'; +import 'shared.dart'; + +void main() { + watcherFactory = WindowsDirectoryWatcher.new; + + sharedTests(); +} diff --git a/pkgs/watcher/test/utils.dart b/pkgs/watcher/test/utils.dart new file mode 100644 index 000000000..7867b9fc2 --- /dev/null +++ b/pkgs/watcher/test/utils.dart @@ -0,0 +1,288 @@ +// Copyright (c) 2012, 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:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:watcher/src/stat.dart'; +import 'package:watcher/watcher.dart'; + +typedef WatcherFactory = Watcher Function(String directory); + +/// Sets the function used to create the watcher. +set watcherFactory(WatcherFactory factory) { + _watcherFactory = factory; +} + +/// The mock modification times (in milliseconds since epoch) for each file. +/// +/// The actual file system has pretty coarse granularity for file modification +/// times. This means using the real file system requires us to put delays in +/// the tests to ensure we wait long enough between operations for the mod time +/// to be different. +/// +/// Instead, we'll just mock that out. Each time a file is written, we manually +/// increment the mod time for that file instantly. +final _mockFileModificationTimes = {}; + +late WatcherFactory _watcherFactory; + +/// Creates a new [Watcher] that watches a temporary file or directory. +/// +/// If [path] is provided, watches a subdirectory in the sandbox with that name. +Watcher createWatcher({String? path}) { + if (path == null) { + path = d.sandbox; + } else { + path = p.join(d.sandbox, path); + } + + return _watcherFactory(path); +} + +/// The stream of events from the watcher started with [startWatcher]. +late StreamQueue _watcherEvents; + +/// Whether the event stream has been closed. +/// +/// If this is not done by a test (by calling [startClosingEventStream]) it will +/// be done automatically via [addTearDown] in [startWatcher]. +var _hasClosedStream = true; + +/// Creates a new [Watcher] that watches a temporary file or directory and +/// starts monitoring it for events. +/// +/// If [path] is provided, watches a path in the sandbox with that name. +Future startWatcher({String? path}) async { + mockGetModificationTime((path) { + final normalized = p.normalize(p.relative(path, from: d.sandbox)); + + // Make sure we got a path in the sandbox. + assert(p.isRelative(normalized) && !normalized.startsWith('..'), + 'Path is not in the sandbox: $path not in ${d.sandbox}'); + + var mtime = _mockFileModificationTimes[normalized]; + return mtime != null ? DateTime.fromMillisecondsSinceEpoch(mtime) : null; + }); + + // We want to wait until we're ready *after* we subscribe to the watcher's + // events. + var watcher = createWatcher(path: path); + _watcherEvents = StreamQueue(watcher.events); + // Forces a subscription to the underlying stream. + unawaited(_watcherEvents.hasNext); + + _hasClosedStream = false; + addTearDown(startClosingEventStream); + + await watcher.ready; +} + +/// Schedule closing the watcher stream after the event queue has been pumped. +/// +/// This is necessary when events are allowed to occur, but don't have to occur, +/// at the end of a test. Otherwise, if they don't occur, the test will wait +/// indefinitely because they might in the future and because the watcher is +/// normally only closed after the test completes. +void startClosingEventStream() async { + if (_hasClosedStream) return; + _hasClosedStream = true; + await pumpEventQueue(); + await _watcherEvents.cancel(immediate: true); +} + +/// A list of [StreamMatcher]s that have been collected using +/// [_collectStreamMatcher]. +List? _collectedStreamMatchers; + +/// Collects all stream matchers that are registered within [block] into a +/// single stream matcher. +/// +/// The returned matcher will match each of the collected matchers in order. +StreamMatcher _collectStreamMatcher(void Function() block) { + var oldStreamMatchers = _collectedStreamMatchers; + var collectedStreamMatchers = _collectedStreamMatchers = []; + try { + block(); + return emitsInOrder(collectedStreamMatchers); + } finally { + _collectedStreamMatchers = oldStreamMatchers; + } +} + +/// Either add [streamMatcher] as an expectation to [_watcherEvents], or collect +/// it with [_collectStreamMatcher]. +/// +/// [streamMatcher] can be a [StreamMatcher], a [Matcher], or a value. +Future _expectOrCollect(Matcher streamMatcher) { + var collectedStreamMatchers = _collectedStreamMatchers; + if (collectedStreamMatchers != null) { + collectedStreamMatchers.add(emits(streamMatcher)); + return Future.sync(() {}); + } else { + return expectLater(_watcherEvents, emits(streamMatcher)); + } +} + +/// Expects that [matchers] will match emitted events in any order. +/// +/// [matchers] may be [Matcher]s or values, but not [StreamMatcher]s. +Future inAnyOrder(Iterable matchers) { + matchers = matchers.toSet(); + return _expectOrCollect(emitsInAnyOrder(matchers)); +} + +/// Expects that the expectations established in either [block1] or [block2] +/// will match the emitted events. +/// +/// If both blocks match, the one that consumed more events will be used. +Future allowEither(void Function() block1, void Function() block2) => + _expectOrCollect(emitsAnyOf( + [_collectStreamMatcher(block1), _collectStreamMatcher(block2)])); + +/// Allows the expectations established in [block] to match the emitted events. +/// +/// If the expectations in [block] don't match, no error will be raised and no +/// events will be consumed. If this is used at the end of a test, +/// [startClosingEventStream] should be called before it. +Future allowEvents(void Function() block) => + _expectOrCollect(mayEmit(_collectStreamMatcher(block))); + +/// Returns a StreamMatcher that matches a [WatchEvent] with the given [type] +/// and [path]. +Matcher isWatchEvent(ChangeType type, String path) { + return predicate((e) { + return e is WatchEvent && + e.type == type && + e.path == p.join(d.sandbox, p.normalize(path)); + }, 'is $type $path'); +} + +/// Returns a [Matcher] that matches a [WatchEvent] for an add event for [path]. +Matcher isAddEvent(String path) => isWatchEvent(ChangeType.ADD, path); + +/// Returns a [Matcher] that matches a [WatchEvent] for a modification event for +/// [path]. +Matcher isModifyEvent(String path) => isWatchEvent(ChangeType.MODIFY, path); + +/// Returns a [Matcher] that matches a [WatchEvent] for a removal event for +/// [path]. +Matcher isRemoveEvent(String path) => isWatchEvent(ChangeType.REMOVE, path); + +/// Expects that the next event emitted will be for an add event for [path]. +Future expectAddEvent(String path) => + _expectOrCollect(isWatchEvent(ChangeType.ADD, path)); + +/// Expects that the next event emitted will be for a modification event for +/// [path]. +Future expectModifyEvent(String path) => + _expectOrCollect(isWatchEvent(ChangeType.MODIFY, path)); + +/// Expects that the next event emitted will be for a removal event for [path]. +Future expectRemoveEvent(String path) => + _expectOrCollect(isWatchEvent(ChangeType.REMOVE, path)); + +/// Consumes a modification event for [path] if one is emitted at this point in +/// the schedule, but doesn't throw an error if it isn't. +/// +/// If this is used at the end of a test, [startClosingEventStream] should be +/// called before it. +Future allowModifyEvent(String path) => + _expectOrCollect(mayEmit(isWatchEvent(ChangeType.MODIFY, path))); + +/// Track a fake timestamp to be used when writing files. This always increases +/// so that files that are deleted and re-created do not have their timestamp +/// set back to a previously used value. +int _nextTimestamp = 1; + +/// Schedules writing a file in the sandbox at [path] with [contents]. +/// +/// If [contents] is omitted, creates an empty file. If [updateModified] is +/// `false`, the mock file modification time is not changed. +void writeFile(String path, {String? contents, bool? updateModified}) { + contents ??= ''; + updateModified ??= true; + + var fullPath = p.join(d.sandbox, path); + + // Create any needed subdirectories. + var dir = Directory(p.dirname(fullPath)); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + + File(fullPath).writeAsStringSync(contents); + + if (updateModified) { + path = p.normalize(path); + + _mockFileModificationTimes[path] = _nextTimestamp++; + } +} + +/// Schedules deleting a file in the sandbox at [path]. +void deleteFile(String path) { + File(p.join(d.sandbox, path)).deleteSync(); + + _mockFileModificationTimes.remove(path); +} + +/// Schedules renaming a file in the sandbox from [from] to [to]. +void renameFile(String from, String to) { + File(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); + + // Make sure we always use the same separator on Windows. + to = p.normalize(to); + + _mockFileModificationTimes.update(to, (value) => value + 1, + ifAbsent: () => 1); +} + +/// Schedules creating a directory in the sandbox at [path]. +void createDir(String path) { + Directory(p.join(d.sandbox, path)).createSync(); +} + +/// Schedules renaming a directory in the sandbox from [from] to [to]. +void renameDir(String from, String to) { + Directory(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to)); + + // Migrate timestamps for any files in this folder. + final knownFilePaths = _mockFileModificationTimes.keys.toList(); + for (final filePath in knownFilePaths) { + if (p.isWithin(from, filePath)) { + _mockFileModificationTimes[filePath.replaceAll(from, to)] = + _mockFileModificationTimes[filePath]!; + _mockFileModificationTimes.remove(filePath); + } + } +} + +/// Schedules deleting a directory in the sandbox at [path]. +void deleteDir(String path) { + Directory(p.join(d.sandbox, path)).deleteSync(recursive: true); +} + +/// Runs [callback] with every permutation of non-negative numbers for each +/// argument less than [limit]. +/// +/// Returns a set of all values returns by [callback]. +/// +/// [limit] defaults to 3. +Set withPermutations(S Function(int, int, int) callback, {int? limit}) { + limit ??= 3; + var results = {}; + for (var i = 0; i < limit; i++) { + for (var j = 0; j < limit; j++) { + for (var k = 0; k < limit; k++) { + results.add(callback(i, j, k)); + } + } + } + return results; +}