blob: bfc2f49ab9ccb75eb6f4a34cc58a168bcd59347d [file] [log] [blame] [edit]
// 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:dap/dap.dart';
import 'package:dds_service_extensions/dds_service_extensions.dart';
import 'package:vm_service/vm_service.dart' as vm;
import '../rpc_error_codes.dart';
import 'adapters/dart.dart';
import 'adapters/mixins.dart';
import 'utils.dart';
import 'variables.dart';
/// A composite ID for breakpoints made up of an isolate ID and breakpoint ID.
///
/// Breakpoint IDs are not unique across all isolates so any place we need to
/// know about a breakpoint specifically, we must use this.
typedef _UniqueVmBreakpointId = ({String isolateId, String breakpointId});
/// 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<ClientBreakpoint>> _clientBreakpointsByUri = {};
/// Tracks client breakpoints by the ID assigned by the VM so we can look up
/// conditions/logpoints when hitting breakpoints.
///
/// Because the VM might return the same breakpoint for multiple
/// `addBreakpointWithScriptUri` calls (if they immediately resolve to the
/// same location) there may be multiple client breakpoints for a given VM
/// breakpoint ID.
///
/// When an item is added to this map, any pending events in
/// [_breakpointResolvedEventsByVmId] MUST be processed immediately.
final Map<_UniqueVmBreakpointId, List<ClientBreakpoint>>
_clientBreakpointsByVmId = {};
/// Tracks `BreakpointAdded` or `BreakpointResolved` events for VM
/// breakpoints.
///
/// These are kept for all breakpoints until they are removed by the VM
/// because it's always possible that the VM will reuse a breakpoint ID (eg.
/// if we add a new breakpoint that resolves to the same location as another
/// breakpoint).
///
/// When new breakpoints are added by the client, we must check this map to
/// see it's al already-resolved breakpoint so that we can send resolution
/// info to the client.
final Map<_UniqueVmBreakpointId, vm.Event> _breakpointResolvedEventsByVmId =
{};
/// 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).
///
/// Breakpoints are indexed by their ID so that duplicates are not stored even
/// if multiple client breakpoints resolve to a single VM breakpoint.
///
/// IsolateId -> Uri -> breakpointId -> VM Breakpoint.
final Map<String, Map<String, Map<String, 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 preceded by a
/// dollar.
///
/// Any leading character matched in place of the dollar is in the first capture.
final _braceNotPrefixedByDollarOrBackslashPattern = RegExp(r'(^|[^\\\$]){');
/// A [RegExp] to extract the useful part of an error message when adding
/// breakpoints so that the tooltip shown in editors can be less wordy.
final _terseBreakpointFailureRegex =
RegExp(r'Error occurred when resolving breakpoint location: (.*?)\.?$');
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.
(thread) => _sendLibraryDebuggables(thread),
));
}
Future<T> getObject<T extends vm.Response>(
vm.IsolateRef isolate,
vm.ObjRef object, {
int? offset,
int? count,
}) async {
final res = await _adapter.vmService?.getObject(
isolate.id!,
object.id!,
offset: offset,
count: count,
);
return res as T;
}
Future<vm.ScriptList> getScripts(vm.IsolateRef isolate) async {
return (await _adapter.vmService?.getScripts(isolate.id!)) as vm.ScriptList;
}
/// 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.
Future<void> handleEvent(vm.Event event) 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);
} else if (eventKind == vm.EventKind.kResume) {
_handleResumed(event);
} else if (eventKind == vm.EventKind.kInspect) {
_handleInspect(event);
} else if (eventKind == vm.EventKind.kBreakpointAdded ||
eventKind == vm.EventKind.kBreakpointResolved) {
_handleBreakpointAddedOrResolved(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 thread = _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 && !thread.runnable) {
thread.runnable = true;
await _configureIsolate(thread);
registrationCompleter.complete();
}
return thread;
}
/// 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) async {
final isolateId = isolateRef.id!;
final thread = _threadsByIsolateId[isolateId];
if (thread == null) {
return;
}
await resumeThread(thread.threadId);
}
Future<void> readyToResumeIsolate(vm.IsolateRef isolateRef) async {
final isolateId = isolateRef.id!;
final thread = _threadsByIsolateId[isolateId];
if (thread == null) {
return;
}
await readyToResumeThread(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 {
await _resume(threadId, resumeType: resumeType);
}
/// Resumes an isolate using its client [threadId].
///
/// CAUTION: This should only be used for a tool-initiated resume, not a user-
/// initiated resume.
///
/// See: https://pub.dev/documentation/dds_service_extensions/latest/dds_service_extensions/DdsExtension/readyToResume.html
Future<void> readyToResumeThread(int threadId) async {
await _readyToResume(threadId);
}
/// Rewinds an isolate to an earlier frame 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.
Future<void> rewindThread(int threadId, {required int frameIndex}) async {
await _resume(
threadId,
resumeType: vm.StepOption.kRewind,
frameIndex: frameIndex,
);
}
/// Resumes (or steps) an isolate using its client [threadId] on behalf
/// of the user.
///
/// 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 [vm.StepOption.kOverAsyncSuspension] step will be
/// sent instead.
///
/// If [resumeType] is [vm.StepOption.kRewind], [frameIndex] must be supplied.
Future<void> _resume(
int threadId, {
String? resumeType,
int? frameIndex,
}) async {
final thread = _threadsByThreadId[threadId];
if (thread == null) {
if (isInvalidThreadId(threadId)) {
throw DebugAdapterException('Thread $threadId was not found');
} else {
// Otherwise, this thread has exited and we don't need to do anything.
// It's possible another debugger unpaused or we're shutting down and
// the VM has terminated it.
return;
}
}
// 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.hasPendingUserResume) {
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;
}
// Finally, when we're resuming, all stored objects become invalid and
// we can drop them to save memory.
await thread.clearTemporaryData();
thread.hasPendingUserResume = true;
try {
await _adapter.vmService?.resume(
thread.isolate.id!,
step: resumeType,
frameIndex: frameIndex,
);
} on vm.SentinelException {
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// resuming it.
} on vm.RPCError catch (e) {
if (e.code == RpcErrorCodes.kIsolateMustBePaused) {
// It's possible something else resumed the thread (such as if another
// debugger is attached), we can just continue.
} else if (e.isServiceDisposedError) {
// The VM service connection was terminated, we can silently ignore this
// because we're likely shutting down.
} else if (e.code == RpcErrorCodes.kInternalError &&
e.message.contains('No running isolate (inspector is not set).')) {
// TODO(bkonyi): remove once https://github.com/flutter/flutter/issues/156793
// is resolved.
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// resuming it.
} else {
rethrow;
}
} finally {
thread.hasPendingUserResume = false;
}
}
/// Resumes an isolate using its client [threadId].
///
/// CAUTION: This should only be used for a tool-initiated resume, not a user-
/// initiated resume.
///
/// See: https://pub.dev/documentation/dds_service_extensions/latest/dds_service_extensions/DdsExtension/readyToResume.html
Future<void> _readyToResume(int threadId) async {
final thread = _threadsByThreadId[threadId];
if (thread == null) {
if (isInvalidThreadId(threadId)) {
throw DebugAdapterException('Thread $threadId was not found');
} else {
// Otherwise, this thread has exited and we don't need to do anything.
// It's possible another debugger unpaused or we're shutting down and
// the VM has terminated it.
return;
}
}
final isolateId = thread.isolate.id!;
try {
// When we're resuming, all stored objects become invalid and we can drop
// to save memory.
await thread.clearTemporaryData();
// Finally, signal that we're ready to resume.
await _adapter.vmService?.readyToResume(isolateId);
} on UnimplementedError {
// Fallback to a regular resume if the DDS version doesn't support
// `readyToResume`:
return _resume(threadId);
} on vm.SentinelException {
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// resuming it.
} on vm.RPCError catch (e) {
if (e.code == RpcErrorCodes.kIsolateMustBePaused) {
// It's possible something else resumed the thread (such as if another
// debugger is attached), we can just continue.
} else if (e.isServiceDisposedError) {
// The VM service connection was terminated, we can silently ignore this
// because we're likely shutting down.
} else if (e.code == RpcErrorCodes.kMethodNotFound) {
// Fallback to a regular resume if the DDS service extension isn't
// available:
return _resume(threadId);
} else if (e.code == RpcErrorCodes.kInternalError &&
e.message.contains('No running isolate (inspector is not set).')) {
// TODO(bkonyi): remove once https://github.com/flutter/flutter/issues/156793
// is resolved.
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// resuming it.
} else {
rethrow;
}
}
}
/// Pauses an isolate using its client [threadId].
///
/// This is simply a _request_ to pause. It does not change any state by
/// itself - we will handle the pause via an event if the pause request
/// succeeds.
Future<void> pauseThread(int threadId) async {
final thread = _threadsByThreadId[threadId];
if (thread == null) {
if (isInvalidThreadId(threadId)) {
throw DebugAdapterException('Thread $threadId was not found');
} else {
// Otherwise, this thread has recently exited so we cannot attempt
// to pause it.
return;
}
}
try {
await _adapter.vmService?.pause(thread.isolate.id!);
} on vm.SentinelException {
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// pausing it.
} on vm.RPCError catch (e) {
if (e.code == RpcErrorCodes.kInternalError &&
e.message.contains('No running isolate (inspector is not set).')) {
// TODO(bkonyi): remove once https://github.com/flutter/flutter/issues/156793
// is resolved.
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// resuming it.
} else {
rethrow;
}
}
}
/// Checks whether [threadId] is invalid and has never been used.
///
/// Returns `false` is [threadId] corresponds to either a live, or previously
/// exited thread.
bool isInvalidThreadId(int threadId) => threadId >= _nextThreadNumber;
/// Sends an event informing the client that a thread is stopped at entry.
void sendStoppedOnEntryEvent(ThreadInfo thread) {
_adapter.sendEvent(StoppedEventBody(
reason: 'entry',
threadId: thread.threadId,
allThreadsStopped: false,
));
}
/// Records breakpoints for [uri].
///
/// [breakpoints] represents the new set and entirely replaces anything given
/// before.
Future<void> setBreakpoints(
String uri,
List<ClientBreakpoint> 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((thread) => _sendBreakpoints(thread, uri: uri)));
}
/// Clears all breakpoints.
Future<void> clearAllBreakpoints() async {
// Clear all breakpoints for each URI. Do not remove the items from the map
// as that will stop them being tracked/sent by the call below.
_clientBreakpointsByUri.updateAll((key, value) => []);
// Send the breakpoints to all existing threads.
await Future.wait(
_threadsByThreadId.values.map((thread) => _sendBreakpoints(thread)),
);
}
/// 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(
(thread) => _sendExceptionPauseMode(thread),
));
}
/// 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 ? threadForIsolateId(isolate!.id!) : null;
ThreadInfo? threadForIsolateId(String isolateId) =>
_threadsByIsolateId[isolateId];
/// 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(ThreadInfo thread) async {
try {
// Libraries must be set as debuggable _before_ sending breakpoints, or
// they may fail for SDK sources.
await Future.wait([
_sendLibraryDebuggables(thread),
_sendExceptionPauseMode(thread),
], eagerError: true);
await _sendBreakpoints(thread);
} on vm.SentinelException {
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// configuring it. State will be cleaned up by the IsolateExit event.
} on vm.RPCError catch (e) {
if (e.code == RpcErrorCodes.kInternalError &&
e.message.contains('No running isolate (inspector is not set).')) {
// TODO(bkonyi): remove once https://github.com/flutter/flutter/issues/156793
// is resolved.
// It's possible during these async requests that the isolate went away
// (for example a shutdown/restart) and we no longer care about
// resuming it.
} else {
rethrow;
}
}
}
/// 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.vmEvaluateInFrame(thread, 0, expression);
if (result is vm.InstanceRef) {
return result;
} else if (result is vm.ErrorRef) {
final message = result.message ?? '<error ref>';
_adapter.sendConsoleOutput(
'Debugger failed to evaluate breakpoint $type "$expression": $message',
);
} else if (result is vm.Sentinel) {
final message = result.valueAsString ?? '<collected>';
_adapter.sendConsoleOutput(
'Debugger failed to evaluate breakpoint $type "$expression": $message',
);
}
} catch (e) {
_adapter.sendConsoleOutput(
'Debugger failed to evaluate breakpoint $type "$expression": $e',
);
}
return null;
}
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 we'll declare we are ready to resume.
///
/// For [vm.EventKind.kPauseStart] we'll declare we are ready to resume.
///
/// 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) async {
final eventKind = event.kind;
final isolate = event.isolate!;
final isolateId = isolate.id!;
final thread = _threadsByIsolateId[isolateId];
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(thread);
// We always want to resume here regardless of whether startupHandled was
// already `true` (because that might be from before the reload).
// Therefore set the flag and resume always.
thread.startupHandled = true;
await readyToResumeThread(thread.threadId);
} else if (eventKind == vm.EventKind.kPauseStart) {
handleThreadStartup(thread, sendStoppedOnEntry: true);
} 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.
//
// When multiple client breakpoints have been folded into a single VM
// breakpoint, we (arbitrarily) use the first one for conditions and
// logpoints.
final clientBreakpoints = event.pauseBreakpoints!.map((bp) {
final uniqueBreakpointId =
(isolateId: isolateId, breakpointId: bp.id!);
return _clientBreakpointsByVmId[uniqueBreakpointId]
?.firstOrNull
?.breakpoint;
}).toSet();
// Split into logpoints (which just print messages) and breakpoints.
final logPoints = clientBreakpoints.nonNulls
.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';
} else if (eventKind == vm.EventKind.kPauseExit) {
reason = 'exit';
}
// 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;
String? text;
if (exception != null) {
_adapter.storeEvaluateName(exception, threadExceptionExpression);
thread.exceptionReference = thread.storeData(exception);
text = await _adapter.getFullString(thread, exception);
}
// Notify the client.
_adapter.sendEvent(
StoppedEventBody(
reason: reason,
threadId: thread.threadId,
allThreadsStopped: false,
text: text,
),
);
}
}
/// Handles thread startup if it has not already been handled.
///
/// This includes sending Stopped-on-Entry and sending a readyToResume.
Future<void> handleThreadStartup(
ThreadInfo thread, {
required bool sendStoppedOnEntry,
}) async {
// Don't resume from a PauseStart if this has already happened (see
// comments on [thread.startupHandled]).
if (thread.startupHandled) {
return;
}
thread.startupHandled = true;
// Send a Stopped event to inform the client UI the thread is paused and
// declare that we are ready to resume (which might result in an
// immediate resume).
if (sendStoppedOnEntry) {
sendStoppedOnEntryEvent(thread);
}
await readyToResumeThread(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) {
// When a thread is resumed, we must inform the client. This is not
// necessary when the user has clicked Continue because it is implied.
// However, resume events can now be triggered by other things (eg. other
// in other IDEs or DevTools) so we must notify the client.
_adapter.sendEvent(ContinuedEventBody(
threadId: thread.threadId,
// Although the DAP spec makes it seem like this defaults to false,
// VS Code treats it as true. As such, always provide it explicitly.
// https://github.com/microsoft/vscode/issues/224832#issuecomment-2469552752
allThreadsContinued: false,
));
thread.paused = false;
thread.pauseEvent = null;
thread.exceptionReference = null;
}
}
/// Handles an inspect event from the VM, sending the value/variable to the
/// debugger.
void _handleInspect(vm.Event event) {
final isolate = event.isolate!;
final thread = _threadsByIsolateId[isolate.id!];
final inspectee = event.inspectee;
if (thread != null && inspectee != null) {
final ref = thread.storeData(InspectData(inspectee));
_adapter.sendOutput(
'console',
'', // Not shown by the client because it fetches the variable.
variablesReference: ref,
);
}
}
/// Handles 'BreakpointAdded'/'BreakpointResolved' events from the VM,
/// informing the client of updated information about the breakpoint.
///
/// Information about unresolved breakpoints will be ignored to avoid
/// overwriting resolved breakpoint info with unresolved/stale info in the
/// case of multiple isolates where they haven't all loaded the scripts that
/// we added breakpoints for.
void _handleBreakpointAddedOrResolved(vm.Event event) {
final breakpoint = event.breakpoint!;
final isolateId = event.isolate!.id!;
final breakpointId = breakpoint.id!;
final uniqueBreakpointId =
(isolateId: isolateId, breakpointId: breakpointId);
if (!(breakpoint.resolved ?? false)) {
// Unresolved breakpoint, don't need to do anything.
return;
}
// If we already have an event, assert that the resolution location is the
// same because we are making assumptions that we can reuse these resolution
// events to speed up telling the client a breakpoint was resolved.
assert(() {
final existingResolvedEvent =
_breakpointResolvedEventsByVmId[uniqueBreakpointId];
if (existingResolvedEvent != null) {
final existingLocation =
existingResolvedEvent.breakpoint?.location as vm.SourceLocation?;
final newLocation = event.breakpoint?.location as vm.SourceLocation?;
assert(existingLocation!.line == newLocation!.line);
assert(existingLocation!.column == newLocation!.column);
}
return true;
}());
// Store this event so if we get any future breakpoints that resolve to this
// VM breakpoint, we can access the resolution info.
_breakpointResolvedEventsByVmId[(
isolateId: isolateId,
breakpointId: breakpointId
)] = event;
// And for existing breakpoints, send (or queue) resolved events.
final existingBreakpoints = _clientBreakpointsByVmId[uniqueBreakpointId];
for (final existingBreakpoint in existingBreakpoints ?? const []) {
queueBreakpointResolutionEvent(event, existingBreakpoint);
}
}
/// Queues a breakpoint resolution event that passes resolution info from
/// the VM back to the client.
///
/// This queue will be processed only after the client has been given the ID
/// of this breakpoint. If that has already happened, the event will be
/// processed on the next task queue iteration.
void queueBreakpointResolutionEvent(
vm.Event addedOrResolvedEvent,
ClientBreakpoint clientBreakpoint,
) {
assert(addedOrResolvedEvent.breakpoint != null);
final breakpoint = addedOrResolvedEvent.breakpoint!;
assert(breakpoint.resolved ?? false);
// This is always resolved because of the check above.
final location = breakpoint.location;
final resolvedLocation = location as vm.SourceLocation;
final updatedBreakpoint = Breakpoint(
id: clientBreakpoint.id,
line: resolvedLocation.line,
column: resolvedLocation.column,
verified: true,
);
// Ensure we don't send the breakpoint event until the client has been
// given the breakpoint ID by queueing it.
clientBreakpoint.queueAction(
() => _adapter.sendEvent(
BreakpointEventBody(breakpoint: updatedBreakpoint, reason: 'changed'),
),
);
}
/// Queues a breakpoint event that passes an error reason from the VM back to
/// the client.
///
/// This queue will be processed only after the client has been given the ID
/// of this breakpoint. If that has already happened, the event will be
/// processed on the next task queue iteration.
void queueFailedBreakpointEvent(
Object error,
ClientBreakpoint clientBreakpoint,
) {
// Attempt to clean up the message to something that fits better in a
// tooltip.
//
// An example failure is:
//
// addBreakpointWithScriptUri: Cannot add breakpoint at line 8.
// Error occurred when resolving breakpoint location:
// No debuggable code where breakpoint was requested.
var userMessage = error is vm.RPCError
? error.details ?? error.toString()
: error.toString();
var terseMessageMatch =
_terseBreakpointFailureRegex.firstMatch(userMessage);
if (terseMessageMatch != null) {
userMessage = terseMessageMatch.group(1) ?? userMessage;
}
final updatedBreakpoint = Breakpoint(
id: clientBreakpoint.id,
verified: false,
message: userMessage,
reason: 'failed',
);
// Ensure we don't send the breakpoint event until the client has been
// given the breakpoint ID by queueing it.
clientBreakpoint.queueAction(
() => _adapter.sendEvent(
BreakpointEventBody(breakpoint: updatedBreakpoint, reason: 'changed'),
),
);
}
/// Attempts to resolve [uris] to file:/// URIs via the VM Service.
///
/// This method calls the VM service directly. Most requests to resolve URIs
/// should go through [ThreadInfo]'s resolveXxx methods which perform caching
/// of results.
Future<List<Uri?>?> _lookupResolvedPackageUris<T extends vm.Response>(
vm.IsolateRef isolate,
List<Uri> uris,
) async {
final isolateId = isolate.id!;
final uriStrings = uris.map((uri) => uri.toString()).toList();
try {
final res = await _adapter.vmService
?.lookupResolvedPackageUris(isolateId, uriStrings, local: true);
return res?.uris
?.cast<String?>()
.map((uri) => uri != null ? Uri.parse(uri) : null)
.toList();
} on vm.SentinelException {
// If the isolate disappeared before we sent this request, just return
// null responses.
return uris.map((e) => null).toList();
} on vm.RPCError catch (e) {
if (e.code == RpcErrorCodes.kInternalError &&
e.message.contains('No running isolate (inspector is not set).')) {
// TODO(bkonyi): remove once https://github.com/flutter/flutter/issues/156793
// is resolved.
// If the isolate disappeared before we sent this request, just return
// null responses.
return uris.map((e) => null).toList();
} else {
rethrow;
}
}
}
/// 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.sendConsoleOutput(messageResult?.valueAsString);
}
}
/// Resumes any paused isolates.
Future<void> resumeAll() async {
final pausedThreads = threads.where((thread) => thread.paused).toList();
await Future.wait(
pausedThreads.map((thread) => resumeThread(thread.threadId)),
);
}
/// 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(ThreadInfo thread, {String? uri}) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
final isolateId = thread.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.toList();
for (final uri in uris) {
// Clear existing breakpoints.
final existingBreakpointsForIsolate =
_vmBreakpointsByIsolateIdAndUri.putIfAbsent(isolateId, () => {});
final existingBreakpointsForIsolateAndUri =
existingBreakpointsForIsolate.putIfAbsent(uri, () => {});
// Before doing async work, take a copy of the breakpoints to remove
// and remove them from the list, so any subsequent calls here don't
// try to remove the same ones multiple times.
final breakpointsToRemove =
existingBreakpointsForIsolateAndUri.values.toList();
existingBreakpointsForIsolateAndUri.clear();
await Future.forEach<vm.Breakpoint>(breakpointsToRemove, (bp) async {
try {
await service.removeBreakpoint(isolateId, bp.id!);
} catch (e) {
// Swallow errors removing breakpoints rather than failing the whole
// request as it's very possible that an isolate exited while we were
// sending this and the request will fail.
_adapter.logger?.call('Failed to remove old breakpoint $e');
}
});
// Set new breakpoints.
final newBreakpoints = _clientBreakpointsByUri[uri] ?? const [];
await Future.forEach<ClientBreakpoint>(newBreakpoints, (bp) async {
try {
// Some file URIs (like SDK sources) need to be converted to
// appropriate internal URIs to be able to set breakpoints.
final vmUri = await thread.resolvePathToUri(Uri.parse(uri));
if (vmUri == null) {
return;
}
final vmBp = await service.addBreakpointWithScriptUri(
isolateId, vmUri.toString(), bp.breakpoint.line,
column: bp.breakpoint.column);
final vmBpId = vmBp.id!;
final uniqueBreakpointId =
(isolateId: isolateId, breakpointId: vmBp.id!);
existingBreakpointsForIsolateAndUri[vmBpId] = vmBp;
// Store this client breakpoint by the VM ID, so when we get events
// from the VM we can map them back to client breakpoints (for example
// to send resolved events).
_clientBreakpointsByVmId
.putIfAbsent(uniqueBreakpointId, () => [])
.add(bp);
// Queue any resolved events that may have already arrived
// (either because the VM sent them before responding to us, or
// because it gave us an existing VM breakpoint because it resolved to
// the same location as another).
final resolvedEvent =
_breakpointResolvedEventsByVmId[uniqueBreakpointId];
if (resolvedEvent != null) {
queueBreakpointResolutionEvent(resolvedEvent, 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');
queueFailedBreakpointEvent(e, bp);
}
});
}
}
/// Sets the exception pause mode for an individual isolate.
Future<void> _sendExceptionPauseMode(ThreadInfo thread) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
await service.setIsolatePauseMode(
thread.isolate.id!,
exceptionPauseMode: _exceptionPauseMode,
);
}
/// Calls setLibraryDebuggable for all libraries in the given isolate based
/// on the debug settings.
Future<void> _sendLibraryDebuggables(ThreadInfo thread) async {
final service = _adapter.vmService;
if (!debug || service == null) {
return;
}
final isolateId = thread.isolate.id!;
final isolate = await service.getIsolate(isolateId);
final libraries = isolate.libraries;
if (libraries == null) {
return;
}
// Pre-resolve all URIs in batch so the call below does not trigger
// many requests to the server.
if (!debugExternalPackageLibraries) {
final allUris = libraries
.map((library) => library.uri)
.nonNulls
.map(Uri.parse)
.toList();
await thread.resolveUrisToPackageLibPathsBatch(allUris);
}
await Future.wait(libraries.map((library) async {
final libraryUri = library.uri;
final isDebuggableNew = libraryUri != null
? await _adapter.libraryIsDebuggable(thread, Uri.parse(libraryUri))
: false;
final isDebuggableCurrent =
thread.getIsLibraryCurrentlyDebuggable(library);
thread.setIsLibraryCurrentlyDebuggable(library, isDebuggableNew);
if (isDebuggableNew == isDebuggableCurrent) {
return;
}
try {
await service.setLibraryDebuggable(
isolateId, library.id!, isDebuggableNew);
} on vm.RPCError catch (e) {
// DWDS does not currently support `setLibraryDebuggable` so instead of
// failing (because this code runs in a VM event handler where there's
// no incoming request to fail/reject), just log this error.
// https://github.com/dart-lang/webdev/issues/606
if (e.code == RpcErrorCodes.kMethodNotFound) {
_adapter.logger?.call(
'setLibraryDebuggable not available ($libraryUri, $e)',
);
} else {
rethrow;
}
}
}));
}
/// 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);
}
/// Clears all data stored for [thread].
///
/// References to stored data become invalid when a thread is resumed.
void clearStoredData(ThreadInfo thread) {
_storedData.removeWhere((_, value) => value.thread == thread);
}
}
/// Holds state for a single Isolate/Thread.
class ThreadInfo with FileUtils {
final IsolateManager _manager;
final vm.IsolateRef isolate;
final int threadId;
var runnable = false;
var atAsyncSuspension = false;
int? exceptionReference;
/// A [Completer] that completes with the evaluation zone ID for this thread.
///
/// The completer is created when the request to create an evaluation zone is
/// started (which is lazy, the first time evaluation is performed).
///
/// When the Debug Adapter is ready to resume this Isolate, it will first
/// invalidate all evaluation IDs in this zone so that they can be collected.
/// If the [Completer] is null, no evaluation has occurred and invalidation
/// can be skipped.
Completer<String?>? _currentEvaluationZoneIdCompleter;
/// Returns the current evaluation zone ID.
///
/// To avoid additional 'await's, may return a String? directly if the value
/// is already available.
FutureOr<String?> get currentEvaluationZoneId {
// We already have the value, avoid the Future.
if (_currentEvaluationZoneId != null) {
return _currentEvaluationZoneId;
}
return _createOrGetEvaluationZoneId();
}
/// The current evaluation zone ID (if available).
String? _currentEvaluationZoneId;
/// Whether this thread is currently known to be paused in the VM.
///
/// Because requests are async, this is not guaranteed to be always correct
/// but should represent the state based on the latest VM events.
var paused = false;
/// Tracks whether an isolates startup routine has been handled.
///
/// The startup routine will either automatically resume the isolate or send
/// a stopped-on-entry event, depending on whether we're launching or
/// attaching.
///
/// 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.
///
/// In the case of attach, a similar race exists.. The initialization may
/// choose not to resume the isolate (so we can attach to a VM with paused
/// isolates) but then a PauseStart event that arrived during initialization
/// could trigger a resume that we don't want.
bool startupHandled = 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>>{};
/// A cache of requests (Futures) to resolve URIs to their file-like URIs.
///
/// Used so that multiple requests that require them (for example looking up
/// locations for stack frames from tokenPos) can share the same response.
///
/// Keys are URIs in string form.
/// Values are file-like URIs (file: or similar, such as dart-macro+file:).
final _resolvedPaths = <String, Future<Uri?>>{};
/// Whether this isolate has an in-flight user-initiated resume request that
/// has not yet been responded to.
var hasPendingUserResume = 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));
}
/// Fetches scripts for a given isolate.
Future<vm.ScriptList> getScripts() {
return _manager.getScripts(isolate);
}
/// Returns the evaluation zone ID for this thread.
///
/// If it has not been created yet, creates it. If creation is in progress,
/// returns the existing future.
Future<String?> _createOrGetEvaluationZoneId() async {
// If we already have a completer, the request is already in flight (or
// has completed).
var completer = _currentEvaluationZoneIdCompleter;
if (completer != null) {
return completer.future;
}
// Otherwise, we need to start the request.
_currentEvaluationZoneIdCompleter = completer = Completer();
try {
final response = await _manager._adapter.vmService?.createIdZone(
isolate.id!,
vm.IdZoneBackingBufferKind.kRing,
vm.IdAssignmentPolicy.kAlwaysAllocate,
// Default capacity is 512. Since these are short-lived (only while
// paused) and we don't want to prevent expanding Lists, use something a
// little bigger.
capacity: 2048,
);
_currentEvaluationZoneId = response?.id;
} catch (_) {
// If this request fails for any reason (perhaps the target VM does not
// support this request), we should just use `null` as the zone ID and not
// prevent any evaluation requests.
_currentEvaluationZoneId = null;
}
completer.complete(_currentEvaluationZoneId);
return _currentEvaluationZoneId;
}
/// Resolves a source file path (or URI) into a URI for the VM.
///
/// sdk-path/lib/core/print.dart -> dart:core/print.dart
/// c:\foo\bar -> package:foo/bar
/// dart-macro+file:///c:/foo/bar -> dart-macro+package:foo/bar
///
/// This is required so that when the user sets a breakpoint in an SDK source
/// (which they may have navigated to via the Analysis Server) we generate a
/// valid URI that the VM would create a breakpoint for.
///
/// Because the VM supports using `file:` URIs in many places, we usually do
/// not need to convert file paths into `package:` URIs, however this will
/// be done if [forceResolveFileUris] is `true`.
Future<Uri?> resolvePathToUri(
Uri sourcePathUri, {
bool forceResolveFileUris = false,
}) async {
final sdkUri = _manager._adapter.convertUriToOrgDartlangSdk(sourcePathUri);
if (sdkUri != null) {
return sdkUri;
}
final google3Uri = _convertPathToGoogle3Uri(sourcePathUri);
final uri = google3Uri ?? sourcePathUri;
// As an optimisation, we don't resolve file -> package URIs in many cases
// because the VM can set breakpoints for file: URIs anyway. However for
// G3 or if [forceResolveFileUris] is set, we will.
final performResolve = google3Uri != null || forceResolveFileUris;
// TODO(dantup): Consider caching results for this like we do for
// resolveUriToPath (and then forceResolveFileUris can be removed and just
// always used).
final packageUriList = performResolve
? await _manager._adapter.vmService
?.lookupPackageUris(isolate.id!, [uri.toString()])
: null;
final packageUriString = packageUriList?.uris?.firstOrNull;
if (packageUriString != null) {
// Use package URI if we resolved something
return Uri.parse(packageUriString);
} else if (google3Uri != null) {
// If we failed to resolve and was a Google3 URI, return null
return null;
} else {
// Otherwise, use the original (file) URI
return uri;
}
}
/// Batch resolves source URIs from the VM to a file-like URI for the package
/// lib folder.
///
/// This method is more performant than repeatedly calling
/// [resolveUrisToPackageLibPath] because it resolves multiple URIs in a
/// single request to the VM.
///
/// Results are cached and shared with [resolveUrisToPackageLibPath] (and
/// [resolveUriToPath]) so it's reasonable to call this method up-front and
/// then use [resolveUrisToPackageLibPath] (and [resolveUriToPath]) to read
/// the results later.
Future<List<Uri?>> resolveUrisToPackageLibPathsBatch(
List<Uri> uris,
) async {
final results = await resolveUrisToPathsBatch(uris);
return results
.mapIndexed((i, filePath) => _trimPathToLibFolder(filePath, uris[i]))
.toList();
}
/// Batch resolves source URIs from the VM to a file-like URI.
///
/// This method is more performant than repeatedly calling [resolveUriToPath]
/// because it resolves multiple URIs in a single request to the VM.
///
/// Results are cached and shared with [resolveUriToPath] so it's reasonable
/// to call this method up-front and then use [resolveUriToPath] to read
/// the results later.
Future<List<Uri?>> resolveUrisToPathsBatch(List<Uri> uris) async {
// First find the set of URIs we don't already have results for.
final requiredUris = uris
.where(isResolvableUri)
.where((uri) => !_resolvedPaths.containsKey(uri.toString()))
.toSet() // Take only distinct values.
.toList();
if (requiredUris.isNotEmpty) {
// Populate completers for each URI before we start the request so that
// concurrent calls to this method will not start their own requests.
final completers = Map<String, Completer<Uri?>>.fromEntries(
requiredUris.map((uri) => MapEntry('$uri', Completer<Uri?>())),
);
completers.forEach(
(uri, completer) => _resolvedPaths[uri] = completer.future,
);
try {
final results =
await _manager._lookupResolvedPackageUris(isolate, requiredUris);
if (results == null) {
// If no result, all of the results are null.
completers.forEach((uri, completer) => completer.complete(null));
} else if (results.length != requiredUris.length) {
// If the lengths of the lists are different, we have an invalid
// response from the VM. This is a bug in the VM/VM Service:
// https://github.com/dart-lang/sdk/issues/52632
final reason =
results.length > requiredUris.length ? 'more' : 'fewer';
final message =
'lookupResolvedPackageUris result contained $reason results than '
'the request. See https://github.com/dart-lang/sdk/issues/52632';
final error = Exception(message);
completers
.forEach((uri, completer) => completer.completeError(error));
} else {
// Otherwise, complete each one by index with the corresponding value.
results.map(_convertUriToFilePath).forEachIndexed((i, result) {
final uri = requiredUris[i].toString();
completers[uri]!.complete(result);
});
}
} catch (e) {
// We can't leave dangling completers here because others may already
// be waiting on them, so propagate the error to them.
completers.forEach((uri, completer) {
// Only complete if not already completed. It's possible an exception
// occurred above inside the loop and that some of the completers have
// already completed. We don't want to replace a good exception with
// "Future already completed".
if (!completer.isCompleted) {
completer.completeError(e);
}
});
// Don't rethrow here, because it will cause these completers futures
// to not have error handlers attached which can cause their errors to
// go unhandled. Instead, these completers futures will be returned
// below and awaited by the caller (which will propagate the errors).
}
}
// Finally, assemble a list of the values by using the cached futures and
// the original list. Any non-file URI is guaranteed to be in [_resolvedPaths]
// because they were either filtered out of [requiredUris] because they were
// already there, or we then populated completers for them above.
final futures = uris.map((uri) async {
if (_manager._adapter.isSupportedFileScheme(uri)) {
return uri;
} else {
return await _resolvedPaths[uri.toString()];
}
});
return Future.wait(futures);
}
/// Returns whether [library] is currently debuggable according to the VM
/// (or there is a request in-flight to set it).
bool getIsLibraryCurrentlyDebuggable(vm.LibraryRef library) {
return _libraryIsDebuggableById[library.id!] ??
_getIsLibraryDebuggableByDefault(library);
}
/// Records whether [library] is currently debuggable for this isolate.
///
/// This should be called whenever a `setLibraryDebuggable` request is made
/// to the VM.
void setIsLibraryCurrentlyDebuggable(
vm.LibraryRef library,
bool isDebuggable,
) {
if (isDebuggable == _getIsLibraryDebuggableByDefault(library)) {
_libraryIsDebuggableById.remove(library.id!);
} else {
_libraryIsDebuggableById[library.id!] = isDebuggable;
}
}
/// Returns whether [library] is debuggable by default.
///
/// This value is _assumed_ to avoid having to fetch each library for each
/// isolate.
bool _getIsLibraryDebuggableByDefault(vm.LibraryRef library) {
final isSdkLibrary = library.uri?.startsWith('dart:') ?? false;
return !isSdkLibrary;
}
/// Tracks whether libraries are currently marked as debuggable in the VM.
///
/// If a library ID is not in the map, it is set to the default (which is
/// debuggable for non-SDK sources, and not-debuggable for SDK sources).
///
/// This can be used to avoid calling setLibraryDebuggable where the value
/// would not be changed.
final _libraryIsDebuggableById = <String, bool>{};
/// Resolves a source URI to a file-like URI for the lib folder of its
/// package.
///
/// package:foo/a/b/c/d.dart -> file:///code/packages/foo/lib
/// dart-macro+package:foo/a/b/c/d.dart -> dart-macro+file:///code/packages/foo/lib
///
/// This method is an optimisation over calling [resolveUriToPath] where only
/// the package root is required (for example when determining whether a
/// package is within the users workspace). This method allows results to be
/// cached per-package to avoid hitting the VM Service for each individual
/// library within a package.
Future<Uri?> resolveUriToPackageLibPath(Uri uri) async {
final result = await resolveUrisToPackageLibPathsBatch([uri]);
return result.first;
}
/// Resolves a source URI from the VM to a file-like URI.
///
/// dart:core/print.dart -> sdk-path/lib/core/print.dart
///
/// This is required so that when the user stops (or navigates via a stack
/// frame) we open the same file on their local disk. If we downloaded the
/// source from the VM, they would end up seeing two copies of files (and they
/// would each have their own breakpoints) which can be confusing.
Future<Uri?> resolveUriToPath(Uri uri) async {
final result = await resolveUrisToPathsBatch([uri]);
return result.first;
}
/// 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);
Uri? _convertPathToGoogle3Uri(Uri input) {
// TODO(dantup): Do we need to handle non-file here? Eg. can we have
// dart-macro+file:/// for a google3 path?
if (!input.isScheme('file')) {
return null;
}
final inputPath = input.toFilePath();
const search = '/google3/';
if (inputPath.startsWith('/google') && inputPath.contains(search)) {
var idx = inputPath.indexOf(search);
var remainingPath = inputPath.substring(idx + search.length);
return Uri(
scheme: 'google3',
host: '',
path: remainingPath,
);
}
return null;
}
/// Converts a VM-returned URI to a file-like URI, taking org-dartlang-sdk
/// schemes into account.
///
/// Supports file-like URIs and org-dartlang-sdk:// URIs.
Uri? _convertUriToFilePath(Uri? input) {
if (input == null) {
return null;
} else if (_manager._adapter.isSupportedFileScheme(input)) {
return input;
} else {
// TODO(dantup): UriConverter should be upgraded to use file-like URIs
// instead of paths, but that might be breaking because it's used
// outside of this package?
final uriConverter = _manager._adapter.uriConverter();
if (uriConverter != null) {
final filePath = uriConverter(input.toString());
return filePath != null ? Uri.file(filePath) : null;
}
return _manager._adapter.convertOrgDartlangSdkToPath(input);
}
}
/// Helper to remove a libraries path from the a file path so it points at the
/// lib folder.
///
/// [uri] should be the equivalent package: URI and is used to know how many
/// segments to remove from the file path to get to the lib folder.
Uri? _trimPathToLibFolder(Uri? fileLikeUri, Uri uri) {
if (fileLikeUri == null) {
return null;
}
// Track how many segments from the path are from the lib folder to the
// library that will need to be removed later.
final libraryPathSegments = uri.pathSegments.length - 1;
// It should never be the case that the returned value doesn't have at
// least as many segments as the path of the URI.
assert(uri.pathSegments.length > libraryPathSegments);
if (uri.pathSegments.length <= libraryPathSegments) {
return fileLikeUri;
}
// Strip off the correct number of segments to the resulting path points
// to the root of the package:/ URI.
final keepSegments = fileLikeUri.pathSegments.length - libraryPathSegments;
return fileLikeUri.replace(
pathSegments: fileLikeUri.pathSegments.sublist(0, keepSegments));
}
/// Clears all temporary stored for this thread. This includes:
///
/// - dropping any variablesReferences
/// - invalidating the evaluation ID zone
///
/// This is generally called when requesting execution continues, since any
/// evaluated references are not expected to live past this point.
///
/// https://microsoft.github.io/debug-adapter-protocol/overview#lifetime-of-objects-references
Future<void> clearTemporaryData() async {
// Clear variablesReferences.
_manager.clearStoredData(this);
// Invalidate all existing references in this evaluation zone.
// If the completer is null, no zone has ever been created (or started to
// be created), so this can be skipped.
if (_currentEvaluationZoneIdCompleter != null) {
final futureOrEvalZoneId = currentEvaluationZoneId;
final evalZoneId = futureOrEvalZoneId is String
? futureOrEvalZoneId
: await futureOrEvalZoneId;
if (evalZoneId != null) {
await _manager._adapter.vmService
?.invalidateIdZone(isolate.id!, evalZoneId);
}
}
}
/// Attempts to get a [vm.LibraryRef] for the given [scriptFileUri].
///
/// This involves fetching all scripts for this isolate and looking for a
/// match and then returning the relevant library reference.
Future<vm.LibraryRef?> getLibraryForFileUri(Uri scriptFileUri) async {
// We start with a file URI and need to find the Library (via the script).
//
// We need to handle msimatched drive letters, and also file vs package
// URIs.
final scriptResolvedUri = await resolvePathToUri(
scriptFileUri,
forceResolveFileUris: true,
);
final candidateUris = {
scriptFileUri.toString(),
normalizeUri(scriptFileUri).toString(),
if (scriptResolvedUri != null) scriptResolvedUri.toString(),
if (scriptResolvedUri != null) normalizeUri(scriptResolvedUri).toString(),
};
// Find the matching script/library.
final scriptRefs = (await getScripts()).scripts ?? const [];
final scriptRef = scriptRefs
.singleWhereOrNull((script) => candidateUris.contains(script.uri));
final script = scriptRef != null ? await getScript(scriptRef) : null;
return script?.library;
}
}
/// A wrapper over the client-provided [SourceBreakpoint] with a unique ID.
///
/// In order to tell clients about breakpoint changes (such as resolution) we
/// must assign them an ID. If the VM does not have any running Isolates at the
/// time initial breakpoints are set we cannot yet send the breakpoints (and
/// therefore cannot get IDs from the VM). So we generate our own IDs and hold
/// them with the breakpoint here. When we get a 'BreakpointResolved' event we
/// can look up this [ClientBreakpoint] and use the ID to send an update to the
/// client.
class ClientBreakpoint {
/// The next number to use as a client ID for breakpoints.
///
/// To slightly improve debugging, we start this at 100000 so it doesn't
/// initially overlap with VM-produced breakpoint numbers so it's more obvious
/// in log files which numbers are DAP-client and which are VM.
static int _nextId = 100000;
final SourceBreakpoint breakpoint;
final int id;
/// A [Future] that completes with the last action that sends breakpoint
/// information to the client, to ensure breakpoint events are always sent
/// in-order and after the initial response sending the IDs to the client.
Future<void> _lastActionFuture;
ClientBreakpoint(this.breakpoint, Future<void> setBreakpointResponse)
: id = _nextId++,
_lastActionFuture = setBreakpointResponse;
/// Queues an action to run after all previous actions that sent breakpoint
/// information to the client.
FutureOr<T> queueAction<T>(FutureOr<T> Function() action) {
final actionFuture = _lastActionFuture.then((_) => action());
_lastActionFuture = actionFuture;
return actionFuture;
}
}
/// Tracks actions resulting from `BreakpointAdded`/`BreakpointResolved` events
/// that arrive before the `addBreakpointWithScriptUri` request completes.
///
/// These events need to be chained into the end of the [ClientBreakpoint] once
/// that request completes.
class PendingBreakpointActions {
/// A completer that will trigger processing of the queue.
final completer = Completer<void>();
/// A [Future] that completes with the last action in the queue.
late Future<void> _lastActionFuture;
PendingBreakpointActions() {
_lastActionFuture = completer.future;
}
/// Queues an action to run after all previous actions.
FutureOr<T> queueAction<T>(FutureOr<T> Function() action) {
final actionFuture = _lastActionFuture.then((_) => action());
_lastActionFuture = actionFuture;
return actionFuture;
}
}
class StoredData {
final ThreadInfo thread;
final Object data;
StoredData(this.thread, this.data);
}