blob: d2671719306259ad197748a31ee770f573571139 [file] [log] [blame]
// 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();
}
}