[analyzer] Ensure watchers can be subscribed to more than once

The stream created by memory resources was a single-subscriber stream. The stream from the physical file system watchers was a broadcast stream, but the code to translate FileSystemException to the abstractions was written in a way that made it single-subscriber.

This fixes both to ensure that a `ResourceWatcher` can support multiple subscribers that can subscribe and cancel at different times.

We don't currently use this, so this change should effectively be a no-op right now, but it will allow us to reuse the temporary catchers we create when set up analysis roots instead of creating new ones.

There's an issue at https://github.com/dart-lang/sdk/issues/54274 discussing changing how we watch here, but it may still be worth picking the low-hanging fruit in the short-term.

Change-Id: I3bda77ddaee676c573021aa671cb01bae48bd56f
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/341740
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
This commit is contained in:
Danny Tuppeny
2023-12-19 16:53:59 +00:00
committed by Commit Queue
parent 8f6be08cbb
commit 63772f10fe
5 changed files with 206 additions and 25 deletions
+17 -3
View File
@@ -255,16 +255,30 @@ abstract class ResourceProvider {
/// The [ready] event will not fire until a listener has been set up on
/// [changes] and the watcher initialization is complete.
class ResourceWatcher {
/// A broadcast stream of changes from this watcher.
///
/// This stream can be subscribed to by multiple listeners, but each listener
/// should await the latest [Future] returned by [ready] after subscribing to
/// ensure the watcher is set up. The internal watcher may be closed when
/// there are no subscribers and re-created when another subscriber appears.
final Stream<WatchEvent> changes;
/// A function to obtain the to current [ready] [Future] from the underlying
/// watcher.
final Future<void> Function() _ready;
ResourceWatcher(this.changes, this._ready);
/// An event that fires when the watcher is fully initialized and ready to
/// produce events.
///
/// This event will not fire until a listener has been set up on [changes] and
/// the watcher initialization is complete.
final Future<void> ready;
ResourceWatcher(this.changes, this.ready);
///
/// This Future may change over time because the internal watcher may be
/// closed when there are no subscribers and re-created when another
/// subscriber appears.
Future<void> get ready => _ready();
}
extension FolderExtension on Folder {
@@ -614,7 +614,7 @@ abstract class _MemoryResource implements Resource {
/// watcher.
@override
ResourceWatcher watch() {
final streamController = StreamController<WatchEvent>();
final streamController = StreamController<WatchEvent>.broadcast();
final ready = Completer<void>();
/// A helper that sets up the watcher that may be called synchronously
@@ -650,7 +650,7 @@ abstract class _MemoryResource implements Resource {
setupWatcher();
}
return ResourceWatcher(streamController.stream, ready.future);
return ResourceWatcher(streamController.stream, () => ready.future);
}
}
@@ -184,7 +184,10 @@ class _PhysicalFile extends _PhysicalResource implements File {
@override
ResourceWatcher watch() {
final watcher = FileWatcher(_entry.path);
return ResourceWatcher(_wrapWatcherStream(watcher.events), watcher.ready);
return ResourceWatcher(
watcher.events.transform(_exceptionTransformer),
() => watcher.ready,
);
}
@override
@@ -320,7 +323,10 @@ class _PhysicalFolder extends _PhysicalResource implements Folder {
// Don't suppress "Directory watcher closed," so the outer
// listener can see the interruption & act on it.
!error.message.startsWith("Directory watcher closed unexpectedly"));
return ResourceWatcher(_wrapWatcherStream(events), watcher.ready);
return ResourceWatcher(
events.transform(_exceptionTransformer),
() => watcher.ready,
);
}
}
@@ -328,6 +334,17 @@ class _PhysicalFolder extends _PhysicalResource implements Folder {
abstract class _PhysicalResource implements Resource {
final io.FileSystemEntity _entry;
/// Wraps [FileSystemException]s in the stream through [_wrapException].
late final _exceptionTransformer =
StreamTransformer<WatchEvent, WatchEvent>.fromHandlers(
handleError: (error, stackTrace, sink) {
if (error is io.FileSystemException) {
error = _wrapException(error);
}
sink.addError(error);
},
);
_PhysicalResource(this._entry);
@override
@@ -415,22 +432,4 @@ abstract class _PhysicalResource implements Resource {
return FileSystemException(e.path ?? path, e.message);
}
}
/// Wraps a `Stream<WatchEvent>` to map all known errors through
/// [_wrapException] into server types.
Stream<WatchEvent> _wrapWatcherStream(Stream<WatchEvent> original) {
/// Helper to map thrown `FileSystemException`s to servers abstraction.
Object mapException(Object e) {
return e is io.FileSystemException ? _wrapException(e) : e;
}
final mappedEventsController = StreamController<WatchEvent>();
final subscription = original.listen(
mappedEventsController.add,
onError: (Object e) => mappedEventsController.addError(mapException(e)),
onDone: () => mappedEventsController.close(),
);
mappedEventsController.onCancel = subscription.cancel;
return mappedEventsController.stream;
}
}
@@ -2,13 +2,17 @@
// 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 'dart:typed_data';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/source.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import 'package:watcher/watcher.dart' show ChangeType;
final isFile = TypeMatcher<File>();
final isFileSystemException = TypeMatcher<FileSystemException>();
@@ -16,6 +20,11 @@ final isFolder = TypeMatcher<Folder>();
final throwsFileSystemException = throwsA(isFileSystemException);
typedef _WatcherChanges = ({
List<ChangeType> changes,
Future<void> Function() cancel,
});
abstract class FileSystemTestSupport {
/// The content used for the file at the [defaultFilePath] if it is created
/// and no other content is provided.
@@ -65,6 +74,38 @@ abstract class FileSystemTestSupport {
String? part5,
String? part6]) =>
provider.pathContext.join(part1, part2, part3, part4, part5, part6);
/// Subscribes to [watcher], waits for it to become ready, and returns
/// a record containing a list of events received (which updates as events
/// arrive) and a function to cancel.
Future<_WatcherChanges> _subscribeToWatcher(ResourceWatcher watcher) async {
var changes = <ChangeType>[];
var subscription =
watcher.changes.listen((event) => changes.add(event.type));
await watcher.ready;
return (changes: changes, cancel: subscription.cancel);
}
/// Waits up to [maxDuration] for [condition] to return `true`.
///
/// Throws if condition is not `true` before the time expires.
Future<void> _waitFor(
FutureOr<bool> Function() condition, [
// Set the default max high enough to ensure no flakes on slow bots. We
// check periodically and will exit early if the condition is met.
Duration maxDuration = const Duration(seconds: 5),
]) async {
var endTime = DateTime.now().add(maxDuration);
while (DateTime.now().isBefore(endTime)) {
if (await condition()) {
return;
}
await pumpEventQueue(times: 1000);
}
throw 'Condition was not true within $maxDuration';
}
}
/// Unlike most test mixins, this mixin defines some abstract test methods.
@@ -229,6 +270,77 @@ mixin FileTestMixin implements FileSystemTestSupport {
expect(() => file.modificationStamp, throwsA(isFileSystemException));
}
/// Test that we can reuse a [ResourceWatcher] to create a second subscription
/// and cancel it at a different time.
test_multipleWatchers_staggeredCancel() async {
var filePath = path.join(tempPath, 'foo');
var file = getFile(filePath: filePath, exists: true);
// On Windows, package:watcher may miss modification events for files that
// were created just before we started watching (see
// https://github.com/dart-lang/watcher/issues/159), so add an artificial
// delay there to ensure we don't miss events until that issue is resolved.
if (provider is PhysicalResourceProvider && Platform.isWindows) {
await Future<void>.delayed(const Duration(seconds: 1));
}
var resource = provider.getResource(filePath);
var watcher = resource.watch();
var watch1 = await _subscribeToWatcher(watcher);
var watch2 = await _subscribeToWatcher(watcher);
file.writeAsStringSync('first');
await _waitFor(
() => watch1.changes.isNotEmpty && watch2.changes.isNotEmpty,
);
// Cancel the second watcher before causing the second event.
await watch2.cancel();
file.writeAsStringSync('second');
await _waitFor(() => watch1.changes.length >= 2);
await watch1.cancel();
// Watcher 1 should have both events, and watcher 2 should have only the
// first one.
expect(watch1.changes.length, 2);
expect(watch2.changes.length, 1);
}
/// Test that we can reuse a [ResourceWatcher] to create a second subscription
/// at a different time.
test_multipleWatchers_staggeredSubscribe() async {
var filePath = path.join(tempPath, 'foo');
var file = getFile(filePath: filePath, exists: true);
// On Windows, package:watcher may miss modification events for files that
// were created just before we started watching (see
// https://github.com/dart-lang/watcher/issues/159), so add an artificial
// delay there to ensure we don't miss events until that issue is resolved.
if (provider is PhysicalResourceProvider && Platform.isWindows) {
await Future<void>.delayed(const Duration(seconds: 1));
}
var resource = provider.getResource(filePath);
var watcher = resource.watch();
var watch1 = await _subscribeToWatcher(watcher);
file.writeAsStringSync('first');
await _waitFor(() => watch1.changes.isNotEmpty);
var watch2 = await _subscribeToWatcher(watcher);
file.writeAsStringSync('second');
await _waitFor(() => watch2.changes.isNotEmpty);
await Future.wait([watch1.cancel(), watch2.cancel()]);
// Watcher 1 should have both events, and watcher 2 should have only the
// second.
expect(watch1.changes.length, 2);
expect(watch2.changes.length, 1);
}
test_parent2() {
File file = getFile(exists: true);
@@ -778,6 +890,61 @@ mixin FolderTestMixin implements FileSystemTestSupport {
expect(folder.isOrContains(defaultFolderPath), isTrue);
}
/// Test that we can reuse a [ResourceWatcher] to create a second subscription
/// and cancel it at a different time.
test_multipleWatchers_staggeredCancel() async {
var folderPath = path.join(tempPath, 'foo');
var fileInFolder =
getFile(filePath: path.join(folderPath, 'file'), exists: true);
var resource = provider.getResource(folderPath);
var watcher = resource.watch();
var watch1 = await _subscribeToWatcher(watcher);
var watch2 = await _subscribeToWatcher(watcher);
fileInFolder.writeAsStringSync('first');
await _waitFor(
() => watch1.changes.isNotEmpty && watch2.changes.isNotEmpty,
);
// Cancel the second watcher before causing the second event.
await watch2.cancel();
fileInFolder.writeAsStringSync('second');
await _waitFor(() => watch1.changes.length >= 2);
await watch1.cancel();
// Watcher 1 should have both events, and watcher 2 should have only the
// first one.
expect(watch1.changes.length, 2);
expect(watch2.changes.length, 1);
}
/// Test that we can reuse a [ResourceWatcher] to create a second subscription
/// at a different time.
test_multipleWatchers_staggeredSubscribe() async {
var folderPath = path.join(tempPath, 'foo');
var fileInFolder =
getFile(filePath: path.join(folderPath, 'file'), exists: true);
var resource = provider.getResource(folderPath);
var watcher = resource.watch();
var watch1 = await _subscribeToWatcher(watcher);
fileInFolder.writeAsStringSync('first');
await _waitFor(() => watch1.changes.isNotEmpty);
var watch2 = await _subscribeToWatcher(watcher);
fileInFolder.writeAsStringSync('second');
await _waitFor(() => watch2.changes.isNotEmpty);
await Future.wait([watch1.cancel(), watch2.cancel()]);
// Watcher 1 should have both events, and watcher 2 should have only the
// second.
expect(watch1.changes.length, 2);
expect(watch2.changes.length, 1);
}
test_parent() {
Folder folder = getFolder(exists: true);
@@ -2,6 +2,7 @@
// 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' as io;
import 'package:analyzer/file_system/file_system.dart';