blob: 47a8dde3cf610f433f97bf0c9f98afda482c12cd [file] [log] [blame]
// Copyright (c) 2021, 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:collection/collection.dart';
import 'package:vm_service/vm_service.dart' as vm;
import 'adapters/dart.dart';
import 'exceptions.dart';
import 'protocol_generated.dart';
/// Manages state of Isolates (called Threads by the DAP protocol).
///
/// Handles incoming Isolate and Debug events to track the lifetime of isolates
/// and updating breakpoints for each isolate as necessary.
class IsolateManager {
// TODO(dantup): This class has a lot of overlap with the same-named class
// in DDS. Review what can be shared.
final DartDebugAdapter _adapter;
final Map<String, Completer<void>> _isolateRegistrations = {};
final Map<String, ThreadInfo> _threadsByIsolateId = {};
final Map<int, ThreadInfo> _threadsByThreadId = {};
int _nextThreadNumber = 1;
/// Whether debugging is enabled for this session.
///
/// This must be set before any isolates are spawned and controls whether
/// breakpoints or exception pause modes are sent to the VM.
///
/// If false, requests to send breakpoints or exception pause mode will be
/// dropped. Other functionality (handling pause events, resuming, etc.) will
/// all still function.
///
/// This is used to support debug sessions that have VM Service connections
/// but were run with noDebug: true (for example we may need a VM Service
/// connection for a noDebug flutter app in order to support hot reload).
bool debug = false;
/// Whether SDK libraries should be marked as debuggable.
///
/// Calling [sendLibraryDebuggables] is required after changing this value to
/// apply changes. This allows applying both [debugSdkLibraries] and
/// [debugExternalPackageLibraries] in one step.
bool debugSdkLibraries = true;
/// Whether external package libraries should be marked as debuggable.
///
/// Calling [sendLibraryDebuggables] is required after changing this value to
/// apply changes. This allows applying both [debugSdkLibraries] and
/// [debugExternalPackageLibraries] in one step.
bool debugExternalPackageLibraries = true;
/// Tracks breakpoints last provided by the client so they can be sent to new
/// isolates that appear after initial breakpoints were sent.
final Map<String, List<SourceBreakpoint>> _clientBreakpointsByUri = {};
/// Tracks client breakpoints by the ID assigned by the VM so we can look up
/// conditions/logpoints when hitting breakpoints.
final Map<String, SourceBreakpoint> _clientBreakpointsByVmId = {};
/// Tracks breakpoints created in the VM so they can be removed when the
/// editor sends new breakpoints (currently the editor just sends a new list
/// and not requests to add/remove).
final Map<String, Map<String, List<vm.Breakpoint>>>
_vmBreakpointsByIsolateIdAndUri = {};
/// The exception pause mode last provided by the client.
///
/// This will be sent to isolates as they are created, and to all existing
/// isolates at start or when changed.
String _exceptionPauseMode = 'None';
/// An incrementing number used as the reference for [_storedData].
var _nextStoredDataId = 1;
/// A store of data indexed by a number that is used for round tripping
/// references to the client (which only accepts ints).
///
/// For example, when we send a stack frame back to the client we provide only
/// a "sourceReference" integer and the client may later ask us for the source
/// using that number (via sourceRequest).
///
/// Stored data is thread-scoped but the client will not provide the thread
/// when asking for data so it's all stored together here.
final _storedData = <int, _StoredData>{};
/// A pattern that matches an opening brace `{` that was not preceeded by a
/// dollar.
///
/// Any leading character matched in place of the dollar is in the first capture.
final _braceNotPrefixedByDollarOrBackslashPattern = RegExp(r'(^|[^\\\$]){');
IsolateManager(this._adapter);
/// A list of all current active isolates.
///
/// When isolates exit, they will no longer be returned in this list, although
/// due to the async nature, it's not guaranteed that threads in this list have
/// not exited between accessing this list and trying to use the results.
List<ThreadInfo> get threads => _threadsByIsolateId.values.toList();
/// Re-applies debug options to all isolates/libraries.
///
/// This is required if options like debugSdkLibraries are modified, but is a
/// separate step to batch together changes to multiple options.
Future<void> applyDebugOptions() async {
await Future.wait(_threadsByThreadId.values.map(
// debuggable libraries is the only thing currently affected by these
// changable options.
(isolate) => _sendLibraryDebuggables(isolate.isolate),
));
}
Future<T> getObject<T extends vm.Response>(
vm.IsolateRef isolate, vm.ObjRef object) async {
final res = await _adapter.vmService?.getObject(isolate.id!, object.id!);
return res as T;
}
/// Retrieves some basic data indexed by an integer for use in "reference"
/// fields that are round-tripped to the client.
_StoredData? getStoredData(int id) {
return _storedData[id];
}
ThreadInfo? getThread(int threadId) => _threadsByThreadId[threadId];
/// Handles Isolate and Debug events.
///
/// If [resumeIfStarting] is `true`, PauseStart/PausePostStart events will be
/// automatically resumed from.
Future<void> handleEvent(
vm.Event event, {
bool resumeIfStarting = true,
}) async {
final isolateId = event.isolate?.id!;
final eventKind = event.kind;
if (eventKind == vm.EventKind.kIsolateStart ||
eventKind == vm.EventKind.kIsolateRunnable) {
await registerIsolate(event.isolate!, eventKind!);
}
// Additionally, ensure the thread registration has completed before trying
// to process any other events. This is to cover the case where we are
// processing the above registerIsolate call in the handler for one isolate
// event but another one arrives and gets us here before the registration
// above (in the other event handler) has finished.
await _isolateRegistrations[isolateId]?.future;
if (eventKind == vm.EventKind.kIsolateExit) {
_handleExit(event);
} else if (eventKind?.startsWith('Pause') ?? false) {
await _handlePause(event, resumeIfStarting: resumeIfStarting);
} else if (eventKind == vm.EventKind.kResume) {
_handleResumed(event);
}
}
/// Registers a new isolate that exists at startup, or has subsequently been
/// created.
///
/// New isolates will be configured with the correct pause-exception behaviour,
/// libraries will be marked as debuggable if appropriate, and breakpoints
/// sent.
Future<ThreadInfo> registerIsolate(
vm.IsolateRef isolate,
String eventKind,
) async {
// Ensure the completer is set up before doing any async work, so future
// events can wait on it.
final registrationCompleter =
_isolateRegistrations.putIfAbsent(isolate.id!, () => Completer<void>());
final info = _threadsByIsolateId.putIfAbsent(
isolate.id!,
() {
// The first time we see an isolate, start tracking it.
final info = ThreadInfo(this, _nextThreadNumber++, isolate);
_threadsByThreadId[info.threadId] = info;
// And notify the client about it.
_adapter.sendEvent(
ThreadEventBody(reason: 'started', threadId: info.threadId),
);
return info;
},
);
// If it's just become runnable (IsolateRunnable), configure the isolate
// by sending breakpoints etc.
if (eventKind == vm.EventKind.kIsolateRunnable && !info.runnable) {
info.runnable = true;
await _configureIsolate(isolate);
registrationCompleter.complete();
}
return info;
}
/// Calls reloadSources for all isolates.
Future<void> reloadSources() async {
await Future.wait(_threadsByThreadId.values.map(
(isolate) => _reloadSources(isolate.isolate),
));
}
Future<void> resumeIsolate(vm.IsolateRef isolateRef,
[String? resumeType]) async {
final isolateId = isolateRef.id!;
final thread = _threadsByIsolateId[isolateId];
if (thread == null) {
return;
}
await resumeThread(thread.threadId);
}
/// Resumes (or steps) an isolate using its client [threadId].
///
/// If the isolate is not paused, or already has a pending resume request
/// in-flight, a request will not be sent.
///
/// If the isolate is paused at an async suspension and the [resumeType] is
/// [vm.StepOption.kOver], a [StepOption.kOverAsyncSuspension] step will be
/// sent instead.
Future<void> resumeThread(int threadId, [String? resumeType]) async {
final thread = _threadsByThreadId[threadId];
if (thread == null) {
throw DebugAdapterException('Thread $threadId was not found');
}
// Check this thread hasn't already been resumed by another handler in the
// meantime (for example if the user performs a hot restart or something
// while we processing some previous events).
if (!thread.paused || thread.hasPendingResume) {
return;
}
// We always assume that a step when at an async suspension is intended to
// be an async step.
if (resumeType == vm.StepOption.kOver && thread.atAsyncSuspension) {
resumeType = vm.StepOption.kOverAsyncSuspension;
}
thread.hasPendingResume = true;
try {
await _adapter.vmService?.resume(thread.isolate.id!, step: resumeType);
} finally {
thread.hasPendingResume = false;
}
}
/// Sends an event informing the client that a thread is stopped at entry.
void sendStoppedOnEntryEvent(int threadId) {
_adapter.sendEvent(StoppedEventBody(reason: 'entry', threadId: threadId));
}
/// Records breakpoints for [uri].
///
/// [breakpoints] represents the new set and entirely replaces anything given
/// before.
Future<void> setBreakpoints(
String uri,
List<SourceBreakpoint> breakpoints,
) async {
// Track the breakpoints to get sent to any new isolates that start.
_clientBreakpointsByUri[uri] = breakpoints;
// Send the breakpoints to all existing threads.
await Future.wait(_threadsByThreadId.values
.map((isolate) => _sendBreakpoints(isolate.isolate, uri: uri)));
}
/// Records exception pause mode as one of 'None', 'Unhandled' or 'All'. All
/// existing isolates will be updated to reflect the new setting.
Future<void> setExceptionPauseMode(String mode) async {
_exceptionPauseMode = mode;
// Send to all existing threads.
await Future.wait(_threadsByThreadId.values.map(
(isolate) => _sendExceptionPauseMode(isolate.isolate),
));
}
/// Stores some basic data indexed by an integer for use in "reference" fields
/// that are round-tripped to the client.
int storeData(ThreadInfo thread, Object data) {
final id = _nextStoredDataId++;
_storedData[id] = _StoredData(thread, data);
return id;
}
ThreadInfo? threadForIsolate(vm.IsolateRef? isolate) =>
isolate?.id != null ? _threadsByIsolateId[isolate!.id!] : null;
/// Evaluates breakpoint condition [condition] and returns whether the result
/// is true (or non-zero for a numeric), sending any evaluation error to the
/// client.
Future<bool> _breakpointConditionEvaluatesTrue(
ThreadInfo thread,
String condition,
) async {
final result =
await _evaluateAndPrintErrors(thread, condition, 'condition');
if (result == null) {
return false;
}
// Values we consider true for breakpoint conditions are boolean true,
// or non-zero numerics.
return (result.kind == vm.InstanceKind.kBool &&
result.valueAsString == 'true') ||
(result.kind == vm.InstanceKind.kInt && result.valueAsString != '0') ||
(result.kind == vm.InstanceKind.kDouble && result.valueAsString != '0');
}
/// Configures a new isolate, setting it's exception-pause mode, which
/// libraries are debuggable, and sending all breakpoints.
Future<void> _configureIsolate(vm.IsolateRef isolate) async {
await Future.wait([
_sendLibraryDebuggables(isolate),
_sendExceptionPauseMode(isolate),
_sendBreakpoints(isolate),
], eagerError: true);
}
/// Evaluates an expression, returning the result if it is a [vm.InstanceRef]
/// and sending any error as an [OutputEvent].
Future<vm.InstanceRef?> _evaluateAndPrintErrors(
ThreadInfo thread,
String expression,
String type,
) async {
try {
final result = await _adapter.vmService?.evaluateInFrame(
thread.isolate.id!,
0,
expression,
disableBreakpoints: true,
);
if (result is vm.InstanceRef) {
return result;
} else if (result is vm.ErrorRef) {
final message = result.message ?? '<error ref>';
_adapter.sendOutput(
'console',
'Debugger failed to evaluate breakpoint $type "$expression": $message\n',
);
} else if (result is vm.Sentinel) {
final message = result.valueAsString ?? '<collected>';
_adapter.sendOutput(
'console',
'Debugger failed to evaluate breakpoint $type "$expression": $message\n',
);
}
} catch (e) {
_adapter.sendOutput(
'console',
'Debugger failed to evaluate breakpoint $type "$expression": $e\n',
);
}
}
void _handleExit(vm.Event event) {
final isolate = event.isolate!;
final isolateId = isolate.id!;
final thread = _threadsByIsolateId[isolateId];
if (thread != null) {
// Notify the client.
_adapter.sendEvent(
ThreadEventBody(reason: 'exited', threadId: thread.threadId),
);
_threadsByIsolateId.remove(isolateId);
_threadsByThreadId.remove(thread.threadId);
}
}
/// Handles a pause event.
///
/// For [vm.EventKind.kPausePostRequest] which occurs after a restart, the
/// isolate will be re-configured (pause-exception behaviour, debuggable
/// libraries, breakpoints) and then (if [resumeIfStarting] is `true`)
/// resumed.
///
/// For [vm.EventKind.kPauseStart] and [resumeIfStarting] is `true`, the
/// isolate will be resumed.
///
/// For breakpoints with conditions that are not met and for logpoints, the
/// isolate will be automatically resumed.
///
/// For all other pause types, the isolate will remain paused and a
/// corresponding "Stopped" event sent to the editor.
Future<void> _handlePause(
vm.Event event, {
bool resumeIfStarting = true,
}) async {
final eventKind = event.kind;
final isolate = event.isolate!;
final thread = _threadsByIsolateId[isolate.id!];
if (thread == null) {
return;
}
thread.atAsyncSuspension = event.atAsyncSuspension ?? false;
thread.paused = true;
thread.pauseEvent = event;
// For PausePostRequest we need to re-send all breakpoints; this happens
// after a hot restart.
if (eventKind == vm.EventKind.kPausePostRequest) {
await _configureIsolate(isolate);
if (resumeIfStarting) {
await resumeThread(thread.threadId);
}
} else if (eventKind == vm.EventKind.kPauseStart) {
// Don't resume from a PauseStart if this has already happened (see
// comments on [thread.hasBeenStarted]).
if (!thread.hasBeenStarted) {
// If requested, automatically resume. Otherwise send a Stopped event to
// inform the client UI the thread is paused.
if (resumeIfStarting) {
thread.hasBeenStarted = true;
await resumeThread(thread.threadId);
} else {
sendStoppedOnEntryEvent(thread.threadId);
}
}
} else {
// PauseExit, PauseBreakpoint, PauseInterrupted, PauseException
var reason = 'pause';
if (eventKind == vm.EventKind.kPauseBreakpoint &&
(event.pauseBreakpoints?.isNotEmpty ?? false)) {
reason = 'breakpoint';
// Look up the client breakpoints that correspond to the VM breakpoint(s)
// we hit. It's possible some of these may be missing because we could
// hit a breakpoint that was set before we were attached.
final clientBreakpoints = event.pauseBreakpoints!
.map((bp) => _clientBreakpointsByVmId[bp.id!])
.toSet();
// Split into logpoints (which just print messages) and breakpoints.
final logPoints = clientBreakpoints
.whereNotNull()
.where((bp) => bp.logMessage?.isNotEmpty ?? false)
.toSet();
final breakpoints = clientBreakpoints.difference(logPoints);
await _processLogPoints(thread, logPoints);
// Resume if there are no (non-logpoint) breakpoints, of any of the
// breakpoints don't have false conditions.
if (breakpoints.isEmpty ||
!await _shouldHitBreakpoint(thread, breakpoints)) {
await resumeThread(thread.threadId);
return;
}
} else if (eventKind == vm.EventKind.kPauseBreakpoint) {
reason = 'step';
} else if (eventKind == vm.EventKind.kPauseException) {
reason = 'exception';
}
// If we stopped at an exception, capture the exception instance so we
// can add a variables scope for it so it can be examined.
final exception = event.exception;
if (exception != null) {
_adapter.storeEvaluateName(exception, threadExceptionExpression);
thread.exceptionReference = thread.storeData(exception);
}
// Notify the client.
_adapter.sendEvent(
StoppedEventBody(reason: reason, threadId: thread.threadId),
);
}
}
/// Handles a resume event from the VM, updating our local state.
void _handleResumed(vm.Event event) {
final isolate = event.isolate!;
final thread = _threadsByIsolateId[isolate.id!];
if (thread != null) {
thread.paused = false;
thread.pauseEvent = null;
thread.exceptionReference = null;
}
}
/// Interpolates and prints messages for any log points.
///
/// Log Points are breakpoints with string messages attached. When the VM hits
/// the breakpoint, we evaluate/print the message and then automatically
/// resume (as long as there was no other breakpoint).
Future<void> _processLogPoints(
ThreadInfo thread,
Set<SourceBreakpoint> logPoints,
) async {
// Otherwise, we need to evaluate all of the conditions and see if any are
// true, in which case we will also hit.
final messages = logPoints.map((bp) => bp.logMessage!).toList();
final results = await Future.wait(messages.map(
(message) {
// Log messages are bare so use jsonEncode to make them valid string
// expressions.
final expression = jsonEncode(message)
// The DAP spec says "Expressions within {} are interpolated" so to
// avoid any clever parsing, just prefix them with $ and treat them
// like other Dart interpolation expressions.
.replaceAllMapped(_braceNotPrefixedByDollarOrBackslashPattern,
(match) => '${match.group(1)}\${')
// Remove any backslashes the user added to "escape" braces.
.replaceAll(r'\\{', '{');
return _evaluateAndPrintErrors(thread, expression, 'log message');
},
));
for (final messageResult in results) {
// TODO(dantup): Format this using other existing code in protocol converter?
_adapter.sendOutput('console', '${messageResult?.valueAsString}\n');
}
}
/// Calls reloadSources for the given isolate.
Future<void> _reloadSources(vm.IsolateRef isolateRef) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
final isolateId = isolateRef.id!;
await service.reloadSources(isolateId);
}
/// Sets breakpoints for an individual isolate.
///
/// If [uri] is provided, only breakpoints for that URI will be sent (used
/// when breakpoints are modified for a single file in the editor). Otherwise
/// breakpoints for all previously set URIs will be sent (used for
/// newly-created isolates).
Future<void> _sendBreakpoints(vm.IsolateRef isolate, {String? uri}) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
final isolateId = isolate.id!;
// If we were passed a single URI, we should send breakpoints only for that
// (this means the request came from the client), otherwise we should send
// all of them (because this is a new/restarting isolate).
final uris = uri != null ? [uri] : _clientBreakpointsByUri.keys;
for (final uri in uris) {
// Clear existing breakpoints.
final existingBreakpointsForIsolate =
_vmBreakpointsByIsolateIdAndUri.putIfAbsent(isolateId, () => {});
final existingBreakpointsForIsolateAndUri =
existingBreakpointsForIsolate.putIfAbsent(uri, () => []);
await Future.forEach<vm.Breakpoint>(existingBreakpointsForIsolateAndUri,
(bp) => service.removeBreakpoint(isolateId, bp.id!));
// Set new breakpoints.
final newBreakpoints = _clientBreakpointsByUri[uri] ?? const [];
await Future.forEach<SourceBreakpoint>(newBreakpoints, (bp) async {
try {
final vmBp = await service.addBreakpointWithScriptUri(
isolateId, uri, bp.line,
column: bp.column);
existingBreakpointsForIsolateAndUri.add(vmBp);
_clientBreakpointsByVmId[vmBp.id!] = bp;
} catch (e) {
// Swallow errors setting breakpoints rather than failing the whole
// request as it's very easy for editors to send us breakpoints that
// aren't valid any more.
_adapter.logger?.call('Failed to add breakpoint $e');
}
});
}
}
/// Sets the exception pause mode for an individual isolate.
Future<void> _sendExceptionPauseMode(vm.IsolateRef isolate) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
await service.setExceptionPauseMode(isolate.id!, _exceptionPauseMode);
}
/// Calls setLibraryDebuggable for all libraries in the given isolate based
/// on the debug settings.
Future<void> _sendLibraryDebuggables(vm.IsolateRef isolateRef) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
final isolateId = isolateRef.id!;
final isolate = await service.getIsolate(isolateId);
final libraries = isolate.libraries;
if (libraries == null) {
return;
}
await Future.wait(libraries.map((library) async {
final libraryUri = library.uri;
final isDebuggable = libraryUri != null
? _adapter.libaryIsDebuggable(Uri.parse(libraryUri))
: false;
await service.setLibraryDebuggable(isolateId, library.id!, isDebuggable);
}));
}
/// Checks whether a breakpoint the VM paused at is one we should actually
/// remain at. That is, it either has no condition, or its condition evaluates
/// to something truthy.
Future<bool> _shouldHitBreakpoint(
ThreadInfo thread,
Set<SourceBreakpoint?> breakpoints,
) async {
// If any were missing (they're null) or do not have a condition, we should
// hit the breakpoint.
final clientBreakpointsWithConditions =
breakpoints.where((bp) => bp?.condition?.isNotEmpty ?? false).toList();
if (breakpoints.length != clientBreakpointsWithConditions.length) {
return true;
}
// Otherwise, we need to evaluate all of the conditions and see if any are
// true, in which case we will also hit.
final conditions =
clientBreakpointsWithConditions.map((bp) => bp!.condition!).toSet();
final results = await Future.wait(conditions.map(
(condition) => _breakpointConditionEvaluatesTrue(thread, condition),
));
return results.any((result) => result);
}
}
/// Holds state for a single Isolate/Thread.
class ThreadInfo {
final IsolateManager _manager;
final vm.IsolateRef isolate;
final int threadId;
var runnable = false;
var atAsyncSuspension = false;
int? exceptionReference;
var paused = false;
/// Tracks whether an isolate has been started from its PauseStart state.
///
/// This is used to prevent trying to resume a thread twice if a PauseStart
/// event arrives around the same time that are our initialization code (which
/// automatically resumes threads that are in the PauseStart state when we
/// connect).
///
/// If we send a duplicate resume, it could trigger an unwanted resume for a
/// breakpoint or exception that occur early on.
bool hasBeenStarted = false;
// The most recent pauseEvent for this isolate.
vm.Event? pauseEvent;
// A cache of requests (Futures) to fetch scripts, so that multiple requests
// that require scripts (for example looking up locations for stack frames from
// tokenPos) can share the same response.
final _scripts = <String, Future<vm.Script>>{};
/// Whether this isolate has an in-flight resume request that has not yet
/// been responded to.
var hasPendingResume = false;
ThreadInfo(this._manager, this.threadId, this.isolate);
Future<T> getObject<T extends vm.Response>(vm.ObjRef ref) =>
_manager.getObject<T>(isolate, ref);
/// Fetches a script for a given isolate.
///
/// Results from this method are cached so that if there are multiple
/// concurrent calls (such as when converting multiple stack frames) they will
/// all use the same script.
Future<vm.Script> getScript(vm.ScriptRef script) {
return _scripts.putIfAbsent(script.id!, () => getObject<vm.Script>(script));
}
/// Stores some basic data indexed by an integer for use in "reference" fields
/// that are round-tripped to the client.
int storeData(Object data) => _manager.storeData(this, data);
}
class _StoredData {
final ThreadInfo thread;
final Object data;
_StoredData(this.thread, this.data);
}