Watch Bazel generated files for changes

This introduces a way to see what paths have been searched for by
the `BazelWorkspace` and adds a polling-based watcher to detect
when files generated by Bazel appear (or have changed). This allows
us to re-analyze things automatically instead of, e.g., restarting
the server.

Change-Id: I60eae29b0e4fcc3a91d8d2275c6898e45548ea03
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/168649
Commit-Queue: Michal Terepeta <michalt@google.com>
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
This commit is contained in:
Michal Terepeta
2020-12-09 09:39:19 +00:00
committed by commit-bot@chromium.org
parent bf26eb3c94
commit 97093b2594
8 changed files with 576 additions and 12 deletions
@@ -31,6 +31,7 @@ import 'package:analyzer/src/source/path_filter.dart';
import 'package:analyzer/src/task/options.dart';
import 'package:analyzer/src/util/glob.dart';
import 'package:analyzer/src/util/yaml.dart';
import 'package:analyzer/src/workspace/bazel.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart' as protocol;
import 'package:analyzer_plugin/utilities/analyzer_converter.dart';
import 'package:path/path.dart' as pathos;
@@ -393,6 +394,11 @@ class ContextManagerImpl implements ContextManager {
final Map<Folder, StreamSubscription<WatchEvent>> changeSubscriptions =
<Folder, StreamSubscription<WatchEvent>>{};
/// For each root directory stores subscriptions and watchers that we
/// established to detect changes to Bazel generated files.
final Map<Folder, _BazelWorkspaceSubscription> bazelSubscriptions =
<Folder, _BazelWorkspaceSubscription>{};
ContextManagerImpl(
this.resourceProvider,
this.sdkManager,
@@ -1016,6 +1022,7 @@ class ContextManagerImpl implements ContextManager {
contextRoot.optionsFilePath = optionsFile.path;
}
info.analysisDriver = callbacks.addAnalysisDriver(folder, contextRoot);
_watchBazelFilesIfNeeded(folder, info.analysisDriver);
if (optionsFile != null) {
_analyzeAnalysisOptionsFile(info.analysisDriver, optionsFile.path);
}
@@ -1116,6 +1123,7 @@ class ContextManagerImpl implements ContextManager {
/// Clean up and destroy the context associated with the given folder.
void _destroyContext(ContextInfo info) {
changeSubscriptions.remove(info.folder)?.cancel();
bazelSubscriptions.remove(info.folder)?.cancel();
callbacks.removeContext(info.folder, _computeFlushedFiles(info));
var wasRemoved = info.parent.children.remove(info);
assert(wasRemoved);
@@ -1220,6 +1228,47 @@ class ContextManagerImpl implements ContextManager {
return rootInfo;
}
/// Establishes watch(es) for the Bazel generated files provided in
/// [notification].
///
/// Whenever the files change, we trigger re-analysis. This allows us to react
/// to creation/modification of files that were generated by Bazel.
void _handleBazelFileNotification(
Folder folder, BazelFileNotification notification) {
var fileSubscriptions = bazelSubscriptions[folder].fileSubscriptions;
if (fileSubscriptions.containsKey(notification.requested)) {
// We have already established a Watcher for this particular path.
return;
}
var watcher = notification.watcher(
pollingDelayShort: Duration(seconds: 10),
pollingDelayLong: Duration(seconds: 30));
var subscription = watcher.events.listen(_handleBazelWatchEvent);
fileSubscriptions[notification.requested] =
_BazelFilesSubscription(watcher, subscription);
watcher.start();
}
/// Notifies the drivers that a generated Bazel file has changed.
void _handleBazelWatchEvent(WatchEvent event) {
if (event.type == ChangeType.ADD) {
for (var driver in driverMap.values) {
driver.addFile(event.path);
// Since the file has been created after we've searched for it, the
// URI resolution is likely wrong, so we need to reset it.
driver.resetUriResolution();
}
} else if (event.type == ChangeType.MODIFY) {
for (var driver in driverMap.values) {
driver.changeFile(event.path);
}
} else if (event.type == ChangeType.REMOVE) {
for (var driver in driverMap.values) {
driver.removeFile(event.path);
}
}
}
void _handleWatchEvent(WatchEvent event) {
callbacks.broadcastWatchEvent(event);
_handleWatchEventImpl(event);
@@ -1504,6 +1553,22 @@ class ContextManagerImpl implements ContextManager {
driver.configure(sourceFactory: sourceFactory);
}
/// Listens to files generated by Bazel that were found or searched for.
///
/// This is handled specially because the files are outside the package
/// folder, but we still want to watch for changes to them.
///
/// Does nothing if the [driver] is not in a Bazel workspace.
void _watchBazelFilesIfNeeded(Folder folder, AnalysisDriver analysisDriver) {
var workspace = analysisDriver.analysisContext.workspace;
if (workspace is BazelWorkspace &&
!bazelSubscriptions.containsKey(folder)) {
var subscription = workspace.bazelCandidateFiles.listen(
(notification) => _handleBazelFileNotification(folder, notification));
bazelSubscriptions[folder] = _BazelWorkspaceSubscription(subscription);
}
}
/// Create and return a source representing the given [file] within the given
/// [driver].
static Source createSourceInContext(AnalysisDriver driver, File file) {
@@ -1622,3 +1687,36 @@ class PackagesFileDisposition extends FolderDisposition {
return _embedderLocator;
}
}
/// A watcher with subscription used to detect changes to some file.
class _BazelFilesSubscription {
final BazelFileWatcher watcher;
final StreamSubscription<WatchEvent> subscription;
_BazelFilesSubscription(this.watcher, this.subscription);
void cancel() {
subscription.cancel();
watcher.stop();
}
}
/// A subscription to notifications from a Bazel workspace.
class _BazelWorkspaceSubscription {
final StreamSubscription<BazelFileNotification> workspaceSubscription;
/// For each absolute path that we searched for, provides the subscriptions
/// that we established to watch for changes.
///
/// Note that the absolute path used when searching for a file is not
/// necessarily the actual path of the file (see [BazelWorkspace.findFile] for
/// details on how the files are searched).
final fileSubscriptions = <String, _BazelFilesSubscription>{};
_BazelWorkspaceSubscription(this.workspaceSubscription);
void cancel() {
workspaceSubscription.cancel();
fileSubscriptions.values.forEach((sub) => sub.cancel());
}
}
@@ -0,0 +1,83 @@
// 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 'dart:async';
import 'package:analysis_server/protocol/protocol.dart';
import 'package:analysis_server/protocol/protocol_constants.dart';
import 'package:analysis_server/protocol/protocol_generated.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../analysis_abstract.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(BazelChangesTest);
});
}
@reflectiveTest
class BazelChangesTest extends AbstractAnalysisTest {
Map<String, List<AnalysisError>> filesErrors = {};
Completer<void> processedNotification;
@override
void processNotification(Notification notification) {
if (notification.event == ANALYSIS_NOTIFICATION_ERRORS) {
var decoded = AnalysisErrorsParams.fromNotification(notification);
filesErrors[decoded.file] = decoded.errors;
processedNotification?.complete();
}
}
@override
void setUp() {
super.setUp();
projectPath = convertPath('/workspaceRoot/third_party/dart/project');
testFile =
convertPath('/workspaceRoot/third_party/dart/project/lib/test.dart');
newFile('/workspaceRoot/WORKSPACE');
newFolder('/workspaceRoot/bazel-lib/project');
newFolder('/workspaceRoot/bazel-genfiles/project');
}
@override
void tearDown() {
// Make sure to destroy all the contexts and cancel all subscriptions to
// file watchers.
server.contextManager.setRoots([], []);
super.tearDown();
}
Future<void> test_findingFileInGenfiles() async {
processedNotification = Completer();
newFile(testFile, content: r'''
import 'generated.dart';
void main() { fun(); }
''');
createProject();
// We should have some errors since the `generated.dart` is not there yet.
await processedNotification.future;
expect(filesErrors[testFile], isNotEmpty);
// Clear errors, so that we'll notice new results.
filesErrors.clear();
processedNotification = Completer();
// Simulate the creation of a generated file.
newFile(
'/workspaceRoot/bazel-genfiles/'
'third_party/dart/project/lib/generated.dart',
content: 'fun() {}');
// No errors.
await processedNotification.future;
expect(filesErrors[testFile], isEmpty);
}
}
@@ -4,6 +4,7 @@
import 'package:test_reflective_loader/test_reflective_loader.dart';
import 'bazel_changes_test.dart' as bazel_changes;
import 'get_errors_test.dart' as get_errors;
import 'get_hover_test.dart' as get_hover;
import 'get_navigation_test.dart' as get_navigation;
@@ -26,6 +27,7 @@ import 'update_content_test.dart' as update_content;
void main() {
defineReflectiveSuite(() {
bazel_changes.main();
get_errors.main();
get_hover.main();
get_navigation.main();
+14 -1
View File
@@ -114,7 +114,10 @@ class ContextBuilder {
if (builderOptions.librarySummaryPaths != null) {
summaryData = SummaryDataStore(builderOptions.librarySummaryPaths);
}
final sf = createSourceFactory(path, summaryData: summaryData);
Workspace workspace =
ContextBuilder.createWorkspace(resourceProvider, path, this);
final sf =
createSourceFactoryFromWorkspace(workspace, summaryData: summaryData);
AnalysisDriver driver = AnalysisDriver(
analysisDriverScheduler,
@@ -143,6 +146,7 @@ class ContextBuilder {
resourceProvider,
apiContextRoots.first,
driver,
workspace: workspace,
),
);
@@ -209,6 +213,15 @@ class ContextBuilder {
return workspace.createSourceFactory(sdk, summaryData);
}
SourceFactory createSourceFactoryFromWorkspace(Workspace workspace,
{SummaryDataStore summaryData}) {
DartSdk sdk = findSdk(workspace);
if (summaryData != null && sdk is SummaryBasedDartSdk) {
summaryData.addBundle(null, sdk.bundle);
}
return workspace.createSourceFactory(sdk, summaryData);
}
/// Add any [declaredVariables] to the list of declared variables used by the
/// given analysis [driver].
void declareVariablesInDriver(AnalysisDriver driver) {
@@ -29,7 +29,9 @@ class DriverBasedAnalysisContext implements AnalysisContext {
/// to access the file system and that is based on the given analysis
/// [driver].
DriverBasedAnalysisContext(
this.resourceProvider, this.contextRoot, this.driver) {
this.resourceProvider, this.contextRoot, this.driver,
{Workspace workspace})
: _workspace = workspace {
driver.analysisContext = this;
}
+197 -10
View File
@@ -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:collection';
import 'dart:core';
@@ -14,8 +15,43 @@ import 'package:analyzer/src/generated/source_io.dart';
import 'package:analyzer/src/summary/package_bundle_reader.dart';
import 'package:analyzer/src/util/uri.dart';
import 'package:analyzer/src/workspace/workspace.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'package:pub_semver/pub_semver.dart';
import 'package:watcher/watcher.dart';
/// Notification that we issue in [BazelWorkspace.findFile] when searching for
/// generated files.
///
/// This allows clients to watch for changes to the generated files.
class BazelFileNotification {
/// Candidate paths that we searched.
///
/// If it's a singleton, then the file was found/resolved.
final List<String> candidates;
/// Absolute path that we tried searching for.
///
/// This is not necessarily the path of the actual file that will be used. See
/// [BazelWorkspace.findFile] for details.
final String requested;
final ResourceProvider _provider;
BazelFileNotification(this.requested, this.candidates, this._provider);
BazelFileWatcher watcher(
{@required Duration pollingDelayShort,
@required Duration pollingDelayLong,
Timer Function(Duration, void Function(Timer)) timerFactory =
_defaultTimerFactory}) =>
BazelFileWatcher(candidates, _provider, pollingDelayShort,
pollingDelayLong, timerFactory);
static Timer _defaultTimerFactory(
Duration duration, void Function(Timer) callback) =>
Timer.periodic(duration, callback);
}
/// Instances of the class `BazelFileUriResolver` resolve `file` URI's by first
/// resolving file uri's in the expected way, and then by looking in the
@@ -41,6 +77,140 @@ class BazelFileUriResolver extends ResourceUriResolver {
}
}
/// Watches a list of files that should be generated by Bazel.
///
/// If we didn't find the file initially, we'll have more that one potential
/// path. We'll poll them with a shorter delay waiting for at least one
/// of the paths to become valid (we assume that users will generate the rather
/// sooner than later since they will likely see errors due to unresolved
/// files). Once a file appears, we switch to a different mode, where we only
/// watch that particular file and poll it less frequently. If the file is
/// deleted we go back to the initial mode of eager polling for all paths.
class BazelFileWatcher {
/// The paths of files that we watch for.
///
/// If it's a singleton, then the file was found/resolved by the
/// [BazelWorkspace].
final List<String> _candidates;
final _eventsController = StreamController<WatchEvent>.broadcast();
/// The time of last modification of the file under [_validPath].
int _lastModified;
/// How often do we poll a file that we have found.
final Duration _pollingDelayLong;
/// How often do we poll when none of potential files exist.
final Duration _pollingDelayShort;
final ResourceProvider _provider;
/// One of the [_candidates] that is valid, i.e., we found a file with that
/// path.
String _validPath;
Timer _timer;
/// Used to contruct a [Timer] for polling.
final Timer Function(Duration, void Function(Timer)) _timerFactory;
BazelFileWatcher(this._candidates, this._provider, this._pollingDelayShort,
this._pollingDelayLong, this._timerFactory);
Stream<WatchEvent> get events => _eventsController.stream;
/// Starts watching the files.
///
/// To avoid missing events, the clients should first start listening on
/// [events] and then call [start].
void start() {
assert(_timer == null);
var info = _pollAll();
if (info != null) {
_validPath = info.path;
_lastModified = info.modified;
_setPollingDelayToLong();
} else {
_setPollingDelayToShort();
}
}
void stop() {
_timer.cancel();
_eventsController.close();
}
void _poll() {
if (_eventsController.isClosed) return;
int modified;
if (_validPath == null) {
var info = _pollAll();
if (info != null) {
_validPath = info.path;
modified = info.modified;
}
} else {
modified = _pollOne(_validPath);
}
// If there is no file, then we have nothing to do.
if (_validPath == null) return;
if (modified == null && _lastModified != null) {
// The file is no longer there, so let's issue a REMOVE event, unset
// `_validPath` and set the timer to poll more frequently.
_eventsController.add(WatchEvent(ChangeType.REMOVE, _validPath));
_validPath = null;
_setPollingDelayToShort();
} else if (modified != null && _lastModified == null) {
_eventsController.add(WatchEvent(ChangeType.ADD, _validPath));
_setPollingDelayToLong();
} else if (_lastModified != null && modified != _lastModified) {
_eventsController.add(WatchEvent(ChangeType.MODIFY, _validPath));
}
_lastModified = modified;
}
/// Tries polling all the possible paths.
///
/// Will set [_validPath] and return its modified time if a file is found.
/// Returns [null] if nothing is found.
FileInfo _pollAll() {
assert(_validPath == null);
for (var path in _candidates) {
var modified = _pollOne(path);
if (modified != null) {
return FileInfo(path, modified);
}
}
return null;
}
/// Returns the modified time of the path or `null` if the file does not
/// exist.
int _pollOne(String path) {
try {
var file = _provider.getFile(path);
return file.modificationStamp;
} on FileSystemException catch (_) {
// File doesn't exist, so return null.
return null;
}
}
void _setPollingDelayToLong() {
_timer?.cancel();
_timer = _timerFactory(_pollingDelayLong, (_) => _poll());
}
void _setPollingDelayToShort() {
_timer?.cancel();
_timer = _timerFactory(_pollingDelayShort, (_) => _poll());
}
}
/// The [UriResolver] that can resolve `package` URIs in [BazelWorkspace].
class BazelPackageUriResolver extends UriResolver {
final BazelWorkspace _workspace;
@@ -189,9 +359,17 @@ class BazelWorkspace extends Workspace
/// The absolute path to the `bazel-genfiles` folder.
final String genfiles;
final _bazelCandidateFiles =
StreamController<BazelFileNotification>.broadcast();
BazelWorkspace._(
this.provider, this.root, this.readonly, this.binPaths, this.genfiles);
/// Stream of files that we tried to find along with their potential or actual
/// paths.
Stream<BazelFileNotification> get bazelCandidateFiles =>
_bazelCandidateFiles.stream;
@override
bool get isBazel => true;
@@ -224,17 +402,16 @@ class BazelWorkspace extends Workspace
if (relative == '.') {
return null;
}
// genfiles
if (genfiles != null) {
File file = provider.getFile(context.join(genfiles, relative));
if (file.exists) {
return file;
}
}
// bin
for (String bin in binPaths) {
File file = provider.getFile(context.join(bin, relative));
// First check genfiles and bin directories
var generatedCandidates = <String>[
if (genfiles != null) genfiles,
...?binPaths
].map((prefix) => context.join(prefix, relative));
for (var path in generatedCandidates) {
File file = provider.getFile(path);
if (file.exists) {
_bazelCandidateFiles.add(BazelFileNotification(
relative, generatedCandidates.toList(), provider));
return file;
}
}
@@ -250,6 +427,10 @@ class BazelWorkspace extends Workspace
return file;
}
}
// If we couldn't find the file, assume that it has not yet been
// generated, so send an event with all the paths that we tried.
_bazelCandidateFiles.add(BazelFileNotification(
relative, generatedCandidates.toList(), provider));
// Not generated, return the default one.
return writableFile;
} catch (_) {
@@ -613,3 +794,9 @@ class BazelWorkspacePackage extends WorkspacePackage {
}
}
}
class FileInfo {
String path;
int modified;
FileInfo(this.path, this.modified);
}
+1
View File
@@ -24,6 +24,7 @@ dependencies:
dev_dependencies:
analyzer_utilities:
path: ../analyzer_utilities
async: ^2.0.0
linter: any
matcher: ^0.12.3
pedantic: ^1.9.0
@@ -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 'package:analyzer/src/generated/source.dart';
import 'package:analyzer/src/summary/package_bundle_reader.dart';
import 'package:analyzer/src/test_utilities/resource_provider_mixin.dart';
import 'package:analyzer/src/workspace/bazel.dart';
import 'package:async/async.dart';
import 'package:meta/meta.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import 'package:watcher/watcher.dart';
import '../../generated/test_support.dart';
@@ -766,6 +770,152 @@ class BazelWorkspacePackageTest with ResourceProviderMixin {
class BazelWorkspaceTest with ResourceProviderMixin {
BazelWorkspace workspace;
void test_bazelFileWatcher() async {
_addResources([
'/workspace/WORKSPACE',
]);
_MockTimer timer;
var timerFactory = (Duration _, void Function(Timer) callback) {
timer = _MockTimer(callback);
return timer;
};
var candidates = [
convertPath('/workspace/bazel-bin/my/module/test1.dart'),
convertPath('/workspace/bazel-genfiles/my/module/test1.dart'),
];
var watcher = BazelFileWatcher(candidates, resourceProvider, Duration.zero,
Duration.zero, timerFactory);
var events = StreamQueue(watcher.events);
watcher.start();
// First do some tests with the first candidate path.
_addResources([candidates[0]]);
timer.triggerCallback();
var event = await events.next;
expect(event.type, ChangeType.ADD);
expect(event.path, candidates[0]);
modifyFile(candidates[0], 'const foo = 42;');
timer.triggerCallback();
event = await events.next;
expect(event.type, ChangeType.MODIFY);
expect(event.path, candidates[0]);
_deleteResources([candidates[0]]);
timer.triggerCallback();
event = await events.next;
expect(event.type, ChangeType.REMOVE);
expect(event.path, candidates[0]);
// Now check that if we add the *second* candidate, we'll get the
// notification for it.
_addResources([candidates[1]]);
timer.triggerCallback();
event = await events.next;
expect(event.type, ChangeType.ADD);
expect(event.path, candidates[1]);
watcher.stop();
expect(await events.rest.isEmpty, true);
}
void test_bazelFileWatcher_existingFile() async {
_addResources([
'/workspace/WORKSPACE',
'/workspace/bazel-bin/my/module/test1.dart',
]);
BazelWorkspace workspace = BazelWorkspace.find(
resourceProvider, convertPath('/workspace/my/module'));
_MockTimer timer;
var timerFactory = (Duration _, void Function(Timer) callback) {
timer = _MockTimer(callback);
return timer;
};
var watcherCompleter = Completer<BazelFileWatcher>();
workspace.bazelCandidateFiles.listen((notification) =>
watcherCompleter.complete(notification.watcher(
pollingDelayLong: Duration.zero,
pollingDelayShort: Duration.zero,
timerFactory: timerFactory)));
var file1 =
workspace.findFile(convertPath('/workspace/my/module/test1.dart'));
expect(file1.exists, true);
var watcher = await watcherCompleter.future;
var events = StreamQueue(watcher.events);
watcher.start();
// Make sure that triggering the callback, will not generate extra events.
timer.triggerCallback();
var convertedPath =
convertPath('/workspace/bazel-bin/my/module/test1.dart');
// Change the file -- we should get a single MODIFY event and not an ADD
// event, since the file already existed.
modifyFile(convertedPath, 'const foo = 42;');
timer.triggerCallback();
var event = await events.next;
expect(event.type, ChangeType.MODIFY);
expect(event.path, convertedPath);
// But if we delete the file and then re-create it, we should get an ADD
// event (after the REMOVE one).
deleteFile(convertedPath);
timer.triggerCallback();
event = await events.next;
expect(event.type, ChangeType.REMOVE);
expect(event.path, convertedPath);
newFile(convertedPath);
timer.triggerCallback();
event = await events.next;
expect(event.type, ChangeType.ADD);
expect(event.path, convertedPath);
watcher.stop();
expect(await events.rest.isEmpty, true);
}
void test_bazelNotifications() async {
_addResources([
'/workspace/WORKSPACE',
'/workspace/bazel-bin/my/module/test1.dart',
]);
BazelWorkspace workspace = BazelWorkspace.find(
resourceProvider, convertPath('/workspace/my/module'));
var notifications = StreamQueue(workspace.bazelCandidateFiles);
var file1 =
workspace.findFile(convertPath('/workspace/my/module/test1.dart'));
expect(file1.exists, true);
var notification = await notifications.next;
expect(notification.requested, convertPath('my/module/test1.dart'));
expect(
notification.candidates,
containsAll(
[convertPath('/workspace/bazel-bin/my/module/test1.dart')]));
var file2 =
workspace.findFile(convertPath('/workspace/my/module/test2.dart'));
expect(file2.exists, false);
notification = await notifications.next;
expect(notification.requested, convertPath('my/module/test2.dart'));
expect(
notification.candidates,
containsAll([
convertPath('/workspace/bazel-bin/my/module/test2.dart'),
convertPath('/workspace/bazel-genfiles/my/module/test2.dart'),
]));
}
void test_find_fail_notAbsolute() {
expect(
() =>
@@ -1015,6 +1165,17 @@ class BazelWorkspaceTest with ResourceProviderMixin {
}
}
/// Create new files and directories from [paths].
void _deleteResources(List<String> paths) {
for (String path in paths) {
if (path.endsWith('/')) {
deleteFolder(path.substring(0, path.length - 1));
} else {
deleteFile(path);
}
}
}
/// Expect that [BazelWorkspace.findFile], given [path], returns [equals].
void _expectFindFile(String path, {@required String equals}) =>
expect(workspace.findFile(convertPath(path)).path, convertPath(equals));
@@ -1031,3 +1192,20 @@ class _MockSource implements Source {
throw StateError('Unexpected invocation of ${invocation.memberName}');
}
}
class _MockTimer implements Timer {
final void Function(Timer) callback;
@override
bool isActive = true;
_MockTimer(this.callback);
@override
int get tick => throw UnimplementedError();
@override
void cancel() => isActive = false;
void triggerCallback() => callback(this);
}