| // 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/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/util/glob.dart'; |
| import 'package:analyzer/src/workspace/bazel.dart'; |
| import 'package:analyzer/src/workspace/gn.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; |
| } |
| |
| /// 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, List<int>>> pluginResponseTimes = |
| <PluginInfo, Map<String, List<int>>>{}; |
| |
| /// 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; |
| List<String> pluginPaths; |
| try { |
| pluginPaths = pathsFor(path); |
| } catch (exception, stackTrace) { |
| plugin = DiscoveredPluginInfo( |
| path, '', '', notificationManager, instrumentationService); |
| plugin.reportException(CaughtException(exception, stackTrace)); |
| _pluginMap[path] = plugin; |
| return; |
| } |
| plugin = DiscoveredPluginInfo(path, pluginPaths[0], pluginPaths[1], |
| notificationManager, instrumentationService); |
| _pluginMap[path] = plugin; |
| try { |
| var session = await plugin.start(byteStorePath, sdkPath); |
| 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 execution path and .packages path associated with the plugin at |
| /// the given [path]. Throw a [PluginException] if there is a problem that |
| /// prevents the plugin from being executing. |
| @visibleForTesting |
| List<String> pathsFor(String pluginPath) { |
| var pluginFolder = resourceProvider.getFolder(pluginPath); |
| var pubspecFile = pluginFolder.getChildAssumingFile('pubspec.yaml'); |
| 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 _computePaths(pluginFolder); |
| } |
| var workspace = BazelWorkspace.find(resourceProvider, pluginFolder.path) ?? |
| GnWorkspace.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 _computePaths(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 _computePaths(executionFolder, pubCommand: 'upgrade'); |
| } |
| var executionFolder = pluginFolder.copyTo(parentFolder); |
| return _computePaths(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); |
| 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 paths 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. |
| List<String> _computePaths(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.getChildAssumingFile('.packages'); |
| if (pubCommand != null) { |
| var vmPath = Platform.executable; |
| var pubPath = path.join(path.dirname(vmPath), 'pub'); |
| if (Platform.isWindows) { |
| // Process.run requires the `.bat` suffix on Windows |
| pubPath = '$pubPath.bat'; |
| } |
| var result = Process.runSync(pubPath, <String>[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) { |
| reason = 'Could not create .packages 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 <String>[pluginFile.path, packagesFile.path]; |
| } |
| |
| 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 `.packages` 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('pubspec.yaml'); |
| if (!pluginPubspec.exists) { |
| return null; |
| } |
| |
| try { |
| var visitedPackages = <String, String>{}; |
| var context = resourceProvider.pathContext; |
| visitedPackages[context.basename(pluginPath)] = |
| context.join(pluginFolder.path, 'lib'); |
| var pubspecFiles = <File>[]; |
| pubspecFiles.add(pluginPubspec); |
| while (pubspecFiles.isNotEmpty) { |
| var pubspecFile = pubspecFiles.removeLast(); |
| for (var packageName in _readDependecies(pubspecFile)) { |
| if (!visitedPackages.containsKey(packageName)) { |
| var uri = Uri.parse('package:$packageName/$packageName.dart'); |
| var packageSource = packageUriResolver.resolveAbsolute(uri); |
| if (packageSource != null) { |
| var libDirPath = context.dirname(packageSource.fullName); |
| visitedPackages[packageName] = libDirPath; |
| var pubspecPath = |
| context.join(context.dirname(libDirPath), 'pubspec.yaml'); |
| pubspecFiles.add(resourceProvider.getFile(pubspecPath)); |
| } |
| } |
| } |
| } |
| |
| var buffer = StringBuffer(); |
| visitedPackages.forEach((String name, String path) { |
| buffer.write(name); |
| buffer.write(':'); |
| buffer.writeln(Uri.file(path)); |
| }); |
| packagesFile.writeAsStringSync(buffer.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> _readDependecies(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, List<int>>{}) |
| .putIfAbsent(method, () => <int>[]) |
| .add(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. |
| 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) { |
| 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; |
| } |
| } |
| |
| /// 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); |
| } |