blob: 5880513961053c37304a8d9ef2c16fb38f6e3bfd [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 'dart:math' as math;
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:dwds/src/config/tool_configuration.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/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/objects.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) {
_libraryHelper = LibraryHelper(this);
_classHelper = ClassHelper(this);
_instanceHelper = InstanceHelper(this);
}
Future<void> initialize() async {
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);
await inspector.initialize();
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 ${globalToolConfiguration.loadStrategy.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 ${globalToolConfiguration.loadStrategy.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() {
const sdk = ${globalToolConfiguration.loadStrategy.loadModuleSnippet}('dart_sdk');
const dart = sdk.dart;
const library = dart.getLibrary('$libraryUri');
if (!library) throw 'cannot find library for $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,
) {
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',
RPCErrorKind.kInternalError.code,
'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',
RPCErrorKind.kInternalError.code,
'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);
}
/// Calls the Chrome Runtime.getProperties API for the object with [objectId].
///
/// Note that the property names are JS names, e.g.
/// Symbol(DartClass.actualName) and will need to be converted. For a system
/// List or Map, [offset] and/or [count] can be provided to indicate a desired
/// range of entries. They will be ignored if there is no [length].
@override
Future<List<Property>> getProperties(
String objectId, {
int? offset,
int? count,
int? length,
}) async {
String rangeId = objectId;
// Ignore offset/count if there is no length:
if (length != null) {
if (_isEmptyRange(offset: offset, count: count, length: length)) {
return [];
}
if (_isSubRange(offset: offset, count: count)) {
final range = await _subRange(
objectId,
offset: offset ?? 0,
count: count,
length: length,
);
rangeId = range.objectId ?? rangeId;
}
}
final jsProperties = await sendCommandAndValidateResult<List>(
_remoteDebugger,
method: 'Runtime.getProperties',
resultField: 'result',
params: {
'objectId': rangeId,
'ownProperties': true,
},
);
return jsProperties
.map<Property>((each) => Property(each as Map<String, dynamic>))
.where(_isVisibleProperty)
.toList();
}
bool _isVisibleProperty(Property property) {
// Filter out any RTI objects from the new DDC type system. See:
// https://github.com/dart-lang/webdev/issues/2316
final isRtiObject =
property.value?.className?.startsWith('dart_rti.Rti') ?? false;
return !isRtiObject;
}
/// Calculate the number of available elements in the range.
static int _calculateRangeCount({
int? count,
required int offset,
required int length,
}) =>
count == null ? length - offset : math.min(count, length - offset);
/// Find a sub-range of the entries for a Map/List when offset and/or count
/// have been specified on a getObject request.
///
/// If the object referenced by [id] is not a system List or Map then this
/// will just return a RemoteObject for it and ignore [offset], [count] and
/// [length]. If it is, then [length] should be the number of entries in the
/// List/Map and [offset] and [count] should indicate the desired range.
Future<RemoteObject> _subRange(
String id, {
required int offset,
required int length,
int? count,
}) async {
// TODO(#809): Sometimes we already know the type of the object, and
// we could take advantage of that to short-circuit.
final receiver = remoteObjectFor(id);
final rangeCount =
_calculateRangeCount(count: count, offset: offset, length: length);
final args =
[offset, rangeCount].map(dartIdFor).map(remoteObjectFor).toList();
// If this is a List, just call sublist. If it's a Map, get the entries, but
// avoid doing a toList on a large map using skip/take to get the section we
// want. To make those alternatives easier in JS, pass both count and end.
final expression = '''
function (offset, count) {
const sdk = ${globalToolConfiguration.loadStrategy.loadModuleSnippet}("dart_sdk");
const dart = sdk.dart;
return dart.getSubRange(this, offset, count);
}
''';
return await jsCallFunctionOn(receiver, expression, args);
}
static bool _isEmptyRange({
required int length,
int? offset,
int? count,
}) {
if (count == 0) return true;
if (offset == null) return false;
return offset >= length;
}
static bool _isSubRange({
int? offset,
int? count,
}) {
if (offset == 0 && count == null) return false;
return offset != null || count != null;
}
/// Returns true for objects we display for the user.
@override
bool isDisplayableObject(Object? object) =>
object is Sentinel ||
object is InstanceRef &&
!isNativeJsObject(object) &&
!isNativeJsError(object);
/// Returns true for non-dart JavaScript objects.
bool isNativeJsObject(InstanceRef instanceRef) {
return _instanceHelper.metadataHelper
.isNativeJsObject(instanceRef.classRef);
}
/// Returns true for JavaScript exceptions.
@override
bool isNativeJsError(InstanceRef instanceRef) {
return _instanceHelper.metadataHelper.isNativeJsError(instanceRef.classRef);
}
/// 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() {
return _scriptCacheMemoizer.runOnce(() async {
final scripts = await globalToolConfiguration.loadStrategy
.metadataProviderFor(appConnection.request.entrypointPath)
.scripts;
// For all the non-dart: libraries, find their parts and create scriptRefs
// for them.
final userLibraries =
_userLibraryUris(isolate.libraries ?? <LibraryRef>[]);
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();
});
}
Iterable<String> _userLibraryUris(Iterable<LibraryRef> libraries) {
return libraries
.map((library) => library.uri ?? '')
.where((uri) => uri.isNotEmpty && !uri.startsWith('dart:'));
}
/// 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 =
"${globalToolConfiguration.loadStrategy.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;
}
}