[dds] Add support for breakpoints and stepping to DAP

Change-Id: Iebd2c5630f826effac56ece3b4ffb3252b9ac556
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/201834
Reviewed-by: Ben Konyi <bkonyi@google.com>
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
This commit is contained in:
Danny Tuppeny 2021-06-11 17:51:01 +00:00 committed by commit-bot@chromium.org
parent d5d1d6e6f6
commit 6ebcabaaf2
12 changed files with 1094 additions and 87 deletions

View file

@ -9,8 +9,10 @@ import 'package:collection/collection.dart';
import 'package:vm_service/vm_service.dart' as vm;
import '../base_debug_adapter.dart';
import '../exceptions.dart';
import '../isolate_manager.dart';
import '../logging.dart';
import '../protocol_converter.dart';
import '../protocol_generated.dart';
import '../protocol_stream.dart';
@ -56,10 +58,13 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
final _debuggerInitializedCompleter = Completer<void>();
final _configurationDoneCompleter = Completer<void>();
/// Managers VM Isolates and their events, including fanning out any requests
/// Manages VM Isolates and their events, including fanning out any requests
/// to set breakpoints etc. from the client to all Isolates.
late IsolateManager _isolateManager;
/// A helper that handlers converting to/from DAP and VM Service types.
late ProtocolConverter _converter;
/// All active VM Service subscriptions.
///
/// TODO(dantup): This may be changed to use StreamManager as part of using
@ -80,6 +85,7 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
DartDebugAdapter(ByteStreamServerChannel channel, Logger? logger)
: super(channel, logger) {
_isolateManager = IsolateManager(this);
_converter = ProtocolConverter(this);
}
/// Completes when the debugger initialization has completed. Used to delay
@ -92,19 +98,16 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// an existing app. This will only be called once (and only one of this or
/// launchRequest will be called).
@override
FutureOr<void> attachRequest(
Future<void> attachRequest(
Request request,
T args,
void Function(void) sendResponse,
void Function() sendResponse,
) async {
this.args = args;
isAttach = true;
// Don't start launching until configurationDone.
if (!_configurationDoneCompleter.isCompleted) {
logger?.call('Waiting for configurationDone request...');
await _configurationDoneCompleter.future;
}
// Common setup.
await _prepareForLaunchOrAttach();
// TODO(dantup): Implement attach support.
throw UnimplementedError();
@ -112,7 +115,7 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
// Delegate to the sub-class to attach to the process.
// await attachImpl();
//
// sendResponse(null);
// sendResponse();
}
/// configurationDone is called by the client when it has finished sending
@ -123,13 +126,13 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// been sent to ensure we're not still getting breakpoints (which are sent
/// per-file) while we're launching and initializing over the VM Service.
@override
FutureOr<void> configurationDoneRequest(
Future<void> configurationDoneRequest(
Request request,
ConfigurationDoneArguments? args,
void Function(void) sendResponse,
void Function() sendResponse,
) async {
_configurationDoneCompleter.complete();
sendResponse(null);
sendResponse();
}
/// Connects to the VM Service at [uri] and initializes debugging.
@ -219,13 +222,25 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
_debuggerInitializedCompleter.complete();
}
/// Handles the clients "continue" ("resume") request for the thread in
/// [args.threadId].
@override
Future<void> continueRequest(
Request request,
ContinueArguments args,
void Function(ContinueResponseBody) sendResponse,
) async {
await _isolateManager.resumeThread(args.threadId);
sendResponse(ContinueResponseBody(allThreadsContinued: false));
}
/// Overridden by sub-classes to perform any additional setup after the VM
/// Service is connected.
FutureOr<void> debuggerConnected(vm.VM vmInfo);
Future<void> debuggerConnected(vm.VM vmInfo);
/// Overridden by sub-classes to handle when the client sends a
/// `disconnectRequest` (a forceful request to shut down).
FutureOr<void> disconnectImpl();
Future<void> disconnectImpl();
/// disconnectRequest is called by the client when it wants to forcefully shut
/// us down quickly. This comes after the `terminateRequest` which is intended
@ -237,13 +252,13 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
///
/// https://microsoft.github.io/debug-adapter-protocol/overview#debug-session-end
@override
FutureOr<void> disconnectRequest(
Future<void> disconnectRequest(
Request request,
DisconnectArguments? args,
void Function(void) sendResponse,
void Function() sendResponse,
) async {
await disconnectImpl();
sendResponse(null);
sendResponse();
}
/// initializeRequest is the first request send by the client during
@ -254,7 +269,7 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// https://microsoft.github.io/debug-adapter-protocol/overview#initialization
/// with a summary in this classes description.
@override
FutureOr<void> initializeRequest(
Future<void> initializeRequest(
Request request,
InitializeRequestArguments? args,
void Function(Capabilities) sendResponse,
@ -293,30 +308,39 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
///
/// Sub-classes can use the [args] field to access the arguments provided
/// to this request.
FutureOr<void> launchImpl();
Future<void> launchImpl();
/// launchRequest is called by the client when it wants us to to start the app
/// to be run/debug. This will only be called once (and only one of this or
/// attachRequest will be called).
@override
FutureOr<void> launchRequest(
Future<void> launchRequest(
Request request,
T args,
void Function(void) sendResponse,
void Function() sendResponse,
) async {
this.args = args;
isAttach = false;
// Don't start launching until configurationDone.
if (!_configurationDoneCompleter.isCompleted) {
logger?.call('Waiting for configurationDone request...');
await _configurationDoneCompleter.future;
}
// Common setup.
await _prepareForLaunchOrAttach();
// Delegate to the sub-class to launch the process.
await launchImpl();
sendResponse(null);
sendResponse();
}
/// Handles the clients "next" ("step over") request for the thread in
/// [args.threadId].
@override
Future<void> nextRequest(
Request request,
NextArguments args,
void Function() sendResponse,
) async {
await _isolateManager.resumeThread(args.threadId, vm.StepOption.kOver);
sendResponse();
}
/// Sends an OutputEvent (without a newline, since calls to this method
@ -337,9 +361,160 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
sendOutput(category, '$prefix$indentedMessage\n');
}
/// Handles a request from the client to set breakpoints.
///
/// This method can be called at any time (before the app is launched or while
/// the app is running) and will include the new full set of breakpoints for
/// the file URI in [args.source.path].
///
/// The VM requires breakpoints to be set per-isolate so these will be passed
/// to [_isolateManager] that will fan them out to each isolate.
///
/// When new isolates are registered, it is [isolateManager]s responsibility
/// to ensure all breakpoints are given to them (and like at startup, this
/// must happen before they are resumed).
@override
Future<void> setBreakpointsRequest(
Request request,
SetBreakpointsArguments args,
void Function(SetBreakpointsResponseBody) sendResponse,
) async {
final breakpoints = args.breakpoints ?? [];
final path = args.source.path;
final name = args.source.name;
final uri = path != null ? Uri.file(path).toString() : name!;
await _isolateManager.setBreakpoints(uri, breakpoints);
// TODO(dantup): Handle breakpoint resolution rather than pretending all
// breakpoints are verified immediately.
sendResponse(SetBreakpointsResponseBody(
breakpoints: breakpoints.map((e) => Breakpoint(verified: true)).toList(),
));
}
/// Handles a request from the client for the call stack for [args.threadId].
///
/// This is usually called after we sent a [StoppedEvent] to the client
/// notifying it that execution of an isolate has paused and it wants to
/// populate the call stack view.
///
/// Clients may fetch the frames in batches and VS Code in particular will
/// send two requests initially - one for the top frame only, and then one for
/// the next 19 frames. For better performance, the first request is satisfied
/// entirely from the threads pauseEvent.topFrame so we do not need to
/// round-trip to the VM Service.
@override
Future<void> stackTraceRequest(
Request request,
StackTraceArguments args,
void Function(StackTraceResponseBody) sendResponse,
) async {
// We prefer to provide frames in small batches. Rather than tell the client
// how many frames there really are (which can be expensive to compute -
// especially for web) we just add 20 on to the last frame we actually send,
// as described in the spec:
//
// "Returning monotonically increasing totalFrames values for subsequent
// requests can be used to enforce paging in the client."
const stackFrameBatchSize = 20;
final threadId = args.threadId;
final thread = _isolateManager.getThread(threadId);
final topFrame = thread?.pauseEvent?.topFrame;
final startFrame = args.startFrame ?? 0;
final numFrames = args.levels ?? 0;
var totalFrames = 1;
if (thread == null) {
throw DebugAdapterException('No thread with threadId $threadId');
}
if (!thread.paused) {
throw DebugAdapterException('Thread $threadId is not paused');
}
final stackFrames = <StackFrame>[];
// If the request is only for the top frame, we may be able to satisfy it
// from the threads `pauseEvent.topFrame`.
if (startFrame == 0 && numFrames == 1 && topFrame != null) {
totalFrames = 1 + stackFrameBatchSize;
final dapTopFrame = await _converter.convertVmToDapStackFrame(
thread,
topFrame,
isTopFrame: true,
);
stackFrames.add(dapTopFrame);
} else {
// Otherwise, send the request on to the VM.
// The VM doesn't support fetching an arbitrary slice of frames, only a
// maximum limit, so if the client asks for frames 20-30 we must send a
// request for the first 30 and trim them ourselves.
final limit = startFrame + numFrames;
final stack = await vmService?.getStack(thread.isolate.id!, limit: limit);
final frames = stack?.frames;
if (stack != null && frames != null) {
// When the call stack is truncated, we always add [stackFrameBatchSize]
// to the count, indicating to the client there are more frames and
// the size of the batch they should request when "loading more".
//
// It's ok to send a number that runs past the actual end of the call
// stack and the client should handle this gracefully:
//
// "a client should be prepared to receive less frames than requested,
// which is an indication that the end of the stack has been reached."
totalFrames = (stack.truncated ?? false)
? frames.length + stackFrameBatchSize
: frames.length;
Future<StackFrame> convert(int index, vm.Frame frame) async {
return _converter.convertVmToDapStackFrame(
thread,
frame,
isTopFrame: startFrame == 0 && index == 0,
);
}
final frameSubset = frames.sublist(startFrame);
stackFrames.addAll(await Future.wait(frameSubset.mapIndexed(convert)));
}
}
sendResponse(
StackTraceResponseBody(
stackFrames: stackFrames,
totalFrames: totalFrames,
),
);
}
/// Handles the clients "step in" request for the thread in [args.threadId].
@override
Future<void> stepInRequest(
Request request,
StepInArguments args,
void Function() sendResponse,
) async {
await _isolateManager.resumeThread(args.threadId, vm.StepOption.kInto);
sendResponse();
}
/// Handles the clients "step out" request for the thread in [args.threadId].
@override
Future<void> stepOutRequest(
Request request,
StepOutArguments args,
void Function() sendResponse,
) async {
await _isolateManager.resumeThread(args.threadId, vm.StepOption.kOut);
sendResponse();
}
/// Overridden by sub-classes to handle when the client sends a
/// `terminateRequest` (a request for a graceful shut down).
FutureOr<void> terminateImpl();
Future<void> terminateImpl();
/// terminateRequest is called by the client when it wants us to gracefully
/// shut down.
@ -350,13 +525,13 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
///
/// https://microsoft.github.io/debug-adapter-protocol/overview#debug-session-end
@override
FutureOr<void> terminateRequest(
Future<void> terminateRequest(
Request request,
TerminateArguments? args,
void Function(void) sendResponse,
void Function() sendResponse,
) async {
terminateImpl();
sendResponse(null);
await terminateImpl();
sendResponse();
}
void _handleDebugEvent(vm.Event event) {
@ -378,7 +553,7 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
/// Helper to convert to InstanceRef to a String, taking into account
/// [vm.InstanceKind.kNull] which is the type for the unused fields of a
/// log event.
FutureOr<String?> asString(vm.InstanceRef? ref) {
Future<String?> asString(vm.InstanceRef? ref) async {
if (ref == null || ref.kind == vm.InstanceKind.kNull) {
return null;
}
@ -407,6 +582,21 @@ abstract class DartDebugAdapter<T extends DartLaunchRequestArguments>
}
}
/// Performs some setup that is common to both [launchRequest] and
/// [attachRequest].
Future<void> _prepareForLaunchOrAttach() async {
// Don't start launching until configurationDone.
if (!_configurationDoneCompleter.isCompleted) {
logger?.call('Waiting for configurationDone request...');
await _configurationDoneCompleter.future;
}
// Notify IsolateManager if we'll be debugging so it knows whether to set
// up breakpoints etc. when isolates are registered.
final debug = !(args.noDebug ?? false);
_isolateManager.setDebugEnabled(debug);
}
/// A wrapper around the same name function from package:vm_service that
/// allows logging all traffic over the VM Service.
Future<vm.VmService> _vmServiceConnectUri(
@ -455,8 +645,37 @@ class DartLaunchRequestArguments extends LaunchRequestArguments {
final int? vmServicePort;
final List<String>? vmAdditionalArgs;
final bool? enableAsserts;
/// Whether SDK libraries should be marked as debuggable.
///
/// Treated as `false` if null, which means "step in" will not step into SDK
/// libraries.
final bool? debugSdkLibraries;
/// Whether external package libraries should be marked as debuggable.
///
/// Treated as `false` if null, which means "step in" will not step into
/// libraries in packages that are not either the local package or a path
/// dependency. This allows users to debug "just their code" and treat Pub
/// packages as block boxes.
final bool? debugExternalPackageLibraries;
/// Whether to evaluate getters in debug views like hovers and the variables
/// list.
///
/// Invoking getters has a performance cost and may introduce side-effects,
/// although users may expected this functionality. null is treated like false
/// although clients may have their own defaults (for example Dart-Code sends
/// true by default at the time of writing).
final bool? evaluateGettersInDebugViews;
/// Whether to call toString() on objects in debug views like hovers and the
/// variables list.
///
/// Invoking toString() has a performance cost and may introduce side-effects,
/// although users may expected this functionality. null is treated like false
/// although clients may have their own defaults (for example Dart-Code sends
/// true by default at the time of writing).
final bool? evaluateToStringInDebugViews;
/// Whether to send debug logging to clients in a custom `dart.log` event. This
@ -476,6 +695,7 @@ class DartLaunchRequestArguments extends LaunchRequestArguments {
this.vmAdditionalArgs,
this.enableAsserts,
this.debugSdkLibraries,
this.debugExternalPackageLibraries,
this.evaluateGettersInDebugViews,
this.evaluateToStringInDebugViews,
this.sendLogsToClient,
@ -490,6 +710,8 @@ class DartLaunchRequestArguments extends LaunchRequestArguments {
vmAdditionalArgs = (obj['vmAdditionalArgs'] as List?)?.cast<String>(),
enableAsserts = obj['enableAsserts'] as bool?,
debugSdkLibraries = obj['debugSdkLibraries'] as bool?,
debugExternalPackageLibraries =
obj['debugExternalPackageLibraries'] as bool?,
evaluateGettersInDebugViews =
obj['evaluateGettersInDebugViews'] as bool?,
evaluateToStringInDebugViews =
@ -508,6 +730,8 @@ class DartLaunchRequestArguments extends LaunchRequestArguments {
if (vmAdditionalArgs != null) 'vmAdditionalArgs': vmAdditionalArgs,
if (enableAsserts != null) 'enableAsserts': enableAsserts,
if (debugSdkLibraries != null) 'debugSdkLibraries': debugSdkLibraries,
if (debugExternalPackageLibraries != null)
'debugExternalPackageLibraries': debugExternalPackageLibraries,
if (evaluateGettersInDebugViews != null)
'evaluateGettersInDebugViews': evaluateGettersInDebugViews,
if (evaluateToStringInDebugViews != null)

View file

@ -44,7 +44,7 @@ class DartCliDebugAdapter extends DartDebugAdapter<DartLaunchRequestArguments> {
DartCliDebugAdapter(ByteStreamServerChannel channel, [Logger? logger])
: super(channel, logger);
FutureOr<void> debuggerConnected(vm.VM vmInfo) {
Future<void> debuggerConnected(vm.VM vmInfo) async {
if (!isAttach) {
// Capture the PID from the VM Service so that we can terminate it when
// cleaning up. Terminating the process might not be enough as it could be
@ -60,7 +60,7 @@ class DartCliDebugAdapter extends DartDebugAdapter<DartLaunchRequestArguments> {
/// Called by [disconnectRequest] to request that we forcefully shut down the
/// app being run (or in the case of an attach, disconnect).
FutureOr<void> disconnectImpl() {
Future<void> disconnectImpl() async {
// TODO(dantup): In Dart-Code DAP, we first try again with sigint and wait
// for a few seconds before sending sigkill.
pidsToTerminate.forEach(
@ -137,7 +137,7 @@ class DartCliDebugAdapter extends DartDebugAdapter<DartLaunchRequestArguments> {
/// Called by [terminateRequest] to request that we gracefully shut down the
/// app being run (or in the case of an attach, disconnect).
FutureOr<void> terminateImpl() async {
Future<void> terminateImpl() async {
pidsToTerminate.forEach(
(pid) => Process.killPid(pid, ProcessSignal.sigint),
);

View file

@ -11,6 +11,12 @@ import 'protocol_stream.dart';
typedef _FromJsonHandler<T> = T Function(Map<String, Object?>);
typedef _NullableFromJsonHandler<T> = T? Function(Map<String, Object?>?);
typedef _RequestHandler<TArg, TResp> = Future<void> Function(
Request, TArg, void Function(TResp));
typedef _VoidArgRequestHandler<TArg> = Future<void> Function(
Request, TArg, void Function(void));
typedef _VoidNoArgRequestHandler<TArg> = Future<void> Function(
Request, TArg, void Function());
/// A base class for debug adapters.
///
@ -34,22 +40,28 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
/// Dart CLI, Dart tests, Flutter, Flutter tests).
TLaunchArgs Function(Map<String, Object?>) get parseLaunchArgs;
FutureOr<void> attachRequest(
Future<void> attachRequest(
Request request,
TLaunchArgs args,
void Function(void) sendResponse,
void Function() sendResponse,
);
FutureOr<void> configurationDoneRequest(
Future<void> configurationDoneRequest(
Request request,
ConfigurationDoneArguments? args,
void Function(void) sendResponse,
void Function() sendResponse,
);
FutureOr<void> disconnectRequest(
Future<void> continueRequest(
Request request,
ContinueArguments args,
void Function(ContinueResponseBody) sendResponse,
);
Future<void> disconnectRequest(
Request request,
DisconnectArguments? args,
void Function(void) sendResponse,
void Function() sendResponse,
);
/// Calls [handler] for an incoming request, using [fromJson] to parse its
@ -63,9 +75,9 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
/// require a body.
///
/// If [handler] throws, its exception will be sent as an error response.
FutureOr<void> handle<TArg, TResp>(
Future<void> handle<TArg, TResp>(
Request request,
FutureOr<void> Function(Request, TArg, void Function(TResp)) handler,
_RequestHandler<TArg, TResp> handler,
TArg Function(Map<String, Object?>) fromJson,
) async {
final args = request.arguments != null
@ -109,14 +121,23 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
}
}
FutureOr<void> initializeRequest(
Future<void> initializeRequest(
Request request,
InitializeRequestArguments args,
void Function(Capabilities) sendResponse,
);
FutureOr<void> launchRequest(
Request request, TLaunchArgs args, void Function(void) sendResponse);
Future<void> launchRequest(
Request request,
TLaunchArgs args,
void Function() sendResponse,
);
Future<void> nextRequest(
Request request,
NextArguments args,
void Function() sendResponse,
);
/// Sends an event, lookup up the event type based on the runtimeType of
/// [body].
@ -140,10 +161,33 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
_channel.sendRequest(request);
}
FutureOr<void> terminateRequest(
Future<void> setBreakpointsRequest(
Request request,
SetBreakpointsArguments args,
void Function(SetBreakpointsResponseBody) sendResponse);
Future<void> stackTraceRequest(
Request request,
StackTraceArguments args,
void Function(StackTraceResponseBody) sendResponse,
);
Future<void> stepInRequest(
Request request,
StepInArguments args,
void Function() sendResponse,
);
Future<void> stepOutRequest(
Request request,
StepOutArguments args,
void Function() sendResponse,
);
Future<void> terminateRequest(
Request request,
TerminateArguments? args,
void Function(void) sendResponse,
void Function() sendResponse,
);
/// Wraps a fromJson handler for requests that allow null arguments.
@ -169,27 +213,44 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
if (request.command == 'initialize') {
handle(request, initializeRequest, InitializeRequestArguments.fromJson);
} else if (request.command == 'launch') {
handle(request, launchRequest, parseLaunchArgs);
handle(request, _withVoidResponse(launchRequest), parseLaunchArgs);
} else if (request.command == 'attach') {
handle(request, attachRequest, parseLaunchArgs);
handle(request, _withVoidResponse(attachRequest), parseLaunchArgs);
} else if (request.command == 'terminate') {
handle(
request,
terminateRequest,
_withVoidResponse(terminateRequest),
_allowNullArg(TerminateArguments.fromJson),
);
} else if (request.command == 'disconnect') {
handle(
request,
disconnectRequest,
_withVoidResponse(disconnectRequest),
_allowNullArg(DisconnectArguments.fromJson),
);
} else if (request.command == 'configurationDone') {
handle(
request,
configurationDoneRequest,
_withVoidResponse(configurationDoneRequest),
_allowNullArg(ConfigurationDoneArguments.fromJson),
);
} else if (request.command == 'setBreakpoints') {
handle(request, setBreakpointsRequest, SetBreakpointsArguments.fromJson);
} else if (request.command == 'continue') {
handle(request, continueRequest, ContinueArguments.fromJson);
} else if (request.command == 'next') {
handle(request, _withVoidResponse(nextRequest), NextArguments.fromJson);
} else if (request.command == 'stepIn') {
handle(
request,
_withVoidResponse(stepInRequest),
StepInArguments.fromJson,
);
} else if (request.command == 'stepOut') {
handle(request, _withVoidResponse(stepOutRequest),
StepOutArguments.fromJson);
} else if (request.command == 'stackTrace') {
handle(request, stackTraceRequest, StackTraceArguments.fromJson);
} else {
final response = Response(
success: false,
@ -206,4 +267,20 @@ abstract class BaseDebugAdapter<TLaunchArgs extends LaunchRequestArguments> {
// TODO(dantup): Implement this when the server sends requests to the client
// (for example runInTerminalRequest).
}
/// Helper that converts a handler with no response value to one that has
/// passes an unused arg so that `Function()` can be passed to a function
/// accepting `Function<T>(T x)` where `T` happens to be `void`.
///
/// This allows handlers to simple call sendResponse() where they have no
/// return value but need to send a valid response.
_VoidArgRequestHandler<TArg> _withVoidResponse<TArg>(
_VoidNoArgRequestHandler<TArg> handler,
) {
return (request, arg, sendResponse) => handler(
request,
arg,
() => sendResponse(null),
);
}
}

View file

@ -0,0 +1,14 @@
// 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.
/// Exception thrown by a debug adapter when a request is not valid, either
/// because the inputs are not correct or the adapter is not in the correct
/// state.
class DebugAdapterException implements Exception {
final String message;
DebugAdapterException(this.message);
String toString() => 'DebugAdapterException: $message';
}

View file

@ -7,6 +7,7 @@ import 'dart:async';
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).
@ -22,6 +23,40 @@ class IsolateManager {
final Map<int, ThreadInfo> _threadsByThreadId = {};
int _nextThreadNumber = 1;
/// Whether debugging is enabled.
///
/// This must be set before any isolates are spawned and controls whether
/// breakpoints or exception pause modes are sent to the VM.
///
/// 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;
/// 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 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 = {};
/// 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>{};
IsolateManager(this._adapter);
/// A list of all current active isolates.
@ -37,8 +72,10 @@ class IsolateManager {
return res as T;
}
ThreadInfo? getThread(int threadId) => _threadsByThreadId[threadId];
/// Handles Isolate and Debug events
FutureOr<void> handleEvent(vm.Event event) async {
Future<void> handleEvent(vm.Event event) async {
final isolateId = event.isolate?.id;
if (isolateId == null) {
return;
@ -63,11 +100,11 @@ class IsolateManager {
await _isolateRegistrations[isolateId]?.future;
if (eventKind == vm.EventKind.kIsolateExit) {
await _handleExit(event);
_handleExit(event);
} else if (eventKind?.startsWith('Pause') ?? false) {
await _handlePause(event);
} else if (eventKind == vm.EventKind.kResume) {
await _handleResumed(event);
_handleResumed(event);
}
}
@ -77,7 +114,7 @@ class IsolateManager {
/// New isolates will be configured with the correct pause-exception behaviour,
/// libraries will be marked as debuggable if appropriate, and breakpoints
/// sent.
FutureOr<void> registerIsolate(
Future<void> registerIsolate(
vm.IsolateRef isolate,
String eventKind,
) async {
@ -90,7 +127,7 @@ class IsolateManager {
isolate.id!,
() {
// The first time we see an isolate, start tracking it.
final info = ThreadInfo(_nextThreadNumber++, isolate);
final info = ThreadInfo(this, _nextThreadNumber++, isolate);
_threadsByThreadId[info.threadId] = info;
// And notify the client about it.
_adapter.sendEvent(
@ -109,7 +146,7 @@ class IsolateManager {
}
}
FutureOr<void> resumeIsolate(vm.IsolateRef isolateRef,
Future<void> resumeIsolate(vm.IsolateRef isolateRef,
[String? resumeType]) async {
final isolateId = isolateRef.id;
if (isolateId == null) {
@ -135,7 +172,7 @@ class IsolateManager {
Future<void> resumeThread(int threadId, [String? resumeType]) async {
final thread = _threadsByThreadId[threadId];
if (thread == null) {
throw 'Thread $threadId was not found';
throw DebugAdapterException('Thread $threadId was not found');
}
// Check this thread hasn't already been resumed by another handler in the
@ -159,16 +196,58 @@ class IsolateManager {
}
}
/// 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)));
}
/// Sets whether debugging is enabled for this session.
///
/// If not, 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).
void setDebugEnabled(bool debug) {
_debug = debug;
}
/// 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;
/// Configures a new isolate, setting it's exception-pause mode, which
/// libraries are debuggable, and sending all breakpoints.
FutureOr<void> _configureIsolate(vm.IsolateRef isolate) async {
// TODO(dantup): set library debuggable, exception pause mode, breakpoints
Future<void> _configureIsolate(vm.IsolateRef isolate) async {
await Future.wait([
_sendLibraryDebuggables(isolate),
// TODO(dantup): Implement this...
// _sendExceptionPauseMode(isolate),
_sendBreakpoints(isolate),
], eagerError: true);
}
FutureOr<void> _handleExit(vm.Event event) {
void _handleExit(vm.Event event) {
final isolate = event.isolate!;
final isolateId = isolate.id!;
final thread = _threadsByIsolateId[isolateId];
@ -195,7 +274,7 @@ class IsolateManager {
///
/// For all other pause types, the isolate will remain paused and a
/// corresponding "Stopped" event sent to the editor.
FutureOr<void> _handlePause(vm.Event event) async {
Future<void> _handlePause(vm.Event event) async {
final eventKind = event.kind;
final isolate = event.isolate!;
final thread = _threadsByIsolateId[isolate.id!];
@ -211,7 +290,7 @@ class IsolateManager {
// For PausePostRequest we need to re-send all breakpoints; this happens
// after a hot restart.
if (eventKind == vm.EventKind.kPausePostRequest) {
_configureIsolate(isolate);
await _configureIsolate(isolate);
await resumeThread(thread.threadId);
} else if (eventKind == vm.EventKind.kPauseStart) {
await resumeThread(thread.threadId);
@ -238,7 +317,7 @@ class IsolateManager {
}
/// Handles a resume event from the VM, updating our local state.
FutureOr<void> _handleResumed(vm.Event event) {
void _handleResumed(vm.Event event) {
final isolate = event.isolate!;
final thread = _threadsByIsolateId[isolate.id!];
if (thread != null) {
@ -247,10 +326,103 @@ class IsolateManager {
thread.exceptionReference = null;
}
}
bool _isExternalPackageLibrary(vm.LibraryRef library) =>
// TODO(dantup): This needs to check if it's _external_, eg.
//
// - is from the flutter SDK (flutter, flutter_test, ...)
// - is from pub/pubcache
//
// This is intended to match the users idea of "my code". For example
// they may wish to debug the current app being run, as well as any other
// projects that are references with path: dependencies (which are likely
// their own supporting projects).
false /*library.uri?.startsWith('package:') ?? false*/;
bool _isSdkLibrary(vm.LibraryRef library) =>
library.uri?.startsWith('dart:') ?? false;
/// Checks whether a library should be considered debuggable.
///
/// This usesthe settings from the launch arguments (debugSdkLibraries
/// and debugExternalPackageLibraries) against the type of library given.
bool _libaryIsDebuggable(vm.LibraryRef library) {
if (_isSdkLibrary(library)) {
return _adapter.args.debugSdkLibraries ?? false;
} else if (_isExternalPackageLibrary(library)) {
return _adapter.args.debugExternalPackageLibraries ?? false;
} else {
return true;
}
}
/// 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 {
final vmBp = await service.addBreakpointWithScriptUri(
isolateId, uri, bp.line,
column: bp.column);
existingBreakpointsForIsolateAndUri.add(vmBp);
});
}
}
/// 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;
if (isolateId == null) {
return;
}
final isolate = await service.getIsolate(isolateId);
final libraries = isolate.libraries;
if (libraries == null) {
return;
}
await Future.wait(libraries.map((library) async {
final isDebuggable = _libaryIsDebuggable(library);
await service.setLibraryDebuggable(isolateId, library.id!, isDebuggable);
}));
}
}
/// Holds state for a single Isolate/Thread.
class ThreadInfo {
final IsolateManager _manager;
final vm.IsolateRef isolate;
final int threadId;
var runnable = false;
@ -261,9 +433,37 @@ class ThreadInfo {
// 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.threadId, this.isolate);
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);
}

View file

@ -0,0 +1,152 @@
// 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:io';
import 'package:path/path.dart' as path;
import 'package:vm_service/vm_service.dart' as vm;
import 'adapters/dart.dart';
import 'isolate_manager.dart';
import 'protocol_generated.dart' as dap;
/// A helper that handlers converting to/from DAP and VM Service types and to
/// user-friendly display strings.
///
/// This class may call back to the VM Service to fetch additional information
/// when converting classes - for example when converting a stack frame it may
/// fetch scripts from the VM Service in order to map token positions back to
/// line/columns as required by DAP.
class ProtocolConverter {
/// The parent debug adapter, used to access arguments and the VM Service for
/// the debug session.
final DartDebugAdapter _adapter;
ProtocolConverter(this._adapter);
/// Converts an absolute path to one relative to the cwd used to launch the
/// application.
///
/// If [sourcePath] is outside of the cwd used for launching the application
/// then the full absolute path will be returned.
String convertToRelativePath(String sourcePath) {
final cwd = _adapter.args.cwd;
if (cwd == null) {
return sourcePath;
}
final rel = path.relative(sourcePath, from: cwd);
return !rel.startsWith('..') ? rel : sourcePath;
}
/// Converts a VM Service stack frame to a DAP stack frame.
Future<dap.StackFrame> convertVmToDapStackFrame(
ThreadInfo thread,
vm.Frame frame, {
required bool isTopFrame,
int? firstAsyncMarkerIndex,
}) async {
final frameId = thread.storeData(frame);
if (frame.kind == vm.FrameKind.kAsyncSuspensionMarker) {
return dap.StackFrame(
id: frameId,
name: '<asynchronous gap>',
presentationHint: 'label',
line: 0,
column: 0,
);
}
// The VM may supply frames with a prefix that we don't want to include in
// the frame for the user.
const unoptimizedPrefix = '[Unoptimized] ';
final codeName = frame.code?.name;
final frameName = codeName != null
? (codeName.startsWith(unoptimizedPrefix)
? codeName.substring(unoptimizedPrefix.length)
: codeName)
: '<unknown>';
// If there's no location, this isn't source a user can debug so use a
// subtle hint (which the editor may use to render the frame faded).
final location = frame.location;
if (location == null) {
return dap.StackFrame(
id: frameId,
name: frameName,
presentationHint: 'subtle',
line: 0,
column: 0,
);
}
final scriptRef = location.script;
final tokenPos = location.tokenPos;
final uri = scriptRef?.uri;
final sourcePath = uri != null ? await convertVmUriToSourcePath(uri) : null;
var canShowSource = sourcePath != null && File(sourcePath).existsSync();
// Download the source if from a "dart:" uri.
int? sourceReference;
if (uri != null &&
(uri.startsWith('dart:') || uri.startsWith('org-dartlang-app:')) &&
scriptRef != null) {
sourceReference = thread.storeData(scriptRef);
canShowSource = true;
}
var line = 0, col = 0;
if (scriptRef != null && tokenPos != null) {
try {
final script = await thread.getScript(scriptRef);
line = script.getLineNumberFromTokenPos(tokenPos) ?? 0;
col = script.getColumnNumberFromTokenPos(tokenPos) ?? 0;
} catch (e) {
_adapter.logger?.call('Failed to map frame location to line/col: $e');
}
}
final source = canShowSource
? dap.Source(
name: sourcePath != null ? convertToRelativePath(sourcePath) : uri,
path: sourcePath,
sourceReference: sourceReference,
origin: null,
adapterData: location.script)
: null;
// The VM only allows us to restart from frames that are not the top frame,
// but since we're also showing asyncCausalFrames any indexes past the first
// async boundary will not line up so we cap it there.
final canRestart = !isTopFrame &&
(firstAsyncMarkerIndex == null || frame.index! < firstAsyncMarkerIndex);
return dap.StackFrame(
id: frameId,
name: frameName,
source: source,
line: line,
column: col,
canRestart: canRestart,
);
}
/// Converts the source path from the VM to a file path.
///
/// 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<String?> convertVmUriToSourcePath(String uri) async {
if (uri.startsWith('file://')) {
return Uri.parse(uri).toFilePath();
} else if (uri.startsWith('package:')) {
// TODO(dantup): Handle mapping package: uris ?
return null;
} else {
return null;
}
}
}

View file

@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:convert';
import 'exceptions.dart';
import 'logging.dart';
import 'protocol_generated.dart';
import 'protocol_stream_transformers.dart';
@ -113,7 +114,7 @@ class ByteStreamServerChannel {
void _sendParseError(String data) {
// TODO(dantup): Review LSP implementation of this when consolidating classes.
throw 'Message does not confirm to DAP spec: $data';
throw DebugAdapterException('Message does not confirm to DAP spec: $data');
}
/// Send [bytes] to [_output].

View file

@ -29,15 +29,16 @@ class DapServer {
String get host => _socket.address.host;
int get port => _socket.port;
FutureOr<void> stop() async {
Future<void> stop() async {
_channels.forEach((client) => client.close());
await _socket.close();
}
void _acceptConnection(Socket client) {
_logger?.call('Accepted connection from ${client.remoteAddress}');
final address = client.remoteAddress;
_logger?.call('Accepted connection from $address');
client.done.then((_) {
_logger?.call('Connection from ${client.remoteAddress} closed');
_logger?.call('Connection from $address closed');
});
_createAdapter(client.transform(Uint8ListTransformer()), client, _logger);
}
@ -48,7 +49,7 @@ class DapServer {
// ultimately need to support having a factory passed in to support
// tests and/or being used in flutter_tools.
final channel = ByteStreamServerChannel(_input, _output, logger);
final adapter = DartCliDebugAdapter(channel);
final adapter = DartCliDebugAdapter(channel, logger);
_channels.add(channel);
_adapters.add(adapter);
unawaited(channel.closed.then((_) {

View file

@ -0,0 +1,218 @@
// 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 'package:test/test.dart';
import 'test_client.dart';
import 'test_support.dart';
main() {
testDap((dap) async {
group('debug mode breakpoints', () {
test('stops at a line breakpoint', () async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
print('Hello!'); // BREAKPOINT
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
await client.hitBreakpoint(testFile, breakpointLine);
});
test('stops at a line breakpoint and can be resumed', () async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
print('Hello!'); // BREAKPOINT
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
// Hit the initial breakpoint.
final stop = await client.hitBreakpoint(testFile, breakpointLine);
// Resume and expect termination (as the script will get to the end).
await Future.wait([
client.event('terminated'),
client.continue_(stop.threadId!),
], eagerError: true);
});
test('stops at a line breakpoint and can step over (next)', () async {
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
print('Hello!'); // BREAKPOINT
print('Hello!'); // STEP
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
final stepLine = lineWith(testFile, '// STEP');
// Hit the initial breakpoint.
final stop = await dap.client.hitBreakpoint(testFile, breakpointLine);
// Step and expect stopping on the next line with a 'step' stop type.
await Future.wait([
dap.client.expectStop('step', file: testFile, line: stepLine),
dap.client.next(stop.threadId!),
], eagerError: true);
});
test(
'stops at a line breakpoint and can step over (next) an async boundary',
() async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
Future<void> main(List<String> args) async {
await asyncPrint('Hello!'); // BREAKPOINT
await asyncPrint('Hello!'); // STEP
}
Future<void> asyncPrint(String message) async {
await Future.delayed(const Duration(milliseconds: 1));
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
final stepLine = lineWith(testFile, '// STEP');
// Hit the initial breakpoint.
final stop = await dap.client.hitBreakpoint(testFile, breakpointLine);
// The first step will move from `asyncPrint` to the `await`.
await Future.wait([
client.expectStop('step', file: testFile, line: breakpointLine),
client.next(stop.threadId!),
], eagerError: true);
// The next step should go over the async boundary and to stepLine (if
// we did not correctly send kOverAsyncSuspension we would end up in
// the asyncPrint method).
await Future.wait([
client.expectStop('step', file: testFile, line: stepLine),
client.next(stop.threadId!),
], eagerError: true);
});
test('stops at a line breakpoint and can step in', () async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
log('Hello!'); // BREAKPOINT
}
void log(String message) { // STEP
print(message);
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
final stepLine = lineWith(testFile, '// STEP');
// Hit the initial breakpoint.
final stop = await client.hitBreakpoint(testFile, breakpointLine);
// Step and expect stopping in the inner function with a 'step' stop type.
await Future.wait([
client.expectStop('step', file: testFile, line: stepLine),
client.stepIn(stop.threadId!),
], eagerError: true);
});
test('stops at a line breakpoint and can step out', () async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
log('Hello!');
log('Hello!'); // STEP
}
void log(String message) {
print(message); // BREAKPOINT
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
final stepLine = lineWith(testFile, '// STEP');
// Hit the initial breakpoint.
final stop = await client.hitBreakpoint(testFile, breakpointLine);
// Step and expect stopping in the inner function with a 'step' stop type.
await Future.wait([
client.expectStop('step', file: testFile, line: stepLine),
client.stepOut(stop.threadId!),
], eagerError: true);
});
test('does not step into SDK code with debugSdkLibraries=false',
() async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
print('Hello!'); // BREAKPOINT
print('Hello!'); // STEP
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
final stepLine = lineWith(testFile, '// STEP');
// Hit the initial breakpoint.
final stop = await client.hitBreakpoint(testFile, breakpointLine);
// Step in and expect stopping on the next line (don't go into print).
await Future.wait([
client.expectStop('step', file: testFile, line: stepLine),
client.stepIn(stop.threadId!),
], eagerError: true);
});
test('steps into SDK code with debugSdkLibraries=true', () async {
final client = dap.client;
final testFile = dap.createTestFile(r'''
void main(List<String> args) async {
print('Hello!'); // BREAKPOINT
print('Hello!');
}
''');
final breakpointLine = lineWith(testFile, '// BREAKPOINT');
// Hit the initial breakpoint.
final stop = await dap.client.hitBreakpoint(
testFile,
breakpointLine,
launch: () => client.launch(
testFile.path,
debugSdkLibraries: true,
),
);
// Step in and expect to go into print.
await Future.wait([
client.expectStop('step', sourceName: 'dart:core/print.dart'),
client.stepIn(stop.threadId!),
], eagerError: true);
});
test(
'does not step into external package code with debugExternalPackageLibraries=false',
() {
// TODO(dantup): Support for debugExternalPackageLibraries
}, skip: true);
test(
'steps into external package code with debugExternalPackageLibraries=true',
() {
// TODO(dantup): Support for debugExternalPackageLibraries
}, skip: true);
test('allows changing debug settings during session', () {
// TODO(dantup): !
// Dart-Code's DAP has a custom method that allows an editor to change
// the debug settings (debugSdkLibraries/debugExternalPackageLibraries)
// during a debug session.
}, skip: true);
// These tests can be slow due to starting up the external server process.
}, timeout: Timeout.none);
});
}

View file

@ -10,11 +10,15 @@ import 'package:dds/src/dap/logging.dart';
import 'package:dds/src/dap/protocol_generated.dart';
import 'package:dds/src/dap/protocol_stream.dart';
import 'package:dds/src/dap/protocol_stream_transformers.dart';
import 'package:test/test.dart';
import 'test_server.dart';
/// A helper class to simplify acting as a client for interacting with the
/// [DapTestServer] in tests.
///
/// Methods on this class should map directly to protocol methods. Additional
/// helpers are available in [DapTestClientExtension].
class DapTestClient {
final Socket _socket;
final ByteStreamServerChannel _channel;
@ -66,6 +70,13 @@ class DapTestClient {
return outputEventsFuture;
}
/// Sends a continue request for the given thread.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> continue_(int threadId) =>
sendRequest(ContinueArguments(threadId: threadId));
Future<Response> disconnect() => sendRequest(DisconnectArguments());
/// Returns a Future that completes with the next [event] event.
@ -103,6 +114,7 @@ class DapTestClient {
String? cwd,
bool? noDebug,
bool? debugSdkLibraries,
bool? debugExternalPackageLibraries,
bool? evaluateGettersInDebugViews,
bool? evaluateToStringInDebugViews,
}) {
@ -113,6 +125,7 @@ class DapTestClient {
cwd: cwd,
args: args,
debugSdkLibraries: debugSdkLibraries,
debugExternalPackageLibraries: debugExternalPackageLibraries,
evaluateGettersInDebugViews: evaluateGettersInDebugViews,
evaluateToStringInDebugViews: evaluateToStringInDebugViews,
// When running out of process, VM Service traffic won't be available
@ -126,6 +139,13 @@ class DapTestClient {
);
}
/// Sends a next (step over) request for the given thread.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> next(int threadId) =>
sendRequest(NextArguments(threadId: threadId));
/// Sends an arbitrary request to the server.
///
/// Returns a Future that completes when the server returns a corresponding
@ -142,7 +162,34 @@ class DapTestClient {
return _logIfSlow('Request "$command"', completer.future);
}
FutureOr<void> stop() async {
/// Sends a stackTrace request to the server to request the call stack for a
/// given thread.
///
/// If [startFrame] and/or [numFrames] are supplied, only a slice of the
/// frames will be returned.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> stackTrace(int threadId,
{int? startFrame, int? numFrames}) =>
sendRequest(StackTraceArguments(
threadId: threadId, startFrame: startFrame, levels: numFrames));
/// Sends a stepIn request for the given thread.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> stepIn(int threadId) =>
sendRequest(StepInArguments(threadId: threadId));
/// Sends a stepOut request for the given thread.
///
/// Returns a Future that completes when the server returns a corresponding
/// response.
Future<Response> stepOut(int threadId) =>
sendRequest(StepOutArguments(threadId: threadId));
Future<void> stop() async {
_channel.close();
await _socket.close();
await _subscription.cancel();
@ -194,7 +241,7 @@ class DapTestClient {
/// Creates a [DapTestClient] that connects the server listening on
/// [host]:[port].
static FutureOr<DapTestClient> connect(
static Future<DapTestClient> connect(
int port, {
String host = 'localhost',
bool captureVmServiceTraffic = false,
@ -216,3 +263,71 @@ class _OutgoingRequest {
_OutgoingRequest(this.completer, this.name, this.allowFailure);
}
/// Additional helper method for tests to simplify interaction with [DapTestClient].
///
/// Unlike the methods on [DapTestClient] these methods might not map directly
/// onto protocol methods. They may call multiple protocol methods and/or
/// simplify assertion specific conditions/results.
extension DapTestClientExtension on DapTestClient {
/// Sets a breakpoint at [line] in [file] and expects to hit it after running
/// the script.
///
/// Launch options can be customised by passing a custom [launch] function that
/// will be used instead of calling `launch(file.path)`.
Future<StoppedEventBody> hitBreakpoint(File file, int line,
{Future<Response> Function()? launch}) async {
final stop = expectStop('breakpoint', file: file, line: line);
await Future.wait([
initialize(),
sendRequest(
SetBreakpointsArguments(
source: Source(path: file.path),
breakpoints: [SourceBreakpoint(line: line)]),
),
launch?.call() ?? this.launch(file.path),
], eagerError: true);
return stop;
}
/// Expects a 'stopped' event for [reason].
///
/// If [file] or [line] are provided, they will be checked against the stop
/// location for the top stack frame.
Future<StoppedEventBody> expectStop(String reason,
{File? file, int? line, String? sourceName}) async {
final e = await event('stopped');
final stop = StoppedEventBody.fromJson(e.body as Map<String, Object?>);
expect(stop.reason, equals(reason));
final result =
await getValidStack(stop.threadId!, startFrame: 0, numFrames: 1);
expect(result.stackFrames, hasLength(1));
final frame = result.stackFrames[0];
if (file != null) {
expect(frame.source!.path, equals(file.path));
}
if (sourceName != null) {
expect(frame.source!.name, equals(sourceName));
}
if (line != null) {
expect(frame.line, equals(line));
}
return stop;
}
/// Fetches a stack trace and asserts it was a valid response.
Future<StackTraceResponseBody> getValidStack(int threadId,
{required int startFrame, required int numFrames}) async {
final response = await stackTrace(threadId,
startFrame: startFrame, numFrames: numFrames);
expect(response.success, isTrue);
expect(response.command, equals('stackTrace'));
return StackTraceResponseBody.fromJson(
response.body as Map<String, Object?>);
}
}

View file

@ -7,6 +7,7 @@ import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:dds/src/dap/logging.dart';
import 'package:dds/src/dap/server.dart';
import 'package:path/path.dart' as path;
import 'package:pedantic/pedantic.dart';
@ -14,7 +15,7 @@ import 'package:pedantic/pedantic.dart';
abstract class DapTestServer {
String get host;
int get port;
FutureOr<void> stop();
Future<void> stop();
List<String> get errorLogs;
}
@ -33,12 +34,12 @@ class InProcessDapTestServer extends DapTestServer {
List<String> get errorLogs => const []; // In-proc errors just throw in-line.
@override
FutureOr<void> stop() async {
Future<void> stop() async {
await _server.stop();
}
static Future<InProcessDapTestServer> create() async {
final DapServer server = await DapServer.create();
static Future<InProcessDapTestServer> create({Logger? logger}) async {
final DapServer server = await DapServer.create(logger: logger);
return InProcessDapTestServer._(server);
}
}
@ -81,7 +82,7 @@ class OutOfProcessDapTestServer extends DapTestServer {
}
@override
FutureOr<void> stop() async {
Future<void> stop() async {
_isShuttingDown = true;
await _process.kill();
await _process.exitCode;

View file

@ -26,10 +26,14 @@ void expectLines(String actual, List<String> expected) {
expect(actual.replaceAll('\r\n', '\n'), equals(expected.join('\n')));
}
/// Returns the 1-base line in [file] that contains [searchText].
int lineWith(File file, String searchText) =>
file.readAsLinesSync().indexWhere((line) => line.contains(searchText)) + 1;
/// A helper function to wrap all tests in a library with setup/teardown functions
/// to start a shared server for all tests in the library and an individual
/// client for each test.
testDap(FutureOr<void> Function(DapTestSession session) tests) {
testDap(Future<void> Function(DapTestSession session) tests) {
final session = DapTestSession();
setUpAll(session.setUpAll);
@ -59,17 +63,17 @@ class DapTestSession {
return testFile;
}
FutureOr<void> setUp() async {
Future<void> setUp() async {
client = await _startClient(server);
}
FutureOr<void> setUpAll() async {
Future<void> setUpAll() async {
server = await _startServer();
}
FutureOr<void> tearDown() => client.stop();
Future<void> tearDown() => client.stop();
FutureOr<void> tearDownAll() async {
Future<void> tearDownAll() async {
await server.stop();
// Clean up any temp folders created during the test runs.
@ -77,7 +81,7 @@ class DapTestSession {
}
/// Creates and connects a new [DapTestClient] to [server].
FutureOr<DapTestClient> _startClient(DapTestServer server) async {
Future<DapTestClient> _startClient(DapTestServer server) async {
// Since we don't get a signal from the DAP server when it's ready and we
// just started it, add a short retry to connections.
// Since the bots can be quite slow, it may take 6-7 seconds for the server
@ -107,7 +111,7 @@ class DapTestSession {
}
/// Starts a DAP server that can be shared across tests.
FutureOr<DapTestServer> _startServer() async {
Future<DapTestServer> _startServer() async {
return useInProcessDap
? await InProcessDapTestServer.create()
: await OutOfProcessDapTestServer.create();