| // Copyright (c) 2017, 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:convert'; | |
| import 'dart:io' show Platform, Process; | |
| import 'package:analysis_server/src/analytics/percentile_calculator.dart'; | |
| import 'package:analysis_server/src/plugin/notification_manager.dart'; | |
| import 'package:analyzer/dart/analysis/context_root.dart' as analyzer; | |
| import 'package:analyzer/exception/exception.dart'; | |
| import 'package:analyzer/file_system/file_system.dart'; | |
| import 'package:analyzer/instrumentation/instrumentation.dart'; | |
| import 'package:analyzer/src/generated/engine.dart'; | |
| import 'package:analyzer/src/generated/source.dart'; | |
| import 'package:analyzer/src/test_utilities/package_config_file_builder.dart'; | |
| import 'package:analyzer/src/util/file_paths.dart' as file_paths; | |
| import 'package:analyzer/src/util/glob.dart'; | |
| import 'package:analyzer/src/workspace/blaze.dart'; | |
| import 'package:analyzer/src/workspace/workspace.dart'; | |
| import 'package:analyzer_plugin/channel/channel.dart'; | |
| import 'package:analyzer_plugin/protocol/protocol.dart'; | |
| import 'package:analyzer_plugin/protocol/protocol_common.dart'; | |
| import 'package:analyzer_plugin/protocol/protocol_constants.dart'; | |
| import 'package:analyzer_plugin/protocol/protocol_generated.dart'; | |
| import 'package:analyzer_plugin/src/channel/isolate_channel.dart'; | |
| import 'package:analyzer_plugin/src/protocol/protocol_internal.dart'; | |
| import 'package:convert/convert.dart'; | |
| import 'package:crypto/crypto.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'package:watcher/watcher.dart' as watcher; | |
| import 'package:yaml/yaml.dart'; | |
| /// Information about a plugin that is built-in. | |
| class BuiltInPluginInfo extends PluginInfo { | |
| /// The entry point function that will be executed in the plugin's isolate. | |
| final EntryPoint entryPoint; | |
| @override | |
| final String pluginId; | |
| /// Initialize a newly created built-in plugin. | |
| BuiltInPluginInfo( | |
| this.entryPoint, | |
| this.pluginId, | |
| AbstractNotificationManager notificationManager, | |
| InstrumentationService instrumentationService) | |
| : super(notificationManager, instrumentationService); | |
| @override | |
| ServerCommunicationChannel _createChannel() { | |
| return ServerIsolateChannel.builtIn( | |
| entryPoint, pluginId, instrumentationService); | |
| } | |
| } | |
| /// Information about a plugin that was discovered. | |
| class DiscoveredPluginInfo extends PluginInfo { | |
| /// The path to the root directory of the definition of the plugin on disk | |
| /// (the directory containing the 'pubspec.yaml' file and the 'bin' | |
| /// directory). | |
| final String path; | |
| /// The path to the 'plugin.dart' file that will be executed in an isolate. | |
| final String executionPath; | |
| /// The path to the '.packages' file used to control the resolution of | |
| /// 'package:' URIs. | |
| final String packagesPath; | |
| /// Initialize the newly created information about a plugin. | |
| DiscoveredPluginInfo( | |
| this.path, | |
| this.executionPath, | |
| this.packagesPath, | |
| AbstractNotificationManager notificationManager, | |
| InstrumentationService instrumentationService) | |
| : super(notificationManager, instrumentationService); | |
| @override | |
| bool get canBeStarted => executionPath.isNotEmpty; | |
| @override | |
| String get pluginId => path; | |
| @override | |
| ServerCommunicationChannel _createChannel() { | |
| return ServerIsolateChannel.discovered( | |
| Uri.file(executionPath, windows: Platform.isWindows), | |
| Uri.file(packagesPath, windows: Platform.isWindows), | |
| instrumentationService); | |
| } | |
| } | |
| /// An indication of a problem with the execution of a plugin that occurs prior | |
| /// to the execution of the plugin's entry point in an isolate. | |
| class PluginException implements Exception { | |
| /// A message describing the problem. | |
| final String message; | |
| /// Initialize a newly created exception to have the given [message]. | |
| PluginException(this.message); | |
| @override | |
| String toString() => message; | |
| } | |
| class PluginFiles { | |
| final File execution; | |
| final File packages; | |
| PluginFiles(this.execution, this.packages); | |
| } | |
| /// Information about a single plugin. | |
| abstract class PluginInfo { | |
| /// The object used to manage the receiving and sending of notifications. | |
| final AbstractNotificationManager notificationManager; | |
| /// The instrumentation service that is being used by the analysis server. | |
| final InstrumentationService instrumentationService; | |
| /// The context roots that are currently using the results produced by the | |
| /// plugin. | |
| Set<analyzer.ContextRoot> contextRoots = HashSet<analyzer.ContextRoot>(); | |
| /// The current execution of the plugin, or `null` if the plugin is not | |
| /// currently being executed. | |
| PluginSession? currentSession; | |
| CaughtException? _exception; | |
| /// Initialize the newly created information about a plugin. | |
| PluginInfo(this.notificationManager, this.instrumentationService); | |
| /// Return `true` if this plugin can be started, or `false` if there is a | |
| /// reason why it cannot be started. For example, a plugin cannot be started | |
| /// if there was an error with a previous attempt to start running it or if | |
| /// the plugin is not correctly configured. | |
| bool get canBeStarted => true; | |
| /// Return the data known about this plugin. | |
| PluginData get data => | |
| PluginData(pluginId, currentSession?.name, currentSession?.version); | |
| /// The exception that occurred that prevented the plugin from being started, | |
| /// or `null` if there was no exception (possibly because no attempt has yet | |
| /// been made to start the plugin). | |
| CaughtException? get exception => _exception; | |
| /// Return the id of this plugin, used to identify the plugin to users. | |
| String get pluginId; | |
| /// Add the given [contextRoot] to the set of context roots being analyzed by | |
| /// this plugin. | |
| void addContextRoot(analyzer.ContextRoot contextRoot) { | |
| if (contextRoots.add(contextRoot)) { | |
| _updatePluginRoots(); | |
| } | |
| } | |
| /// Add the given context [roots] to the set of context roots being analyzed | |
| /// by this plugin. | |
| void addContextRoots(Iterable<analyzer.ContextRoot> roots) { | |
| var changed = false; | |
| for (var contextRoot in roots) { | |
| if (contextRoots.add(contextRoot)) { | |
| changed = true; | |
| } | |
| } | |
| if (changed) { | |
| _updatePluginRoots(); | |
| } | |
| } | |
| /// Return `true` if at least one of the context roots being analyzed contains | |
| /// the file with the given [filePath]. | |
| bool isAnalyzing(String filePath) { | |
| for (var contextRoot in contextRoots) { | |
| if (contextRoot.isAnalyzed(filePath)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /// Remove the given [contextRoot] from the set of context roots being | |
| /// analyzed by this plugin. | |
| void removeContextRoot(analyzer.ContextRoot contextRoot) { | |
| if (contextRoots.remove(contextRoot)) { | |
| _updatePluginRoots(); | |
| } | |
| } | |
| void reportException(CaughtException exception) { | |
| _exception = exception; | |
| instrumentationService.logPluginException( | |
| data, exception.exception, exception.stackTrace); | |
| } | |
| /// If the plugin is currently running, send a request based on the given | |
| /// [params] to the plugin. If the plugin is not running, the request will | |
| /// silently be dropped. | |
| void sendRequest(RequestParams params) { | |
| currentSession?.sendRequest(params); | |
| } | |
| /// Start a new isolate that is running the plugin. Return the state object | |
| /// used to interact with the plugin, or `null` if the plugin could not be | |
| /// run. | |
| Future<PluginSession?> start(String? byteStorePath, String sdkPath) async { | |
| if (currentSession != null) { | |
| throw StateError('Cannot start a plugin that is already running.'); | |
| } | |
| currentSession = PluginSession(this); | |
| var isRunning = await currentSession!.start(byteStorePath, sdkPath); | |
| if (!isRunning) { | |
| currentSession = null; | |
| } | |
| return currentSession; | |
| } | |
| /// Request that the plugin shutdown. | |
| Future<void> stop() { | |
| if (currentSession == null) { | |
| if (_exception != null) { | |
| // Plugin crashed, nothing to do. | |
| return Future<void>.value(null); | |
| } | |
| throw StateError('Cannot stop a plugin that is not running.'); | |
| } | |
| var doneFuture = currentSession!.stop(); | |
| currentSession = null; | |
| return doneFuture; | |
| } | |
| /// Create and return the channel used to communicate with the server. | |
| ServerCommunicationChannel _createChannel(); | |
| /// Update the context roots that the plugin should be analyzing. | |
| void _updatePluginRoots() { | |
| final currentSession = this.currentSession; | |
| if (currentSession != null) { | |
| var params = AnalysisSetContextRootsParams(contextRoots | |
| .map((analyzer.ContextRoot contextRoot) => ContextRoot( | |
| contextRoot.root.path, contextRoot.excludedPaths.toList(), | |
| optionsFile: contextRoot.optionsFile?.path)) | |
| .toList()); | |
| currentSession.sendRequest(params); | |
| } | |
| } | |
| } | |
| /// An object used to manage the currently running plugins. | |
| class PluginManager { | |
| /// A table, keyed by both a plugin and a request method, to a list of the | |
| /// times that it took the plugin to return a response to requests with the | |
| /// method. | |
| static Map<PluginInfo, Map<String, PercentileCalculator>> | |
| pluginResponseTimes = <PluginInfo, Map<String, PercentileCalculator>>{}; | |
| /// The console environment key used by the pub tool. | |
| static const String _pubEnvironmentKey = 'PUB_ENVIRONMENT'; | |
| /// The resource provider used to access the file system. | |
| final ResourceProvider resourceProvider; | |
| /// The absolute path of the directory containing the on-disk byte store, or | |
| /// `null` if there is no on-disk store. | |
| final String? byteStorePath; | |
| /// The absolute path of the directory containing the SDK. | |
| final String sdkPath; | |
| /// The object used to manage the receiving and sending of notifications. | |
| final AbstractNotificationManager notificationManager; | |
| /// The instrumentation service that is being used by the analysis server. | |
| final InstrumentationService instrumentationService; | |
| /// A table mapping the paths of plugins to information about those plugins. | |
| final Map<String, PluginInfo> _pluginMap = <String, PluginInfo>{}; | |
| /// The parameters for the last 'analysis.setPriorityFiles' request that was | |
| /// received from the client. Because plugins are lazily discovered, this | |
| /// needs to be retained so that it can be sent after a plugin has been | |
| /// started. | |
| AnalysisSetPriorityFilesParams? _analysisSetPriorityFilesParams; | |
| /// The parameters for the last 'analysis.setSubscriptions' request that was | |
| /// received from the client. Because plugins are lazily discovered, this | |
| /// needs to be retained so that it can be sent after a plugin has been | |
| /// started. | |
| AnalysisSetSubscriptionsParams? _analysisSetSubscriptionsParams; | |
| /// The current state of content overlays. Because plugins are lazily | |
| /// discovered, the state needs to be retained so that it can be sent after a | |
| /// plugin has been started. | |
| final Map<String, AddContentOverlay> _overlayState = {}; | |
| final StreamController<void> _pluginsChanged = StreamController.broadcast(); | |
| /// Initialize a newly created plugin manager. The notifications from the | |
| /// running plugins will be handled by the given [notificationManager]. | |
| PluginManager(this.resourceProvider, this.byteStorePath, this.sdkPath, | |
| this.notificationManager, this.instrumentationService); | |
| /// Return a list of all of the plugins that are currently known. | |
| List<PluginInfo> get plugins => _pluginMap.values.toList(); | |
| /// Stream emitting an event when known [plugins] change. | |
| Stream<void> get pluginsChanged => _pluginsChanged.stream; | |
| /// Add the plugin with the given [path] to the list of plugins that should be | |
| /// used when analyzing code for the given [contextRoot]. If the plugin had | |
| /// not yet been started, then it will be started by this method. | |
| Future<void> addPluginToContextRoot( | |
| analyzer.ContextRoot contextRoot, String path) async { | |
| var plugin = _pluginMap[path]; | |
| var isNew = false; | |
| if (plugin == null) { | |
| isNew = true; | |
| PluginFiles pluginFiles; | |
| try { | |
| pluginFiles = filesFor(path); | |
| } catch (exception, stackTrace) { | |
| plugin = DiscoveredPluginInfo( | |
| path, '', '', notificationManager, instrumentationService); | |
| plugin.reportException(CaughtException(exception, stackTrace)); | |
| _pluginMap[path] = plugin; | |
| return; | |
| } | |
| plugin = DiscoveredPluginInfo( | |
| path, | |
| pluginFiles.execution.path, | |
| pluginFiles.packages.path, | |
| notificationManager, | |
| instrumentationService); | |
| _pluginMap[path] = plugin; | |
| try { | |
| var session = await plugin.start(byteStorePath, sdkPath); | |
| unawaited(session?.onDone.then((_) { | |
| if (_pluginMap[path] == plugin) { | |
| _pluginMap.remove(path); | |
| _notifyPluginsChanged(); | |
| } | |
| })); | |
| } catch (exception, stackTrace) { | |
| // Record the exception (for debugging purposes) and record the fact | |
| // that we should not try to communicate with the plugin. | |
| plugin.reportException(CaughtException(exception, stackTrace)); | |
| isNew = false; | |
| } | |
| _notifyPluginsChanged(); | |
| } | |
| plugin.addContextRoot(contextRoot); | |
| if (isNew) { | |
| var analysisSetSubscriptionsParams = _analysisSetSubscriptionsParams; | |
| if (analysisSetSubscriptionsParams != null) { | |
| plugin.sendRequest(analysisSetSubscriptionsParams); | |
| } | |
| if (_overlayState.isNotEmpty) { | |
| plugin.sendRequest(AnalysisUpdateContentParams(_overlayState)); | |
| } | |
| var analysisSetPriorityFilesParams = _analysisSetPriorityFilesParams; | |
| if (analysisSetPriorityFilesParams != null) { | |
| plugin.sendRequest(analysisSetPriorityFilesParams); | |
| } | |
| } | |
| } | |
| /// Broadcast a request built from the given [params] to all of the plugins | |
| /// that are currently associated with the given [contextRoot]. Return a list | |
| /// containing futures that will complete when each of the plugins have sent a | |
| /// response. | |
| Map<PluginInfo, Future<Response>> broadcastRequest(RequestParams params, | |
| {analyzer.ContextRoot? contextRoot}) { | |
| var plugins = pluginsForContextRoot(contextRoot); | |
| var responseMap = <PluginInfo, Future<Response>>{}; | |
| for (var plugin in plugins) { | |
| final request = plugin.currentSession?.sendRequest(params); | |
| // Only add an entry to the map if we have sent a request. | |
| if (request != null) { | |
| responseMap[plugin] = request; | |
| } | |
| } | |
| return responseMap; | |
| } | |
| /// Broadcast the given [watchEvent] to all of the plugins that are analyzing | |
| /// in contexts containing the file associated with the event. Return a list | |
| /// containing futures that will complete when each of the plugins have sent a | |
| /// response. | |
| Future<List<Future<Response>>> broadcastWatchEvent( | |
| watcher.WatchEvent watchEvent) async { | |
| var filePath = watchEvent.path; | |
| /// Return `true` if the given glob [pattern] matches the file being | |
| /// watched. | |
| bool matches(String pattern) => | |
| Glob(resourceProvider.pathContext.separator, pattern).matches(filePath); | |
| WatchEvent? event; | |
| var responses = <Future<Response>>[]; | |
| for (var plugin in _pluginMap.values) { | |
| var session = plugin.currentSession; | |
| var interestingFiles = session?.interestingFiles; | |
| if (session != null && | |
| plugin.isAnalyzing(filePath) && | |
| interestingFiles != null && | |
| interestingFiles.any(matches)) { | |
| // The list of interesting file globs is `null` if the plugin has not | |
| // yet responded to the plugin.versionCheck request. If that happens | |
| // then the plugin hasn't had a chance to analyze anything yet, and | |
| // hence it does not needed to get watch events. | |
| event ??= _convertWatchEvent(watchEvent); | |
| var params = AnalysisHandleWatchEventsParams([event]); | |
| responses.add(session.sendRequest(params)); | |
| } | |
| } | |
| return responses; | |
| } | |
| /// Return the files associated with the plugin at the given [pluginPath]. | |
| /// Throw a [PluginException] if there is a problem that prevents the plugin | |
| /// from being executing. | |
| @visibleForTesting | |
| PluginFiles filesFor(String pluginPath) { | |
| var pluginFolder = resourceProvider.getFolder(pluginPath); | |
| var pubspecFile = pluginFolder.getChildAssumingFile(file_paths.pubspecYaml); | |
| if (!pubspecFile.exists) { | |
| // If there's no pubspec file, then we don't need to copy the package | |
| // because we won't be running pub. | |
| return _computeFiles(pluginFolder); | |
| } | |
| var workspace = BlazeWorkspace.find(resourceProvider, pluginFolder.path); | |
| if (workspace != null) { | |
| // Similarly, we won't be running pub if we're in a workspace because | |
| // there is exactly one version of each package. | |
| return _computeFiles(pluginFolder, workspace: workspace); | |
| } | |
| // | |
| // Copy the plugin directory to a unique subdirectory of the plugin | |
| // manager's state location. The subdirectory's name is selected such that | |
| // it will be invariant across sessions, reducing the number of times the | |
| // plugin will need to be copied and pub will need to be run. | |
| // | |
| var stateFolder = resourceProvider.getStateLocation('.plugin_manager'); | |
| if (stateFolder == null) { | |
| throw PluginException('No state location, so plugin could not be copied'); | |
| } | |
| var stateName = _uniqueDirectoryName(pluginPath); | |
| var parentFolder = stateFolder.getChildAssumingFolder(stateName); | |
| if (parentFolder.exists) { | |
| var executionFolder = | |
| parentFolder.getChildAssumingFolder(pluginFolder.shortName); | |
| return _computeFiles(executionFolder, pubCommand: 'upgrade'); | |
| } | |
| var executionFolder = pluginFolder.copyTo(parentFolder); | |
| return _computeFiles(executionFolder, pubCommand: 'get'); | |
| } | |
| /// Return a list of all of the plugins that are currently associated with the | |
| /// given [contextRoot]. | |
| @visibleForTesting | |
| List<PluginInfo> pluginsForContextRoot(analyzer.ContextRoot? contextRoot) { | |
| if (contextRoot == null) { | |
| return _pluginMap.values.toList(); | |
| } | |
| var plugins = <PluginInfo>[]; | |
| for (var plugin in _pluginMap.values) { | |
| if (plugin.contextRoots.contains(contextRoot)) { | |
| plugins.add(plugin); | |
| } | |
| } | |
| return plugins; | |
| } | |
| /// Record a failure to run the plugin associated with the host package with | |
| /// the given [hostPackageName]. The failure is described by the [message], | |
| /// and is expected to have occurred before a path could be computed, and | |
| /// hence before [addPluginToContextRoot] could be invoked. | |
| void recordPluginFailure(String hostPackageName, String message) { | |
| try { | |
| throw PluginException(message); | |
| } catch (exception, stackTrace) { | |
| var pluginPath = path.join(hostPackageName, 'tools', 'analyzer_plugin'); | |
| var plugin = DiscoveredPluginInfo( | |
| pluginPath, '', '', notificationManager, instrumentationService); | |
| plugin.reportException(CaughtException(exception, stackTrace)); | |
| _pluginMap[pluginPath] = plugin; | |
| } | |
| } | |
| /// The given [contextRoot] is no longer being analyzed. | |
| void removedContextRoot(analyzer.ContextRoot contextRoot) { | |
| var plugins = _pluginMap.values.toList(); | |
| for (var plugin in plugins) { | |
| plugin.removeContextRoot(contextRoot); | |
| if (plugin is DiscoveredPluginInfo && plugin.contextRoots.isEmpty) { | |
| _pluginMap.remove(plugin.path); | |
| _notifyPluginsChanged(); | |
| try { | |
| plugin.stop(); | |
| } catch (e, st) { | |
| AnalysisEngine.instance.instrumentationService | |
| .logException(SilentException('Issue stopping a plugin', e, st)); | |
| } | |
| } | |
| } | |
| } | |
| /// Restart all currently running plugins. | |
| Future<void> restartPlugins() async { | |
| for (var plugin in _pluginMap.values.toList()) { | |
| if (plugin.currentSession != null) { | |
| // | |
| // Capture needed state. | |
| // | |
| var contextRoots = plugin.contextRoots; | |
| var path = plugin.pluginId; | |
| // | |
| // Stop the plugin. | |
| // | |
| await plugin.stop(); | |
| // | |
| // Restart the plugin. | |
| // | |
| _pluginMap[path] = plugin; | |
| var session = await plugin.start(byteStorePath, sdkPath); | |
| unawaited(session?.onDone.then((_) { | |
| _pluginMap.remove(path); | |
| })); | |
| // | |
| // Re-initialize the plugin. | |
| // | |
| plugin.addContextRoots(contextRoots); | |
| var analysisSetSubscriptionsParams = _analysisSetSubscriptionsParams; | |
| if (analysisSetSubscriptionsParams != null) { | |
| plugin.sendRequest(analysisSetSubscriptionsParams); | |
| } | |
| if (_overlayState.isNotEmpty) { | |
| plugin.sendRequest(AnalysisUpdateContentParams(_overlayState)); | |
| } | |
| var analysisSetPriorityFilesParams = _analysisSetPriorityFilesParams; | |
| if (analysisSetPriorityFilesParams != null) { | |
| plugin.sendRequest(analysisSetPriorityFilesParams); | |
| } | |
| } | |
| } | |
| } | |
| /// Send a request based on the given [params] to existing plugins to set the | |
| /// priority files to those specified by the [params]. As a side-effect, | |
| /// record the parameters so that they can be sent to any newly started | |
| /// plugins. | |
| void setAnalysisSetPriorityFilesParams( | |
| AnalysisSetPriorityFilesParams params) { | |
| for (var plugin in _pluginMap.values) { | |
| plugin.sendRequest(params); | |
| } | |
| _analysisSetPriorityFilesParams = params; | |
| } | |
| /// Send a request based on the given [params] to existing plugins to set the | |
| /// subscriptions to those specified by the [params]. As a side-effect, record | |
| /// the parameters so that they can be sent to any newly started plugins. | |
| void setAnalysisSetSubscriptionsParams( | |
| AnalysisSetSubscriptionsParams params) { | |
| for (var plugin in _pluginMap.values) { | |
| plugin.sendRequest(params); | |
| } | |
| _analysisSetSubscriptionsParams = params; | |
| } | |
| /// Send a request based on the given [params] to existing plugins to set the | |
| /// content overlays to those specified by the [params]. As a side-effect, | |
| /// update the overlay state so that it can be sent to any newly started | |
| /// plugins. | |
| void setAnalysisUpdateContentParams(AnalysisUpdateContentParams params) { | |
| for (var plugin in _pluginMap.values) { | |
| plugin.sendRequest(params); | |
| } | |
| var files = params.files; | |
| for (var file in files.keys) { | |
| var overlay = files[file]; | |
| if (overlay is RemoveContentOverlay) { | |
| _overlayState.remove(file); | |
| } else if (overlay is AddContentOverlay) { | |
| _overlayState[file] = overlay; | |
| } else if (overlay is ChangeContentOverlay) { | |
| var previousOverlay = _overlayState[file]!; | |
| var newContent = | |
| SourceEdit.applySequence(previousOverlay.content, overlay.edits); | |
| _overlayState[file] = AddContentOverlay(newContent); | |
| } else { | |
| throw ArgumentError('Invalid class of overlay: ${overlay.runtimeType}'); | |
| } | |
| } | |
| } | |
| /// Stop all of the plugins that are currently running. | |
| Future<List<void>> stopAll() { | |
| return Future.wait(_pluginMap.values.map((PluginInfo info) async { | |
| try { | |
| await info.stop(); | |
| } catch (e, st) { | |
| AnalysisEngine.instance.instrumentationService.logException(e, st); | |
| } | |
| })); | |
| } | |
| /// Compute the files to be returned by the enclosing method given that the | |
| /// plugin should exist in the given [pluginFolder]. | |
| /// | |
| /// Runs pub if [pubCommand] is provided and not null. | |
| PluginFiles _computeFiles(Folder pluginFolder, | |
| {String? pubCommand, Workspace? workspace}) { | |
| var pluginFile = pluginFolder | |
| .getChildAssumingFolder('bin') | |
| .getChildAssumingFile('plugin.dart'); | |
| if (!pluginFile.exists) { | |
| throw PluginException('File "${pluginFile.path}" does not exist.'); | |
| } | |
| String? reason; | |
| File? packagesFile = pluginFolder | |
| .getChildAssumingFolder(file_paths.dotDartTool) | |
| .getChildAssumingFile(file_paths.packageConfigJson); | |
| if (pubCommand != null) { | |
| var result = Process.runSync( | |
| Platform.executable, <String>['pub', pubCommand], | |
| stderrEncoding: utf8, | |
| stdoutEncoding: utf8, | |
| workingDirectory: pluginFolder.path, | |
| environment: {_pubEnvironmentKey: _getPubEnvironmentValue()}); | |
| if (result.exitCode != 0) { | |
| var buffer = StringBuffer(); | |
| buffer.writeln('Failed to run pub $pubCommand'); | |
| buffer.writeln(' pluginFolder = ${pluginFolder.path}'); | |
| buffer.writeln(' exitCode = ${result.exitCode}'); | |
| buffer.writeln(' stdout = ${result.stdout}'); | |
| buffer.writeln(' stderr = ${result.stderr}'); | |
| reason = buffer.toString(); | |
| instrumentationService.logError(reason); | |
| } | |
| if (!packagesFile.exists) { | |
| reason ??= 'File "${packagesFile.path}" does not exist.'; | |
| packagesFile = null; | |
| } | |
| } else if (!packagesFile.exists) { | |
| if (workspace != null) { | |
| packagesFile = | |
| _createPackagesFile(pluginFolder, workspace.packageUriResolver); | |
| if (packagesFile == null) { | |
| var name = file_paths.packageConfigJson; | |
| reason = 'Could not create $name file in workspace $workspace.'; | |
| } | |
| } else { | |
| reason = 'Could not create "${packagesFile.path}".'; | |
| packagesFile = null; | |
| } | |
| } | |
| if (packagesFile == null) { | |
| reason ??= 'Could not create packages file for an unknown reason.'; | |
| throw PluginException(reason); | |
| } | |
| return PluginFiles(pluginFile, packagesFile); | |
| } | |
| WatchEventType _convertChangeType(watcher.ChangeType type) { | |
| switch (type) { | |
| case watcher.ChangeType.ADD: | |
| return WatchEventType.ADD; | |
| case watcher.ChangeType.MODIFY: | |
| return WatchEventType.MODIFY; | |
| case watcher.ChangeType.REMOVE: | |
| return WatchEventType.REMOVE; | |
| default: | |
| throw StateError('Unknown change type: $type'); | |
| } | |
| } | |
| WatchEvent _convertWatchEvent(watcher.WatchEvent watchEvent) { | |
| return WatchEvent(_convertChangeType(watchEvent.type), watchEvent.path); | |
| } | |
| /// Return a temporary `package_config.json` file that is appropriate for | |
| /// the plugin in the given [pluginFolder]. The [packageUriResolver] is | |
| /// used to determine the location of the packages that need to be included | |
| /// in the packages file. | |
| File? _createPackagesFile( | |
| Folder pluginFolder, UriResolver packageUriResolver) { | |
| var pluginPath = pluginFolder.path; | |
| var stateFolder = resourceProvider.getStateLocation('.plugin_manager')!; | |
| var stateName = '${_uniqueDirectoryName(pluginPath)}.packages'; | |
| var packagesFile = stateFolder.getChildAssumingFile(stateName); | |
| if (!packagesFile.exists) { | |
| var pluginPubspec = | |
| pluginFolder.getChildAssumingFile(file_paths.pubspecYaml); | |
| if (!pluginPubspec.exists) { | |
| return null; | |
| } | |
| try { | |
| var visitedPackageNames = <String>{}; | |
| var packages = <_Package>[]; | |
| var context = resourceProvider.pathContext; | |
| packages.add( | |
| _Package( | |
| context.basename(pluginPath), | |
| pluginFolder, | |
| ), | |
| ); | |
| var pubspecFiles = <File>[]; | |
| pubspecFiles.add(pluginPubspec); | |
| while (pubspecFiles.isNotEmpty) { | |
| var pubspecFile = pubspecFiles.removeLast(); | |
| for (var packageName in _readDependencies(pubspecFile)) { | |
| if (visitedPackageNames.add(packageName)) { | |
| var uri = Uri.parse('package:$packageName/$packageName.dart'); | |
| var packageSource = packageUriResolver.resolveAbsolute(uri); | |
| if (packageSource != null) { | |
| var packageRoot = resourceProvider | |
| .getFile(packageSource.fullName) | |
| .parent | |
| .parent; | |
| packages.add( | |
| _Package(packageName, packageRoot), | |
| ); | |
| pubspecFiles.add( | |
| packageRoot.getChildAssumingFile(file_paths.pubspecYaml), | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| packages.sort((a, b) => a.name.compareTo(b.name)); | |
| var packageConfigBuilder = PackageConfigFileBuilder(); | |
| for (var package in packages) { | |
| packageConfigBuilder.add( | |
| name: package.name, | |
| rootPath: package.root.path, | |
| ); | |
| } | |
| packagesFile.writeAsStringSync( | |
| packageConfigBuilder.toContent( | |
| toUriStr: (path) { | |
| return resourceProvider.pathContext.toUri(path).toString(); | |
| }, | |
| ), | |
| ); | |
| } catch (exception) { | |
| // If we are not able to produce a .packages file, return null so that | |
| // callers will not try to load the plugin. | |
| return null; | |
| } | |
| } | |
| return packagesFile; | |
| } | |
| void _notifyPluginsChanged() => _pluginsChanged.add(null); | |
| /// Return the names of packages that are listed as dependencies in the given | |
| /// [pubspecFile]. | |
| Iterable<String> _readDependencies(File pubspecFile) { | |
| var document = loadYamlDocument(pubspecFile.readAsStringSync(), | |
| sourceUrl: pubspecFile.toUri()); | |
| var contents = document.contents; | |
| if (contents is YamlMap) { | |
| var dependencies = contents['dependencies'] as YamlNode?; | |
| if (dependencies is YamlMap) { | |
| return dependencies.keys.cast<String>(); | |
| } | |
| } | |
| return const <String>[]; | |
| } | |
| /// Return a hex-encoded MD5 signature of the given file [path]. | |
| String _uniqueDirectoryName(String path) { | |
| var bytes = md5.convert(path.codeUnits).bytes; | |
| return hex.encode(bytes); | |
| } | |
| /// Record the fact that the given [plugin] responded to a request with the | |
| /// given [method] in the given [time]. | |
| static void recordResponseTime(PluginInfo plugin, String method, int time) { | |
| pluginResponseTimes | |
| .putIfAbsent(plugin, () => <String, PercentileCalculator>{}) | |
| .putIfAbsent(method, () => PercentileCalculator()) | |
| .addValue(time); | |
| } | |
| /// Returns the environment value that should be used when running pub. | |
| /// | |
| /// Includes any existing environment value, if one exists. | |
| static String _getPubEnvironmentValue() { | |
| // DO NOT update this function without contacting kevmoo. | |
| // We have server-side tooling that assumes the values are consistent. | |
| var values = <String>[]; | |
| var existing = Platform.environment[_pubEnvironmentKey]; | |
| // If there is an existing value for this var, make sure to include it. | |
| if ((existing != null) && existing.isNotEmpty) { | |
| values.add(existing); | |
| } | |
| values.add('analysis_server.plugin_manager'); | |
| return values.join(':'); | |
| } | |
| } | |
| /// Information about the execution a single plugin. | |
| @visibleForTesting | |
| class PluginSession { | |
| /// The maximum number of milliseconds that server should wait for a response | |
| /// from a plugin before deciding that the plugin is hung. | |
| static const Duration MAXIMUM_RESPONSE_TIME = Duration(minutes: 2); | |
| /// The length of time to wait after sending a 'plugin.shutdown' request | |
| /// before a failure to terminate will cause the isolate to be killed. | |
| static const Duration WAIT_FOR_SHUTDOWN_DURATION = Duration(seconds: 10); | |
| /// The information about the plugin being executed. | |
| final PluginInfo info; | |
| /// The completer used to signal when the plugin has stopped. | |
| Completer<void> pluginStoppedCompleter = Completer<void>(); | |
| /// The channel used to communicate with the plugin. | |
| ServerCommunicationChannel? channel; | |
| /// The index of the next request to be sent to the plugin. | |
| int requestId = 0; | |
| /// A table mapping the id's of requests to the functions used to handle the | |
| /// response to those requests. | |
| @visibleForTesting | |
| // ignore: library_private_types_in_public_api | |
| Map<String, _PendingRequest> pendingRequests = <String, _PendingRequest>{}; | |
| /// A boolean indicating whether the plugin is compatible with the version of | |
| /// the plugin API being used by this server. | |
| bool isCompatible = true; | |
| /// The contact information to include when reporting problems related to the | |
| /// plugin. | |
| String? contactInfo; | |
| /// The glob patterns of files that the plugin is interested in knowing about. | |
| List<String>? interestingFiles; | |
| /// The name to be used when reporting problems related to the plugin. | |
| String? name; | |
| /// The version number to be used when reporting problems related to the | |
| /// plugin. | |
| String? version; | |
| /// Initialize the newly created information about the execution of a plugin. | |
| PluginSession(this.info); | |
| /// Return the next request id, encoded as a string and increment the id so | |
| /// that a different result will be returned on each invocation. | |
| String get nextRequestId => (requestId++).toString(); | |
| /// Return a future that will complete when the plugin has stopped. | |
| Future<void> get onDone => pluginStoppedCompleter.future; | |
| /// Handle the given [notification]. | |
| void handleNotification(Notification notification) { | |
| if (notification.event == PLUGIN_NOTIFICATION_ERROR) { | |
| var params = PluginErrorParams.fromNotification(notification); | |
| if (params.isFatal) { | |
| info.stop(); | |
| stop(); | |
| } | |
| } | |
| info.notificationManager | |
| .handlePluginNotification(info.pluginId, notification); | |
| } | |
| /// Handle the fact that the plugin has stopped. | |
| void handleOnDone() { | |
| if (channel != null) { | |
| channel!.close(); | |
| channel = null; | |
| } | |
| pluginStoppedCompleter.complete(null); | |
| } | |
| /// Handle the fact that an unhandled error has occurred in the plugin. | |
| void handleOnError(dynamic error) { | |
| var errorPair = (error as List).cast<String>(); | |
| var stackTrace = StackTrace.fromString(errorPair[1]); | |
| info.reportException( | |
| CaughtException(PluginException(errorPair[0]), stackTrace)); | |
| } | |
| /// Handle a [response] from the plugin by completing the future that was | |
| /// created when the request was sent. | |
| void handleResponse(Response response) { | |
| var requestData = pendingRequests.remove(response.id); | |
| if (requestData != null) { | |
| var responseTime = DateTime.now().millisecondsSinceEpoch; | |
| var duration = responseTime - requestData.requestTime; | |
| PluginManager.recordResponseTime(info, requestData.method, duration); | |
| var completer = requestData.completer; | |
| completer.complete(response); | |
| } | |
| } | |
| /// Return `true` if there are any requests that have not been responded to | |
| /// within the maximum allowed amount of time. | |
| bool isNonResponsive() { | |
| // TODO(brianwilkerson) Figure out when to invoke this method in order to | |
| // identify non-responsive plugins and kill them. | |
| var cutOffTime = DateTime.now().millisecondsSinceEpoch - | |
| MAXIMUM_RESPONSE_TIME.inMilliseconds; | |
| for (var requestData in pendingRequests.values) { | |
| if (requestData.requestTime < cutOffTime) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /// Send a request, based on the given [parameters]. Return a future that will | |
| /// complete when a response is received. | |
| Future<Response> sendRequest(RequestParams parameters) { | |
| final channel = this.channel; | |
| if (channel == null) { | |
| throw StateError('Cannot send a request to a plugin that has stopped.'); | |
| } | |
| var id = nextRequestId; | |
| var completer = Completer<Response>(); | |
| var requestTime = DateTime.now().millisecondsSinceEpoch; | |
| var request = parameters.toRequest(id); | |
| pendingRequests[id] = | |
| _PendingRequest(request.method, requestTime, completer); | |
| channel.sendRequest(request); | |
| return completer.future; | |
| } | |
| /// Start a new isolate that is running this plugin. The plugin will be sent | |
| /// the given [byteStorePath]. Return `true` if the plugin is compatible and | |
| /// running. | |
| Future<bool> start(String? byteStorePath, String sdkPath) async { | |
| if (channel != null) { | |
| throw StateError('Cannot start a plugin that is already running.'); | |
| } | |
| if (byteStorePath == null || byteStorePath.isEmpty) { | |
| throw StateError('Missing byte store path'); | |
| } | |
| if (!isCompatible) { | |
| info.reportException(CaughtException( | |
| PluginException('Plugin is not compatible.'), StackTrace.current)); | |
| return false; | |
| } | |
| if (!info.canBeStarted) { | |
| info.reportException(CaughtException( | |
| PluginException('Plugin cannot be started.'), StackTrace.current)); | |
| return false; | |
| } | |
| channel = info._createChannel(); | |
| // TODO(brianwilkerson) Determine if await is necessary, if so, change the | |
| // return type of `channel.listen` to `Future<void>`. | |
| await (channel!.listen(handleResponse, handleNotification, | |
| onDone: handleOnDone, onError: handleOnError) as dynamic); | |
| if (channel == null) { | |
| // If there is an error when starting the isolate, the channel will invoke | |
| // handleOnDone, which will cause `channel` to be set to `null`. | |
| info.reportException(CaughtException( | |
| PluginException('Unrecorded error while starting the plugin.'), | |
| StackTrace.current)); | |
| return false; | |
| } | |
| var response = await sendRequest( | |
| PluginVersionCheckParams(byteStorePath, sdkPath, '1.0.0-alpha.0')); | |
| var result = PluginVersionCheckResult.fromResponse(response); | |
| isCompatible = result.isCompatible; | |
| contactInfo = result.contactInfo; | |
| interestingFiles = result.interestingFiles; | |
| name = result.name; | |
| version = result.version; | |
| if (!isCompatible) { | |
| unawaited(sendRequest(PluginShutdownParams())); | |
| info.reportException(CaughtException( | |
| PluginException('Plugin is not compatible.'), StackTrace.current)); | |
| return false; | |
| } | |
| return true; | |
| } | |
| /// Request that the plugin shutdown. | |
| Future<void> stop() { | |
| if (channel == null) { | |
| throw StateError('Cannot stop a plugin that is not running.'); | |
| } | |
| sendRequest(PluginShutdownParams()); | |
| Future.delayed(WAIT_FOR_SHUTDOWN_DURATION, () { | |
| if (channel != null) { | |
| channel?.kill(); | |
| channel = null; | |
| } | |
| }); | |
| return pluginStoppedCompleter.future; | |
| } | |
| } | |
| class _Package { | |
| final String name; | |
| final Folder root; | |
| _Package(this.name, this.root); | |
| } | |
| /// Information about a request that has been sent but for which a response has | |
| /// not yet been received. | |
| class _PendingRequest { | |
| /// The method of the request. | |
| final String method; | |
| /// The time at which the request was sent to the plugin. | |
| final int requestTime; | |
| /// The completer that will be used to complete the future when the response | |
| /// is received from the plugin. | |
| final Completer<Response> completer; | |
| /// Initialize a pending request. | |
| _PendingRequest(this.method, this.requestTime, this.completer); | |
| } |