blob: 61c0b001b9aeee1f5a66c9b97877804fe5a30c5d [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: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/inspector.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/metadata/provider.dart';
import 'package:dwds/src/debugging/remote_debugger.dart';
import 'package:dwds/src/loaders/ddc_library_bundle.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 ChromeAppInspector extends AppInspector {
final _logger = Logger('AppInspector');
final RemoteDebugger remoteDebugger;
final ExecutionContext _executionContext;
@override
late final ChromeLibraryHelper libraryHelper = ChromeLibraryHelper(this);
late ChromeAppClassHelper _classHelper;
late ChromeAppInstanceHelper _instanceHelper;
final AssetReader _assetReader;
final Locations _locations;
/// 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);
/// Regex used to extract a stack trace line from the exception description.
static final stackTraceLineRegex = RegExp(r'^\s*at\s.*$', multiLine: true);
ChromeAppInspector._(
super.appConnection,
super.isolate,
this.remoteDebugger,
this._assetReader,
this._locations,
super.root,
this._executionContext,
);
/// Reset all caches and recompute any mappings.
///
/// Should be called across hot reloads with a valid [ModifiedModuleReport].
@override
Future<void> initialize({ModifiedModuleReport? modifiedModuleReport}) async {
await super.initialize(modifiedModuleReport: modifiedModuleReport);
// TODO(srujzs): We can invalidate these in a smarter way instead of
// reinitializing when doing a hot reload, but these helpers recompute info
// on demand later and therefore are not in the critical path.
_classHelper = ChromeAppClassHelper(this);
_instanceHelper = ChromeAppInstanceHelper(this);
// Populate the extension RPCs in the isolate.
await getExtensionRpcs();
}
static Future<ChromeAppInspector> 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 = ChromeAppInspector._(
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.
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].
Future<RemoteObject> loadField(RemoteObject receiver, String fieldName) {
final load = globalToolConfiguration.loadStrategy.dartRuntimeDebugger
.getPropertyJsExpression(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 = globalToolConfiguration.loadStrategy.dartRuntimeDebugger
.callInstanceMethodJsExpression(methodName);
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].
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.)
Future<RemoteObject> invoke(
String targetId,
String selector, [
List<dynamic> arguments = const [],
]) 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,
) {
final libraryUri = library.uri;
if (libraryUri == null) {
throwInvalidParam('invoke', 'library uri is null');
}
return globalToolConfiguration.loadStrategy is DdcLibraryBundleStrategy
? _evaluateLibraryMethodWithDdcLibraryBundle(
libraryUri,
selector,
arguments,
)
: _evaluateInLibrary(
libraryUri,
'function () { return this.$selector.apply(this, arguments); }',
arguments,
);
}
/// Evaluate [expression] by calling Chrome's Runtime.evaluate.
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
/// the library identified by [libraryUri] with [arguments].
Future<RemoteObject> _evaluateInLibrary(
String libraryUri,
String jsFunction,
List<RemoteObject> arguments,
) async {
final findLibraryJsExpression = globalToolConfiguration
.loadStrategy
.dartRuntimeDebugger
.callLibraryMethodJsExpression(libraryUri, jsFunction);
final remoteLibrary = await jsEvaluate(findLibraryJsExpression);
return jsCallFunctionOn(remoteLibrary, jsFunction, arguments);
}
/// Evaluates the specified top-level method [methodName] within the library
/// identified by [libraryUri] using the Dart Development Compiler (DDC)
/// library bundle strategy with the given optional [arguments].
Future<RemoteObject> _evaluateLibraryMethodWithDdcLibraryBundle(
String libraryUri,
String methodName, [
List<RemoteObject> arguments = const [],
]) {
final expression = globalToolConfiguration.loadStrategy.dartRuntimeDebugger
.callLibraryMethodJsExpression(libraryUri, methodName);
return _jsCallFunction(expression, arguments);
}
/// Call [function] with objects referred by [argumentIds] as arguments.
Future<RemoteObject> callFunction(
String function,
Iterable<String> argumentIds,
) {
final arguments = argumentIds.map(remoteObjectFor).toList();
return _jsCallFunction(function, arguments);
}
Future<InstanceRef?> instanceRefFor(Object value) =>
_instanceHelper.instanceRefFor(value);
Future<Instance?> instanceFor(RemoteObject value) =>
_instanceHelper.instanceFor(value);
Future<Library?> getLibrary(String objectId) async {
final libraryRef = await libraryRefFor(objectId);
if (libraryRef == null) return null;
return libraryHelper.libraryFor(libraryRef);
}
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;
}
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;
}
/// Return the VM SourceReport for the given parameters.
///
/// Currently this implements the 'PossibleBreakpoints' report kind.
Future<SourceReport> getSourceReport(
List<String> reports, {
String? scriptId,
}) {
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 (final 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);
}
/// 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].
Future<List<Property>> getProperties(
String objectId, {
int? offset,
int? count,
int? length,
}) async {
var 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 = globalToolConfiguration.loadStrategy.dartRuntimeDebugger
.getSubRangeJsExpression();
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.
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.
bool isNativeJsError(InstanceRef instanceRef) {
return _instanceHelper.metadataHelper.isNativeJsError(instanceRef.classRef);
}
/// Runs an eval on the page to compute all existing registered extensions.
///
/// Combines this with the RPCs registered in the [isolate]. Use this over
/// [Isolate.extensionRPCs] as this computes a live set.
///
/// Updates [Isolate.extensionRPCs] to this set.
@override
Future<Set<String>> getExtensionRpcs() async {
final expression = globalToolConfiguration.loadStrategy.dartRuntimeDebugger
.getDartDeveloperExtensionNamesJsExpression();
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,
);
}
isolate.extensionRPCs = List<String>.of(extensionRpcs);
return extensionRpcs;
}
/// Convert a JS exception description into a description containing
/// a Dart stack trace.
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;
}
}