blob: 9b6e9267242fcab938f7dd6cba51f7908a2d8636 [file] [log] [blame]
// Copyright (c) 2019, 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:async/async.dart';
import 'package:collection/collection.dart';
import 'package:dwds/src/connections/app_connection.dart';
import 'package:dwds/src/debugging/classes.dart';
import 'package:dwds/src/debugging/debugger.dart';
import 'package:dwds/src/debugging/execution_context.dart';
import 'package:dwds/src/debugging/instance.dart';
import 'package:dwds/src/debugging/libraries.dart';
import 'package:dwds/src/debugging/location.dart';
import 'package:dwds/src/debugging/remote_debugger.dart';
import 'package:dwds/src/loaders/strategy.dart';
import 'package:dwds/src/readers/asset_reader.dart';
import 'package:dwds/src/utilities/conversions.dart';
import 'package:dwds/src/utilities/dart_uri.dart';
import 'package:dwds/src/utilities/domain.dart';
import 'package:dwds/src/utilities/server.dart';
import 'package:dwds/src/utilities/shared.dart';
import 'package:logging/logging.dart';
import 'package:vm_service/vm_service.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
/// An inspector for a running Dart application contained in the
/// [WipConnection].
///
/// Provides information about currently loaded scripts and objects and support
/// for eval.
class AppInspector implements AppInspectorInterface {
final _scriptCacheMemoizer = AsyncMemoizer<List<ScriptRef>>();
Future<List<ScriptRef>> get scriptRefs => _populateScriptCaches();
final _logger = Logger('AppInspector');
/// Map of scriptRef ID to [ScriptRef].
final _scriptRefsById = <String, ScriptRef>{};
/// Map of Dart server path to [ScriptRef].
final _serverPathToScriptRef = <String, ScriptRef>{};
/// Map of [ScriptRef] id to containing [LibraryRef] id.
final _scriptIdToLibraryId = <String, String>{};
/// Map of [Library] id to included [ScriptRef]s.
final _libraryIdToScriptRefs = <String, List<ScriptRef>>{};
@override
RemoteDebugger get remoteDebugger => _remoteDebugger;
final RemoteDebugger _remoteDebugger;
@override
Isolate get isolate => _isolate;
final Isolate _isolate;
@override
IsolateRef get isolateRef => _isolateRef;
final IsolateRef _isolateRef;
@override
AppConnection get appConnection => _appConnection;
final AppConnection _appConnection;
final ExecutionContext _executionContext;
late final LibraryHelper _libraryHelper;
late final ClassHelper _classHelper;
late final InstanceHelper _instanceHelper;
final AssetReader _assetReader;
final Locations _locations;
/// The root URI from which the application is served.
final String _root;
/// JavaScript expression that evaluates to the Dart stack trace mapper.
static const stackTraceMapperExpression = '\$dartStackTraceUtility.mapper';
/// Regex used to extract the message from an exception description.
static final exceptionMessageRegex = RegExp(r'^.*$', multiLine: true);
/// Flutter widget inspector library.
Future<LibraryRef?> get flutterWidgetInspectorLibrary => _libraryHelper
.libraryRefFor('package:flutter/src/widgets/widget_inspector.dart');
/// Regex used to extract a stack trace line from the exception description.
static final stackTraceLineRegex = RegExp(r'^\s*at\s.*$', multiLine: true);
AppInspector._(
this._appConnection,
this._isolate,
this._remoteDebugger,
this._assetReader,
this._locations,
this._root,
this._executionContext,
) : _isolateRef = _toIsolateRef(_isolate);
Future<void> initialize(
LibraryHelper libraryHelper,
ClassHelper classHelper,
InstanceHelper instanceHelper,
) async {
_libraryHelper = libraryHelper;
_classHelper = classHelper;
_instanceHelper = instanceHelper;
final libraries = await _libraryHelper.libraryRefs;
isolate.rootLib = await _libraryHelper.rootLib;
isolate.libraries?.addAll(libraries);
final scripts = await scriptRefs;
await DartUri.initialize();
DartUri.recordAbsoluteUris(libraries.map((lib) => lib.uri).whereNotNull());
DartUri.recordAbsoluteUris(
scripts.map((script) => script.uri).whereNotNull());
isolate.extensionRPCs?.addAll(await _getExtensionRpcs());
}
static IsolateRef _toIsolateRef(Isolate isolate) => IsolateRef(
id: isolate.id,
name: isolate.name,
number: isolate.number,
isSystemIsolate: isolate.isSystemIsolate,
);
static Future<AppInspector> create(
AppConnection appConnection,
RemoteDebugger remoteDebugger,
AssetReader assetReader,
Locations locations,
String root,
Debugger debugger,
ExecutionContext executionContext,
) async {
final id = createId();
final time = DateTime.now().millisecondsSinceEpoch;
final name = 'main()';
final isolate = Isolate(
id: id,
number: id,
name: name,
startTime: time,
runnable: true,
pauseOnExit: false,
pauseEvent: Event(
kind: EventKind.kPauseStart,
timestamp: time,
isolate: IsolateRef(
id: id,
name: name,
number: id,
isSystemIsolate: false,
)),
livePorts: 0,
libraries: [],
breakpoints: [],
exceptionPauseMode: debugger.pauseState,
isSystemIsolate: false,
isolateFlags: [])
..extensionRPCs = [];
final inspector = AppInspector._(
appConnection,
isolate,
remoteDebugger,
assetReader,
locations,
root,
executionContext,
);
debugger.updateInspector(inspector);
final libraryHelper = LibraryHelper(inspector);
final classHelper = ClassHelper(inspector);
final instanceHelper = InstanceHelper(inspector, debugger);
await inspector.initialize(
libraryHelper,
classHelper,
instanceHelper,
);
return inspector;
}
/// Returns the ID for the execution context or null if not found.
@override
Future<int?> get contextId async {
try {
return await _executionContext.id;
} catch (e, s) {
_logger.severe('Missing execution context ID: ', e, s);
return null;
}
}
/// Get the value of the field named [fieldName] from [receiver].
@override
Future<RemoteObject> loadField(RemoteObject receiver, String fieldName) {
final load = '''
function() {
return ${globalLoadStrategy.loadModuleSnippet}("dart_sdk").dart.dloadRepl(this, "$fieldName");
}
''';
return jsCallFunctionOn(receiver, load, []);
}
/// Call a method by name on [receiver], with arguments [positionalArgs] and
/// [namedArgs].
Future<RemoteObject> _invokeMethod(RemoteObject receiver, String methodName,
[List<RemoteObject> positionalArgs = const [],
Map namedArgs = const {}]) async {
// TODO(alanknight): Support named arguments.
if (namedArgs.isNotEmpty) {
throw UnsupportedError('Named arguments are not yet supported');
}
// We use the JS pseudo-variable 'arguments' to get the list of all arguments.
final send = '''
function () {
if (!(this.__proto__)) { return 'Instance of PlainJavaScriptObject';}
return ${globalLoadStrategy.loadModuleSnippet}("dart_sdk").dart.dsendRepl(this, "$methodName", arguments);
}
''';
final remote = await jsCallFunctionOn(receiver, send, positionalArgs);
return remote;
}
/// Calls Chrome's Runtime.callFunctionOn method.
///
/// [evalExpression] should be a JS function definition that can accept
/// [arguments].
@override
Future<RemoteObject> jsCallFunctionOn(RemoteObject receiver,
String evalExpression, List<RemoteObject> arguments,
{bool returnByValue = false}) async {
final jsArguments = arguments.map(callArgumentFor).toList();
final response =
await remoteDebugger.sendCommand('Runtime.callFunctionOn', params: {
'functionDeclaration': evalExpression,
'arguments': jsArguments,
'objectId': receiver.objectId,
'returnByValue': returnByValue,
});
final result =
getResultOrHandleError(response, evalContents: evalExpression);
return RemoteObject(result);
}
/// Calls Chrome's Runtime.callFunctionOn method with a global function.
///
/// [evalExpression] should be a JS function definition that can accept
/// [arguments].
Future<RemoteObject> _jsCallFunction(
String evalExpression, List<Object> arguments,
{bool returnByValue = false}) async {
final jsArguments = arguments.map(callArgumentFor).toList();
final response =
await remoteDebugger.sendCommand('Runtime.callFunctionOn', params: {
'functionDeclaration': evalExpression,
'arguments': jsArguments,
'executionContextId': await contextId,
'returnByValue': returnByValue,
});
final result =
getResultOrHandleError(response, evalContents: evalExpression);
return RemoteObject(result);
}
/// Invoke the function named [selector] on the object identified by
/// [targetId].
///
/// The [targetId] can be the URL of a Dart library, in which case this means
/// invoking a top-level function. The [arguments] are always strings that are
/// Dart object Ids (which can also be Chrome RemoteObject objectIds that are
/// for non-Dart JS objects.)
@override
Future<RemoteObject> invoke(
String targetId, String selector, List<dynamic> arguments) async {
final remoteArguments =
arguments.cast<String>().map(remoteObjectFor).toList();
// We special case the Dart library, where invokeMethod won't work because
// it's not really a Dart object.
if (isLibraryId(targetId)) {
final library = await getObject(targetId) as Library;
return await _invokeLibraryFunction(library, selector, remoteArguments);
} else {
return _invokeMethod(
remoteObjectFor(targetId), selector, remoteArguments);
}
}
/// Invoke the function named [selector] from [library] with [arguments].
Future<RemoteObject> _invokeLibraryFunction(
Library library, String selector, List<RemoteObject> arguments) {
return _evaluateInLibrary(
library,
'function () { return this.$selector.apply(this, arguments);}',
arguments);
}
/// Evaluate [expression] by calling Chrome's Runtime.evaluate.
@override
Future<RemoteObject> jsEvaluate(String expression,
{bool returnByValue = false, bool awaitPromise = false}) async {
// TODO(alanknight): Support a version with arguments if needed.
final response =
await remoteDebugger.sendCommand('Runtime.evaluate', params: {
'expression': expression,
'returnByValue': returnByValue,
'awaitPromise': awaitPromise,
'contextId': await contextId,
});
final result = getResultOrHandleError(response, evalContents: expression);
return RemoteObject(result);
}
/// Evaluate the JS function with source [jsFunction] in the context of
/// [library] with [arguments].
Future<RemoteObject> _evaluateInLibrary(
Library library, String jsFunction, List<RemoteObject> arguments) async {
final libraryUri = library.uri;
if (libraryUri == null) {
throwInvalidParam('invoke', 'library uri is null');
}
final findLibrary = '''
(function() {
${globalLoadStrategy.loadLibrarySnippet(libraryUri)};
return library;
})();
''';
final remoteLibrary = await jsEvaluate(findLibrary);
return jsCallFunctionOn(remoteLibrary, jsFunction, arguments);
}
/// Call [function] with objects referred by [argumentIds] as arguments.
@override
Future<RemoteObject> callFunction(
String function, Iterable<String> argumentIds) async {
final arguments = argumentIds.map(remoteObjectFor).toList();
return _jsCallFunction(function, arguments);
}
@override
Future<InstanceRef?> instanceRefFor(Object value) =>
_instanceHelper.instanceRefFor(value);
Future<Instance?> instanceFor(RemoteObject value) =>
_instanceHelper.instanceFor(value);
@override
Future<LibraryRef?> libraryRefFor(String objectId) =>
_libraryHelper.libraryRefFor(objectId);
@override
Future<Library?> getLibrary(String objectId) async {
final libraryRef = await libraryRefFor(objectId);
if (libraryRef == null) return null;
return _libraryHelper.libraryFor(libraryRef);
}
@override
Future<Obj> getObject(String objectId, {int? offset, int? count}) async {
try {
final library = await getLibrary(objectId);
if (library != null) {
return library;
}
final clazz = await _classHelper.forObjectId(objectId);
if (clazz != null) {
return clazz;
}
final scriptRef = _scriptRefsById[objectId];
if (scriptRef != null) {
return _getScript(scriptRef);
}
final instance = await _instanceHelper
.instanceFor(remoteObjectFor(objectId), offset: offset, count: count);
if (instance != null) {
return instance;
}
} catch (e, s) {
_logger.fine('getObject $objectId failed', e, s);
rethrow;
}
throw UnsupportedError('Only libraries, instances, classes, and scripts '
'are supported for getObject');
}
Future<Script> _getScript(ScriptRef scriptRef) async {
final scriptId = scriptRef.id;
final scriptUri = scriptRef.uri;
if (scriptId == null || scriptUri == null) {
throwInvalidParam('getObject', 'No script info for script $scriptRef');
}
final serverPath = DartUri(scriptUri, _root).serverPath;
final source = await _assetReader.dartSourceContents(serverPath);
if (source == null) {
throwInvalidParam('getObject',
'No source for $scriptRef with serverPath: $serverPath');
}
final libraryId = _scriptIdToLibraryId[scriptId];
if (libraryId == null) {
throwInvalidParam('getObject', 'No library for script $scriptRef');
}
return Script(
uri: scriptRef.uri,
library: await libraryRefFor(libraryId),
id: scriptId)
..tokenPosTable = await _locations.tokenPosTableFor(serverPath)
..source = source;
}
@override
Future<MemoryUsage> getMemoryUsage() async {
final response = await remoteDebugger.sendCommand('Runtime.getHeapUsage');
final result = response.result;
if (result == null) {
throw RPCError('getMemoryUsage', RPCError.kInternalError,
'Null result from chrome Devtools.');
}
final jsUsage = HeapUsage(result);
final usage = MemoryUsage.parse({
'heapUsage': jsUsage.usedSize,
'heapCapacity': jsUsage.totalSize,
'externalUsage': 0,
});
if (usage == null) {
throw RPCError('getMemoryUsage', RPCError.kInternalError,
'Failed to parse memory usage result.');
}
return usage;
}
/// Returns the [ScriptRef] for the provided Dart server path [uri].
@override
Future<ScriptRef?> scriptRefFor(String uri) async {
await _populateScriptCaches();
return _serverPathToScriptRef[uri];
}
/// Returns the [ScriptRef]s in the library with [libraryId].
@override
Future<List<ScriptRef>> scriptRefsForLibrary(String libraryId) async {
await _populateScriptCaches();
return _libraryIdToScriptRefs[libraryId] ?? [];
}
/// Return the VM SourceReport for the given parameters.
///
/// Currently this implements the 'PossibleBreakpoints' report kind.
@override
Future<SourceReport> getSourceReport(
List<String> reports, {
String? scriptId,
int? tokenPos,
int? endTokenPos,
bool? forceCompile,
bool? reportLines,
List<String>? libraryFilters,
}) {
if (reports.contains(SourceReportKind.kCoverage)) {
throwInvalidParam('getSourceReport',
'Source report kind ${SourceReportKind.kCoverage} not supported');
}
if (reports.isEmpty) {
throwInvalidParam('getSourceReport',
'Invalid parameter: no value for source report kind provided.');
}
if (reports.length > 1 ||
reports.first != SourceReportKind.kPossibleBreakpoints) {
throwInvalidParam('getSourceReport', 'Unsupported source report kind.');
}
return _getPossibleBreakpoints(scriptId);
}
Future<SourceReport> _getPossibleBreakpoints(String? scriptId) async {
// TODO(devoncarew): Consider adding some caching for this method.
final scriptRef = scriptWithId(scriptId);
if (scriptRef == null) {
throwInvalidParam('getSourceReport', 'scriptRef not found for $scriptId');
}
final scriptUri = scriptRef.uri;
if (scriptUri == null) {
throwInvalidParam('getSourceReport', 'scriptUri not found for $scriptId');
}
final dartUri = DartUri(scriptUri, _root);
final mappedLocations =
await _locations.locationsForDart(dartUri.serverPath);
// Unlike the Dart VM, the token positions match exactly to the possible
// breakpoints. This is because the token positions are derived from the
// DDC source maps which Chrome also uses.
final tokenPositions = <int>[
for (var location in mappedLocations) location.tokenPos
];
tokenPositions.sort();
final range = SourceReportRange(
scriptIndex: 0,
startPos: tokenPositions.isEmpty ? -1 : tokenPositions.first,
endPos: tokenPositions.isEmpty ? -1 : tokenPositions.last,
compiled: true,
possibleBreakpoints: tokenPositions,
);
final ranges = [range];
return SourceReport(scripts: [scriptRef], ranges: ranges);
}
/// All the scripts in the isolate.
@override
Future<ScriptList> getScripts() async {
return ScriptList(scripts: await scriptRefs);
}
/// Request and cache <ScriptRef>s for all the scripts in the application.
///
/// This populates [_scriptRefsById], [_scriptIdToLibraryId],
/// [_libraryIdToScriptRefs] and [_serverPathToScriptRef].
///
/// It is a one-time operation, because if we do a
/// reload the inspector will get re-created.
///
/// Returns the list of scripts refs cached.
Future<List<ScriptRef>> _populateScriptCaches() async {
return _scriptCacheMemoizer.runOnce(() async {
final libraryUris = [
for (var library in isolate.libraries ?? []) library.uri
];
final scripts = await globalLoadStrategy
.metadataProviderFor(appConnection.request.entrypointPath)
.scripts;
// For all the non-dart: libraries, find their parts and create scriptRefs
// for them.
final userLibraries =
libraryUris.where((uri) => !uri.startsWith('dart:'));
for (var uri in userLibraries) {
final parts = scripts[uri];
final scriptRefs = [
ScriptRef(uri: uri, id: createId()),
for (var part in parts ?? []) ScriptRef(uri: part, id: createId())
];
final libraryRef = await _libraryHelper.libraryRefFor(uri);
final libraryId = libraryRef?.id;
if (libraryId != null) {
final libraryIdToScriptRefs = _libraryIdToScriptRefs.putIfAbsent(
libraryId, () => <ScriptRef>[]);
for (var scriptRef in scriptRefs) {
final scriptId = scriptRef.id;
final scriptUri = scriptRef.uri;
if (scriptId != null && scriptUri != null) {
_scriptRefsById[scriptId] = scriptRef;
_scriptIdToLibraryId[scriptId] = libraryId;
_serverPathToScriptRef[DartUri(scriptUri, _root).serverPath] =
scriptRef;
libraryIdToScriptRefs.add(scriptRef);
}
}
}
}
return _scriptRefsById.values.toList();
});
}
/// Look up the script by id in an isolate.
@override
ScriptRef? scriptWithId(String? scriptId) =>
scriptId == null ? null : _scriptRefsById[scriptId];
/// Runs an eval on the page to compute all existing registered extensions.
Future<List<String>> _getExtensionRpcs() async {
final expression =
"${globalLoadStrategy.loadModuleSnippet}('dart_sdk').developer._extensions.keys.toList();";
final extensionRpcs = <String>[];
final params = {
'expression': expression,
'returnByValue': true,
'contextId': await contextId,
};
try {
final response =
await remoteDebugger.sendCommand('Runtime.evaluate', params: params);
final result = getResultOrHandleError(response, evalContents: expression);
extensionRpcs.addAll(List.from(result['value'] as List? ?? []));
} catch (e, s) {
_logger.severe(
'Error calling Runtime.evaluate with params $params', e, s);
}
return extensionRpcs;
}
/// Convert a JS exception description into a description containing
/// a Dart stack trace.
@override
Future<String> mapExceptionStackTrace(String description) async {
RemoteObject mapperResult;
try {
mapperResult = await _jsCallFunction(
stackTraceMapperExpression, <Object>[description]);
} catch (_) {
return description;
}
final mappedStack = mapperResult.value?.toString();
if (mappedStack == null || mappedStack.isEmpty) {
return description;
}
final message = _allLinesBeforeStackTrace(description);
return '$message$mappedStack';
}
String _allLinesBeforeStackTrace(String description) {
var message = '';
for (final match in exceptionMessageRegex.allMatches(description)) {
final isStackTraceLine = stackTraceLineRegex.hasMatch(match[0] ?? '');
if (isStackTraceLine) break;
message += '${match[0]}\n';
}
return message;
}
}