| // Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| |
| import '../../event.dart'; |
| import '../../event_batching.dart'; |
| import '../../paths.dart'; |
| import '../../testing.dart'; |
| import '../event_tree.dart'; |
| |
| /// Recursively watches a directory with `Directory.watch` on MacOS or Windows. |
| /// |
| /// Handles incorrect closure of the watch due to a delete event from before |
| /// the watch started, by re-opening the watch if the directory still exists. |
| /// See https://github.com/dart-lang/sdk/issues/14373. |
| /// |
| /// Handles deletion of the watched directory on Windows by watching the parent |
| /// directory. |
| class NativeWatch { |
| final AbsolutePath watchedDirectory; |
| |
| /// Called when [watchedDirectory] is recreated. |
| final void Function() _watchedDirectoryWasRecreated; |
| |
| /// Called when [watchedDirectory] is deleted. |
| final void Function() _watchedDirectoryWasDeleted; |
| |
| /// Called with trees of events. |
| final void Function(EventTree events) _onEvents; |
| |
| /// Called with native watch errors. |
| final void Function(Object, StackTrace) _onError; |
| |
| /// The underlying batched event stream. |
| StreamSubscription<List<Event>>? _subscription; |
| |
| /// On Windows only, the parent directory event stream. |
| StreamSubscription<FileSystemEvent>? _parentSubscription; |
| |
| /// Watches [watchedDirectory]. |
| /// |
| /// Pass [watchedDirectoryWasDeleted], [onEvents] and [onError] handlers. |
| NativeWatch({ |
| required this.watchedDirectory, |
| required void Function() watchedDirectoryWasRecreated, |
| required void Function() watchedDirectoryWasDeleted, |
| required void Function(EventTree) onEvents, |
| required void Function(Object, StackTrace) onError, |
| }) : _onError = onError, |
| _onEvents = onEvents, |
| _watchedDirectoryWasRecreated = watchedDirectoryWasRecreated, |
| _watchedDirectoryWasDeleted = watchedDirectoryWasDeleted { |
| logForTesting?.call('NativeWatch(),$watchedDirectory'); |
| _watch(); |
| if (Platform.isWindows) _watchParent(); |
| } |
| |
| void _watch() { |
| _subscription?.cancel(); |
| // In older SDKs watcher exceptions on Windows are not sent over the stream |
| // and must be caught with a zone handler. |
| runZonedGuarded( |
| () { |
| _subscription = watchedDirectory |
| .watch(recursive: true) |
| .batchAndConvertEventsForPlatform() |
| .listen(_onData, |
| onError: _restartWatchOnOverflowOr(_onError), onDone: _onDone); |
| }, |
| _restartWatchOnOverflowOr(Error.throwWithStackTrace), |
| ); |
| } |
| |
| /// Handles deletes and moves of [watchedDirectory] on Windows. |
| /// |
| /// Deletes can be signalled by an exception, but moves are not signalled |
| /// at all. So, handle both by watching the parent directory. |
| /// |
| /// See https://github.com/dart-lang/sdk/issues/62193 and |
| /// https://github.com/dart-lang/sdk/issues/62194. |
| void _watchParent() { |
| final parent = watchedDirectory.parent; |
| if (parent == watchedDirectory) { |
| // Watching a filesystem root: it can't be deleted. |
| return; |
| } |
| final parentStream = parent.watch(recursive: false); |
| _parentSubscription = parentStream.listen( |
| (event) { |
| // Only look at events for [watchedDirectory]. |
| final eventPath = AbsolutePath(event.path); |
| if (eventPath.basename != watchedDirectory.basename) { |
| return; |
| } |
| // The directory was deleted if there is an event saying it was deleted, |
| // or if there was any event and it no longer exists. Note that it might |
| // still exist but be a different+new directory: this needs handling as |
| // a delete because the new directory would need a new native watch. |
| if (event is FileSystemMoveEvent || |
| event is FileSystemDeleteEvent || |
| (eventPath.typeSync() == FileSystemEntityType.notFound)) { |
| _watchedDirectoryWasDeleted(); |
| } |
| }, |
| 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. |
| _parentSubscription?.cancel(); |
| _parentSubscription = null; |
| }, |
| ); |
| } |
| |
| /// Closes the watch. |
| void close() { |
| logForTesting?.call('NativeWatch,$watchedDirectory,close'); |
| _subscription?.cancel(); |
| _subscription = null; |
| _parentSubscription?.cancel(); |
| _parentSubscription = null; |
| } |
| |
| void _onData(List<Event> events) { |
| logForTesting?.call('NativeWatch,$watchedDirectory,onData,$events'); |
| final eventTree = EventTree(); |
| for (final event in events) { |
| // Delete of the watched directory is handled when the stream closes. |
| if (event.type == EventType.delete && |
| event.absolutePath == watchedDirectory) { |
| continue; |
| } |
| // Drop paths outside the watched directory, which could only be due to |
| // a move event destination path. Currently the VM reports moves to |
| // outside the watched directory as deletes, so there aren't any such move |
| // events, but it's as easy and more future proof to handle correctly by |
| // dropping instead of throwing. |
| final path = event.absolutePath.tryRelativeTo(watchedDirectory); |
| if (path != null) { |
| eventTree.add(path); |
| } |
| } |
| _onEvents(eventTree); |
| } |
| |
| void _onDone() { |
| logForTesting?.call('NativeWatch,$watchedDirectory,onDone'); |
| // Check whether the directory exists and report if it was deleted or |
| // recreated. If it was recreated, restart the watch. |
| if (watchedDirectory.typeSync() == FileSystemEntityType.directory) { |
| _watchedDirectoryWasRecreated(); |
| _watch(); |
| } else { |
| _watchedDirectoryWasDeleted(); |
| } |
| } |
| |
| /// Intercepts and handles Windows-specific exceptions. |
| /// |
| /// A "closed unexpectedly" error happens on Windows when the event |
| /// stream is not serviced quickly enough and the OS buffer fills. |
| /// |
| /// And, a `SocketException` happens on Windows when the watched directory |
| /// is deleted. |
| void Function(Object, StackTrace) _restartWatchOnOverflowOr( |
| void Function(Object, StackTrace) otherwise) { |
| return (error, stackTrace) async { |
| if (error is FileSystemException && |
| error.message.startsWith('Directory watcher closed unexpectedly')) { |
| // Wait to work around https://github.com/dart-lang/sdk/issues/61378. |
| // Give the VM time to reset state after the error. See the issue for |
| // more discussion of the workaround. |
| // TODO(davidmorgan): remove the wait once min SDK version is 3.10. |
| // The recovery test in `windows_isolate_test.dart` will continue to |
| // pass if it's no longer needed. |
| await _subscription?.cancel(); |
| await Future<void>.delayed(const Duration(milliseconds: 1)); |
| _watch(); |
| _watchedDirectoryWasRecreated(); |
| } else if ((error is SocketException && |
| error.message.contains('SocketException: Access is denied')) || |
| (error is FileSystemException && |
| error.message.contains('SocketException: Access is denied'))) { |
| // This can happen if the watched directory is deleted, see |
| // [_watchParent] which handles both deletes and moves. Ignore the |
| // exception. |
| } else { |
| otherwise(error, stackTrace); |
| } |
| }; |
| } |
| } |
| |
| extension _BatchEvents on Stream<FileSystemEvent> { |
| /// Batches events based on the current platform. |
| /// |
| /// On Windows, events need to be batched by path for two reasons: to handle |
| /// duplicate events together and because polling the filesystem state too |
| /// quickly after an event arrives can give incorrect results. In particular, |
| /// if the entity is a newly-created link to a directory then polling too soon |
| /// reports that it is a directory, not a link. By testing, a 1ms delay looks |
| /// sufficient: incorrect type dropped from 150/1000 to 0/10000. Use a 5ms |
| /// delay to have a margin for error for load and machine performance. |
| /// |
| /// On other platforms, which means MacOS, events are batched by "nearby |
| /// microtask" to pick up all the events that the VM sends "together". |
| Stream<List<Event>> batchAndConvertEventsForPlatform() { |
| return Platform.isWindows |
| ? batchBufferedByPathAndConvertEvents( |
| duration: const Duration(milliseconds: 5)) |
| : batchNearbyMicrotasksAndConvertEvents(); |
| } |
| } |