blob: 89f2ab184b55b6ac07621cd1ab93ed77f938e614 [file] [log] [blame]
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:dart_mcp/server.dart';
import 'package:dds_service_extensions/dds_service_extensions.dart';
import 'package:dtd/dtd.dart';
import 'package:meta/meta.dart';
import 'package:unified_analytics/unified_analytics.dart' as ua;
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';
import 'package:web_socket/web_socket.dart';
import '../utils/analytics.dart';
import '../utils/constants.dart';
/// Mix this in to any MCPServer to add support for connecting to the Dart
/// Tooling Daemon and all of its associated functionality (see
/// https://pub.dev/packages/dtd).
///
/// The MCPServer must already have the [ToolsSupport] mixin applied.
base mixin DartToolingDaemonSupport
on ToolsSupport, LoggingSupport, ResourcesSupport
implements AnalyticsSupport {
DartToolingDaemon? _dtd;
/// The last reported active location from the editor.
Map<String, Object?>? _activeLocation;
/// A Map of [VmService] object [Future]s by their VM Service URI.
///
/// [VmService] objects are automatically removed from the Map when they
/// are unregistered via DTD or when the VM service shuts down.
@visibleForTesting
final activeVmServices = <String, Future<VmService>>{};
/// Whether or not the connected app service is supported.
///
/// Once we connect to dtd, this may be toggled to `true`.
bool _connectedAppServiceIsSupported = false;
/// Whether to await the disposal of all [VmService] objects in
/// [activeVmServices] upon server shutdown or loss of DTD connection.
///
/// Defaults to false but can be flipped to true for testing purposes.
@visibleForTesting
static bool debugAwaitVmServiceDisposal = false;
/// The id for the object group used when calling Flutter Widget
/// Inspector service extensions from DTD tools.
@visibleForTesting
static const inspectorObjectGroup = 'dart-tooling-mcp-server';
/// The prefix for Flutter Widget Inspector service extensions.
///
/// See https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/service_extensions.dart#L126
/// for full list of available Flutter Widget Inspector service extensions.
static const _inspectorServiceExtensionPrefix = 'ext.flutter.inspector';
/// Whether or not to enable the screenshot tool.
bool get enableScreenshots;
/// Called when the DTD connection is lost, resets all associated state.
Future<void> _resetDtd() async {
_dtd = null;
_activeLocation = null;
_connectedAppServiceIsSupported = false;
// TODO: determine whether we need to dispose the [inspectorObjectGroup] on
// the Flutter Widget Inspector for each VM service instance.
final future = Future.wait(
activeVmServices.values.map(
(vmService) => vmService.then((service) => service.dispose()),
),
);
debugAwaitVmServiceDisposal ? await future : unawaited(future);
activeVmServices.clear();
}
@visibleForTesting
Future<void> updateActiveVmServices() async {
final dtd = _dtd;
if (dtd == null) return;
if (!_connectedAppServiceIsSupported) return;
final vmServiceInfos = (await dtd.getVmServices()).vmServicesInfos;
if (vmServiceInfos.isEmpty) return;
for (final vmServiceInfo in vmServiceInfos) {
final vmServiceUri = vmServiceInfo.uri;
if (activeVmServices.containsKey(vmServiceUri)) {
continue;
}
final vmServiceFuture = activeVmServices[vmServiceUri] =
vmServiceConnectUri(vmServiceUri);
final vmService = await vmServiceFuture;
// Start listening for and collecting errors immediately.
final errorService = await _AppErrorsListener.forVmService(
vmService,
this,
);
final resource = Resource(
uri: '$runtimeErrorsScheme://${vmService.id}',
name: 'Errors for app ${vmServiceInfo.name}',
description:
'Recent runtime errors seen for app "${vmServiceInfo.name}".',
);
addResource(resource, (request) async {
final watch = Stopwatch()..start();
final result = ReadResourceResult(
contents: [
for (var error in errorService.errorLog.errors)
TextResourceContents(uri: resource.uri, text: error),
],
);
watch.stop();
try {
analytics?.send(
ua.Event.dartMCPEvent(
client: clientInfo.name,
clientVersion: clientInfo.version,
serverVersion: implementation.version,
type: AnalyticsEvent.readResource.name,
additionalData: ReadResourceMetrics(
kind: ResourceKind.runtimeErrors,
length: result.contents.length,
elapsedMilliseconds: watch.elapsedMilliseconds,
),
),
);
} catch (e) {
log(LoggingLevel.warning, 'Error sending analytics event: $e');
}
return result;
});
try {
errorService.errorsStream.listen((_) => updateResource(resource));
} catch (_) {}
unawaited(
vmService.onDone.then((_) {
removeResource(resource.uri);
activeVmServices.remove(vmServiceUri);
}),
);
}
}
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) async {
registerTool(connectTool, _connect);
registerTool(getRuntimeErrorsTool, runtimeErrors);
// TODO: these tools should only be registered for Flutter applications, or
// they should return an error when used against a pure Dart app (or a
// Flutter app that does not support the operation, e.g. hot reload is not
// supported in profile mode).
if (enableScreenshots) registerTool(screenshotTool, takeScreenshot);
registerTool(hotReloadTool, hotReload);
registerTool(getWidgetTreeTool, widgetTree);
registerTool(getSelectedWidgetTool, selectedWidget);
registerTool(setWidgetSelectionModeTool, _setWidgetSelectionMode);
registerTool(getActiveLocationTool, _getActiveLocation);
return super.initialize(request);
}
@override
Future<void> shutdown() async {
await _resetDtd();
await super.shutdown();
}
/// Connects to the Dart Tooling Daemon.
FutureOr<CallToolResult> _connect(CallToolRequest request) async {
if (_dtd != null) {
return _dtdAlreadyConnected;
}
try {
_dtd = await DartToolingDaemon.connect(
Uri.parse(request.arguments![ParameterNames.uri] as String),
);
unawaited(_dtd!.done.then((_) async => await _resetDtd()));
await _listenForServices();
return CallToolResult(
content: [TextContent(text: 'Connection succeeded')],
);
} on WebSocketException catch (_) {
return CallToolResult(
isError: true,
content: [
Content.text(
text: 'Connection failed, make sure your DTD Uri is up to date.',
),
],
)..failureReason = CallToolFailureReason.webSocketException;
} catch (e) {
return CallToolResult(
isError: true,
content: [Content.text(text: 'Connection failed: $e')],
);
}
}
/// Listens to the `ConnectedApp` and `Editor` streams to get app and IDE
/// state information.
///
/// The dart tooling daemon must be connected prior to calling this function.
Future<void> _listenForServices() async {
final dtd = _dtd!;
_connectedAppServiceIsSupported = false;
try {
final registeredServices = await dtd.getRegisteredServices();
if (registeredServices.dtdServices.contains(
'${ConnectedAppServiceConstants.serviceName}.'
'${ConnectedAppServiceConstants.getVmServices}',
)) {
_connectedAppServiceIsSupported = true;
}
} catch (_) {}
if (_connectedAppServiceIsSupported) {
await _listenForConnectedAppServiceEvents();
}
await _listenForEditorEvents();
}
Future<void> _listenForConnectedAppServiceEvents() async {
final dtd = _dtd!;
dtd.onVmServiceUpdate().listen((e) async {
log(LoggingLevel.debug, e.toString());
switch (e.kind) {
case ConnectedAppServiceConstants.vmServiceRegistered:
await updateActiveVmServices();
case ConnectedAppServiceConstants.vmServiceUnregistered:
await activeVmServices
.remove(e.data['uri'] as String)
?.then((service) => service.dispose());
default:
}
});
await dtd.streamListen(ConnectedAppServiceConstants.serviceName);
}
/// Listens for editor specific events.
Future<void> _listenForEditorEvents() async {
final dtd = _dtd!;
dtd.onEvent('Editor').listen((e) async {
log(LoggingLevel.debug, e.toString());
switch (e.kind) {
case 'activeLocationChanged':
_activeLocation = e.data;
default:
}
});
await dtd.streamListen('Editor');
}
/// Takes a screenshot of the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
//
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> takeScreenshot(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();
final result = await vmService.callServiceExtension(
'_flutter.screenshot',
isolateId: vm.isolates!.first.id,
);
if (result.json?['type'] == 'Screenshot' &&
result.json?['screenshot'] is String) {
return CallToolResult(
content: [
ImageContent(
data: result.json!['screenshot'] as String,
mimeType: 'image/png',
),
],
);
} else {
return CallToolResult(
isError: true,
content: [
TextContent(
text:
'Unknown error or bad response taking screenshot:\n'
'${result.json}',
),
],
);
}
},
);
}
/// Performs a hot reload on the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
///
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> hotReload(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
if (request.arguments?['clearRuntimeErrors'] == true) {
(await _AppErrorsListener.forVmService(
vmService,
this,
)).errorLog.clear();
}
final vm = await vmService.getVM();
ReloadReport? report;
StreamSubscription<Event>? serviceStreamSubscription;
try {
final hotReloadMethodNameCompleter = Completer<String?>();
serviceStreamSubscription = vmService
.onEvent(EventStreams.kService)
.listen((Event e) {
if (e.kind == EventKind.kServiceRegistered) {
final serviceName = e.service!;
if (serviceName == 'reloadSources') {
// This may look something like 's0.reloadSources'.
hotReloadMethodNameCompleter.complete(e.method);
}
}
});
await vmService.streamListen(EventStreams.kService);
final hotReloadMethodName = await hotReloadMethodNameCompleter.future
.timeout(
const Duration(milliseconds: 1000),
onTimeout: () async {
return null;
},
);
/// If we haven't seen a specific one, we just call the default one.
if (hotReloadMethodName == null) {
report = await vmService.reloadSources(vm.isolates!.first.id!);
} else {
final result = await vmService.callMethod(
hotReloadMethodName,
isolateId: vm.isolates!.first.id,
);
final resultType = result.json?['type'];
if (resultType == 'Success' ||
(resultType == 'ReloadReport' &&
result.json?['success'] == true)) {
report = ReloadReport(success: true);
} else {
report = ReloadReport(success: false);
}
}
} finally {
await serviceStreamSubscription?.cancel();
await vmService.streamCancel(EventStreams.kService);
}
final success = report.success == true;
return CallToolResult(
isError: !success ? true : null,
content: [
TextContent(
text: 'Hot reload ${success ? 'succeeded' : 'failed'}.',
),
],
);
},
);
}
/// Retrieves runtime errors from the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
///
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> runtimeErrors(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
try {
final errorService = await _AppErrorsListener.forVmService(
vmService,
this,
);
final errorLog = errorService.errorLog;
if (errorLog.errors.isEmpty) {
return CallToolResult(
content: [TextContent(text: 'No runtime errors found.')],
);
}
final result = CallToolResult(
content: [
TextContent(
text:
'Found ${errorLog.errors.length} '
'error${errorLog.errors.length == 1 ? '' : 's'}:\n',
),
for (final e in errorLog.errors) TextContent(text: e.toString()),
],
);
if (request.arguments?['clearRuntimeErrors'] == true) {
errorService.errorLog.clear();
}
return result;
} catch (e) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Failed to get runtime errors: $e')],
);
}
},
);
}
/// Retrieves the Flutter widget tree from the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
///
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> widgetTree(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();
final isolateId = vm.isolates!.first.id;
try {
final result = await vmService.callServiceExtension(
'$_inspectorServiceExtensionPrefix.getRootWidgetTree',
isolateId: isolateId,
args: {
'groupName': inspectorObjectGroup,
// TODO: consider making these configurable or using defaults that
// are better for the LLM.
'isSummaryTree': 'true',
'withPreviews': 'true',
'fullDetails': 'false',
},
);
final tree = result.json?['result'];
if (tree == null) {
return CallToolResult(
content: [
TextContent(
text:
'Could not get Widget tree. '
'Unexpected result: ${result.json}.',
),
],
);
}
return CallToolResult(content: [TextContent(text: jsonEncode(tree))]);
} catch (e) {
return CallToolResult(
isError: true,
content: [
TextContent(
text: 'Unknown error or bad response getting widget tree:\n$e',
),
],
);
}
},
);
}
/// Retrieves the selected widget from the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> selectedWidget(CallToolRequest request) async {
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();
final isolateId = vm.isolates!.first.id;
try {
final result = await vmService.callServiceExtension(
'$_inspectorServiceExtensionPrefix.getSelectedSummaryWidget',
isolateId: isolateId,
args: {'objectGroup': inspectorObjectGroup},
);
final widget = result.json?['result'];
if (widget == null) {
return CallToolResult(
content: [TextContent(text: 'No Widget selected.')],
);
}
return CallToolResult(
content: [TextContent(text: jsonEncode(widget))],
);
} catch (e) {
return CallToolResult(
isError: true,
content: [TextContent(text: 'Failed to get selected widget: $e')],
);
}
},
);
}
/// Enables or disables widget selection mode in the currently running app.
///
/// If more than one debug session is active, then it just uses the first one.
//
// TODO: support passing a debug session id when there is more than one debug
// session.
Future<CallToolResult> _setWidgetSelectionMode(
CallToolRequest request,
) async {
final enabled = request.arguments?['enabled'] as bool?;
if (enabled == null) {
return CallToolResult(
isError: true,
content: [
TextContent(
text:
'Required parameter "enabled" was not provided or is not a '
'boolean.',
),
],
)..failureReason = CallToolFailureReason.argumentError;
}
return _callOnVmService(
callback: (vmService) async {
final vm = await vmService.getVM();
final isolateId = vm.isolates!.first.id;
try {
final result = await vmService.callServiceExtension(
'$_inspectorServiceExtensionPrefix.show',
isolateId: isolateId,
args: {'enabled': enabled.toString()},
);
if (result.json?['enabled'] == enabled ||
result.json?['enabled'] == enabled.toString()) {
return CallToolResult(
content: [
TextContent(
text:
'Widget selection mode '
'${enabled ? 'enabled' : 'disabled'}.',
),
],
);
}
return CallToolResult(
isError: true,
content: [
TextContent(
text:
'Failed to set widget selection mode. Unexpected response: '
'${result.json}',
),
],
);
} catch (e) {
return CallToolResult(
isError: true,
content: [
TextContent(text: 'Failed to set widget selection mode: $e'),
],
);
}
},
);
}
/// Calls [callback] on the first active debug session, if available.
Future<CallToolResult> _callOnVmService({
required Future<CallToolResult> Function(VmService) callback,
}) async {
final dtd = _dtd;
if (dtd == null) return _dtdNotConnected;
if (!_connectedAppServiceIsSupported) return _connectedAppsNotSupported;
await updateActiveVmServices();
if (activeVmServices.isEmpty) return _noActiveDebugSession;
// TODO: support selecting a VM Service if more than one are available.
final vmService = activeVmServices.values.first;
return await callback(await vmService);
}
/// Retrieves the active location from the editor.
Future<CallToolResult> _getActiveLocation(CallToolRequest request) async {
if (_dtd == null) return _dtdNotConnected;
final activeLocation = _activeLocation;
if (activeLocation == null) {
return CallToolResult(
content: [
TextContent(text: 'No active location reported by the editor yet.'),
],
);
}
return CallToolResult(
content: [TextContent(text: jsonEncode(_activeLocation))],
);
}
@visibleForTesting
static final connectTool = Tool(
name: 'connect_dart_tooling_daemon',
description:
'Connects to the Dart Tooling Daemon. You should get the uri either '
'from available tools or the user, do not just make up a random URI to '
'pass. When asking the user for the uri, you should suggest the "Copy '
'DTD Uri to clipboard" action. When reconnecting after losing a '
'connection, always request a new uri first.',
annotations: ToolAnnotations(title: 'Connect to DTD', readOnlyHint: true),
inputSchema: Schema.object(
properties: {ParameterNames.uri: Schema.string()},
required: const [ParameterNames.uri],
),
);
@visibleForTesting
static final getRuntimeErrorsTool = Tool(
name: 'get_runtime_errors',
description:
'Retrieves the most recent runtime errors that have occurred in the '
'active Dart or Flutter application. Requires "${connectTool.name}" to '
'be successfully called first.',
annotations: ToolAnnotations(
title: 'Get runtime errors',
readOnlyHint: true,
),
inputSchema: Schema.object(
properties: {
'clearRuntimeErrors': Schema.bool(
title: 'Whether to clear the runtime errors after retrieving them.',
description:
'This is useful to clear out old errors that may no longer be '
'relevant before reading them again.',
),
},
),
);
@visibleForTesting
static final screenshotTool = Tool(
name: 'take_screenshot',
description:
'Takes a screenshot of the active Flutter application in its '
'current state. Requires "${connectTool.name}" to be successfully '
'called first.',
annotations: ToolAnnotations(title: 'Take screenshot', readOnlyHint: true),
inputSchema: Schema.object(),
);
@visibleForTesting
static final hotReloadTool = Tool(
name: 'hot_reload',
description:
'Performs a hot reload of the active Flutter application. '
'This is to apply the latest code changes to the running application. '
'Requires "${connectTool.name}" to be successfully called first.',
annotations: ToolAnnotations(title: 'Hot reload', destructiveHint: true),
inputSchema: Schema.object(
properties: {
'clearRuntimeErrors': Schema.bool(
title: 'Whether to clear runtime errors before hot reloading.',
description:
'This is useful to clear out old errors that may no longer be '
'relevant.',
),
},
required: [],
),
);
@visibleForTesting
static final getWidgetTreeTool = Tool(
name: 'get_widget_tree',
description:
'Retrieves the widget tree from the active Flutter application. '
'Requires "${connectTool.name}" to be successfully called first.',
annotations: ToolAnnotations(title: 'Get widget tree', readOnlyHint: true),
inputSchema: Schema.object(),
);
@visibleForTesting
static final getSelectedWidgetTool = Tool(
name: 'get_selected_widget',
description:
'Retrieves the selected widget from the active Flutter application. '
'Requires "${connectTool.name}" to be successfully called first.',
annotations: ToolAnnotations(
title: 'Get selected widget',
readOnlyHint: true,
),
inputSchema: Schema.object(),
);
@visibleForTesting
static final setWidgetSelectionModeTool = Tool(
name: 'set_widget_selection_mode',
description:
'Enables or disables widget selection mode in the active Flutter '
'application. Requires "${connectTool.name}" to be successfully called '
'first.',
annotations: ToolAnnotations(
title: 'Set Widget Selection Mode',
readOnlyHint: true,
),
inputSchema: Schema.object(
properties: {
'enabled': Schema.bool(title: 'Enable widget selection mode'),
},
required: const ['enabled'],
),
);
@visibleForTesting
static final getActiveLocationTool = Tool(
name: 'get_active_location',
description:
'Retrieves the current active location (e.g., cursor position) in the '
'connected editor. Requires "${connectTool.name}" to be successfully '
'called first.',
annotations: ToolAnnotations(
title: 'Get Active Editor Location',
readOnlyHint: true,
),
inputSchema: Schema.object(),
);
static final _connectedAppsNotSupported = CallToolResult(
isError: true,
content: [
TextContent(
text:
'A Dart SDK of version 3.9.0-163.0.dev or greater is required to '
'connect to Dart and Flutter applications.',
),
],
)..failureReason = CallToolFailureReason.connectedAppServiceNotSupported;
static final _dtdNotConnected = CallToolResult(
isError: true,
content: [
TextContent(
text:
'The dart tooling daemon is not connected, you need to call '
'"${connectTool.name}" first.',
),
],
)..failureReason = CallToolFailureReason.dtdNotConnected;
static final _dtdAlreadyConnected = CallToolResult(
isError: true,
content: [
TextContent(
text:
'The dart tooling daemon is already connected, you cannot call '
'"${connectTool.name}" again.',
),
],
)..failureReason = CallToolFailureReason.dtdAlreadyConnected;
static final _noActiveDebugSession = CallToolResult(
content: [
TextContent(text: 'No active debug session to take a screenshot'),
],
isError: true,
)..failureReason = CallToolFailureReason.noActiveDebugSession;
static final runtimeErrorsScheme = 'runtime-errors';
}
/// Listens on a VM service for errors.
class _AppErrorsListener {
/// All the errors recorded so far (may be cleared explicitly).
final ErrorLog errorLog;
/// A broadcast stream of all errors that come in after you start listening.
Stream<String> get errorsStream => _errorsController.stream;
/// Controller for the [errorsStream].
final StreamController<String> _errorsController;
/// The listener for Flutter.Error vm service extension events.
final StreamSubscription<Event>? _extensionEventsListener;
/// The stderr listener on the flutter process.
final StreamSubscription<Event>? _stderrEventsListener;
/// The vm service instance connected to the flutter app.
final VmService _vmService;
_AppErrorsListener._(
this.errorLog,
this._errorsController,
this._extensionEventsListener,
this._stderrEventsListener,
this._vmService,
) {
_vmService.onDone.then((_) => shutdown());
}
/// Maintain a cache of error listeners by [VmService] instance as an
/// [Expando] so we don't have to worry about explicit cleanup.
static final _errorListeners = Expando<Future<_AppErrorsListener>>();
/// Returns the canonical [_AppErrorsListener] for the [vmService] instance,
/// which may be an already existing instance.
static Future<_AppErrorsListener> forVmService(
VmService vmService,
LoggingSupport logger,
) async {
return _errorListeners[vmService] ??= () async {
// Needs to be a broadcast stream because we use it to add errors to the
// list but also expose it to clients so they can know when new errors
// are added.
final errorsController = StreamController<String>.broadcast();
final errorLog = ErrorLog();
errorsController.stream.listen(errorLog.add);
// We need to listen to streams with history so that we can get errors
// that occurred before this tool call.
// TODO(https://github.com/dart-lang/ai/issues/57): this can result in
// duplicate errors that we need to de-duplicate somehow.
StreamSubscription<Event>? extensionEvents;
StreamSubscription<Event>? stderrEvents;
try {
extensionEvents = vmService.onExtensionEventWithHistory.listen((
Event e,
) {
if (e.extensionKind == 'Flutter.Error') {
// TODO(https://github.com/dart-lang/ai/issues/57): consider
// pruning this content down to only what is useful for the LLM to
// understand the error and its source.
errorsController.add(e.json.toString());
}
});
Event? lastError;
stderrEvents = vmService.onStderrEventWithHistory.listen((Event e) {
if (lastError case final last?
when last.timestamp == e.timestamp && last.bytes == e.bytes) {
// Looks like a duplicate event, on Dart 3.7 stable we get these.
return;
}
lastError = e;
final message = decodeBase64(e.bytes!);
// TODO(https://github.com/dart-lang/ai/issues/57): consider
// pruning this content down to only what is useful for the LLM to
// understand the error and its source.
errorsController.add(message);
});
await [
vmService.streamListen(EventStreams.kExtension),
vmService.streamListen(EventStreams.kStderr),
].wait;
} catch (e) {
logger.log(LoggingLevel.error, 'Error subscribing to app errors: $e');
}
return _AppErrorsListener._(
errorLog,
errorsController,
extensionEvents,
stderrEvents,
vmService,
);
}();
}
Future<void> shutdown() async {
errorLog.clear();
await _errorsController.close();
await _extensionEventsListener?.cancel();
await _stderrEventsListener?.cancel();
try {
await _vmService.streamCancel(EventStreams.kExtension);
await _vmService.streamCancel(EventStreams.kStderr);
} on RPCError catch (_) {
// The vm service might already be disposed in which causes these to fail.
}
}
}
/// Manages a log of errors with a maximum size in terms of total characters.
@visibleForTesting
class ErrorLog {
Iterable<String> get errors => _errors;
final List<String> _errors = [];
int _characters = 0;
/// The number of characters used by all errors in the log.
@visibleForTesting
int get characters => _characters;
final int _maxSize;
ErrorLog({
// One token is ~4 characters. Allow up to 5k tokens by default, so 20k
// characters.
int maxSize = 20000,
}) : _maxSize = maxSize;
/// Adds a new [error] to the log.
void add(String error) {
if (error.length > _maxSize) {
// If we get a single error over the max size, just trim it and clear
// all other errors.
final trimmed = error.substring(0, _maxSize);
_errors.clear();
_characters = trimmed.length;
_errors.add(trimmed);
} else {
// Otherwise, we append the error and then remove as many errors from the
// front as we need to in order to get under the max size.
_characters += error.length;
_errors.add(error);
var removeCount = 0;
while (_characters > _maxSize) {
_characters -= _errors[removeCount].length;
removeCount++;
}
_errors.removeRange(0, removeCount);
}
}
/// Clears all errors.
void clear() {
_characters = 0;
_errors.clear();
}
}
extension on VmService {
static final _ids = Expando<String>();
static int _nextId = 0;
String get id => _ids[this] ??= '${_nextId++}';
}