blob: 66247d7d0d5ccd2715e3464e0f7ed37a8a04fc9d [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:async';
import 'dart:collection';
import 'dart:convert';
import 'package:dwds/data/devtools_request.dart';
import 'package:dwds/data/extension_request.dart';
import 'package:dwds/data/serializers.dart';
import 'package:dwds/src/debugging/execution_context.dart';
import 'package:dwds/src/debugging/remote_debugger.dart';
import 'package:dwds/src/handlers/socket_connections.dart';
import 'package:dwds/src/services/chrome_debug_exception.dart';
import 'package:logging/logging.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
hide StackTrace;
final _logger = Logger('ExtensionDebugger');
/// A remote debugger backed by the Dart Debug Extension with an SSE connection.
class ExtensionDebugger implements RemoteDebugger {
/// A connection between the debugger and the background of
/// Dart Debug Extension
final SocketConnection sseConnection;
/// A map from id to a completer associated with an [ExtensionRequest]
final _completers = <int, Completer>{};
final _eventStreams = <String, Stream>{};
var _completerId = 0;
/// Null until [close] is called.
///
/// All subsequent calls to [close] will return this future.
Future<void>? _closed;
String? instanceId;
ExecutionContext? _executionContext;
ExecutionContext? get executionContext => _executionContext;
final _devToolsRequestController = StreamController<DevToolsRequest>();
Stream<DevToolsRequest> get devToolsRequestStream =>
_devToolsRequestController.stream;
final _notificationController = StreamController<WipEvent>.broadcast();
Stream<WipEvent> get onNotification => _notificationController.stream;
final _closeController = StreamController<Object>.broadcast();
@override
Stream<Object> get onClose => _closeController.stream;
@override
Stream<ConsoleAPIEvent> get onConsoleAPICalled => eventStream(
'Runtime.consoleAPICalled',
(WipEvent event) => ConsoleAPIEvent(event.json));
@override
Stream<ExceptionThrownEvent> get onExceptionThrown => eventStream(
'Runtime.exceptionThrown',
(WipEvent event) => ExceptionThrownEvent(event.json));
final _scripts = <String, WipScript>{};
final _scriptIds = <String, String>{};
ExtensionDebugger(this.sseConnection) {
sseConnection.stream.listen((data) {
final message = serializers.deserialize(jsonDecode(data));
if (message is ExtensionResponse) {
final encodedResult = {
'result': json.decode(message.result),
'id': message.id
};
final completer = _completers[message.id];
if (completer == null) {
throw StateError('Missing completer.');
}
// TODO(#988): Call completeError(WipError()) to match the behavior of
// package:webkit_inspection_protocol.
completer.complete(WipResponse(encodedResult));
} else if (message is ExtensionEvent) {
final map = {
'method': json.decode(message.method),
'params': json.decode(message.params)
};
// Note: package:sse will try to keep the connection alive, even after
// the client has been closed. Therefore the extension sends an event to
// notify DWDS that we should close the connection, instead of relying
// on the done event sent when the client is closed. See details:
// https://github.com/dart-lang/webdev/pull/1595#issuecomment-1116773378
if (map['method'] == 'DebugExtension.detached') {
close();
} else {
_notificationController.sink.add(WipEvent(map));
}
} else if (message is BatchedEvents) {
for (var event in message.events) {
final map = {
'method': json.decode(event.method),
'params': json.decode(event.params)
};
_notificationController.sink.add(WipEvent(map));
}
} else if (message is DevToolsRequest) {
instanceId = message.instanceId;
_executionContext =
RemoteDebuggerExecutionContext(message.contextId, this);
_devToolsRequestController.sink.add(message);
}
}, onError: (_) {
close();
}, onDone: close);
onScriptParsed.listen((event) {
// Remove stale scripts from cache.
if (event.script.url.isNotEmpty &&
_scriptIds.containsKey(event.script.url)) {
_scripts.remove(_scriptIds[event.script.url]);
}
_scripts[event.script.scriptId] = event.script;
_scriptIds[event.script.url] = event.script.scriptId;
});
// Listens for a page reload.
onGlobalObjectCleared.listen((_) {
_scripts.clear();
});
}
void sendEvent(String method, String params) {
sseConnection.sink
.add(jsonEncode(serializers.serialize(ExtensionEvent((b) => b
..method = method
..params = params))));
}
/// Sends a [command] with optional [params] to Dart Debug Extension
/// over the SSE connection.
@override
Future<WipResponse> sendCommand(String command,
{Map<String, dynamic>? params}) {
final completer = Completer<WipResponse>();
final id = newId();
_completers[id] = completer;
try {
sseConnection.sink
.add(jsonEncode(serializers.serialize(ExtensionRequest((b) => b
..id = id
..command = command
..commandParams = jsonEncode(params ?? {})))));
} on StateError catch (error, stackTrace) {
if (error.message.contains('Cannot add event after closing')) {
_logger.severe('Socket connection closed. Shutting down debugger.');
closeWithError(error);
} else {
_logger.severe('Bad state while sending $command.', error, stackTrace);
}
} catch (error, stackTrace) {
_logger.severe(
'Unknown error while sending $command.', error, stackTrace);
}
return completer.future;
}
int newId() => _completerId++;
@override
void close() => _closed ??= () {
_closeController.add({});
return Future.wait([
sseConnection.sink.close(),
_notificationController.close(),
_devToolsRequestController.close(),
_closeController.close(),
]);
}();
void closeWithError(Object? error) {
_logger.shout(
'Closing extension debugger due to error. Restart app for debugging functionality',
error);
close();
}
@override
Future disable() => sendCommand('Debugger.disable');
@override
Future enable() => sendCommand('Debugger.enable');
@override
Future<String> getScriptSource(String scriptId) async =>
(await sendCommand('Debugger.getScriptSource',
params: {'scriptId': scriptId}))
.result!['scriptSource'] as String;
@override
Future<WipResponse> pause() => sendCommand('Debugger.pause');
@override
Future<WipResponse> resume() => sendCommand('Debugger.resume');
@override
Future<WipResponse> setPauseOnExceptions(PauseState state) =>
sendCommand('Debugger.setPauseOnExceptions',
params: {'state': _pauseStateToString(state)});
@override
Future<WipResponse> removeBreakpoint(String breakpointId) {
return sendCommand('Debugger.removeBreakpoint',
params: {'breakpointId': breakpointId});
}
@override
Future<WipResponse> stepInto({Map<String, dynamic>? params}) =>
sendCommand('Debugger.stepInto', params: params);
@override
Future<WipResponse> stepOut() => sendCommand('Debugger.stepOut');
@override
Future<WipResponse> stepOver({Map<String, dynamic>? params}) =>
sendCommand('Debugger.stepOver', params: params);
@override
Future<WipResponse> enablePage() => sendCommand('Page.enable');
@override
Future<WipResponse> pageReload() => sendCommand('Page.reload');
@override
Future<RemoteObject> evaluate(String expression,
{bool? returnByValue, int? contextId}) async {
final params = <String, dynamic>{
'expression': expression,
};
if (returnByValue != null) {
params['returnByValue'] = returnByValue;
}
if (returnByValue != null) {
params['contextId'] = contextId;
}
final response = await sendCommand('Runtime.evaluate', params: params);
final result = _validateResult(response.result);
return RemoteObject(result['result'] as Map<String, dynamic>);
}
@override
Future<RemoteObject> evaluateOnCallFrame(
String callFrameId, String expression) async {
final params = <String, dynamic>{
'callFrameId': callFrameId,
'expression': expression,
};
final response =
await sendCommand('Debugger.evaluateOnCallFrame', params: params);
final result = _validateResult(response.result);
return RemoteObject(result['result'] as Map<String, dynamic>);
}
@override
Future<List<WipBreakLocation>> getPossibleBreakpoints(
WipLocation start) async {
final params = <String, dynamic>{
'start': start.toJsonMap(),
};
final response =
await sendCommand('Debugger.getPossibleBreakpoints', params: params);
final result = _validateResult(response.result);
final locations = result['locations'] as List;
return List.from(
locations.map((map) => WipBreakLocation(map as Map<String, dynamic>)));
}
@override
Stream<T> eventStream<T>(String method, WipEventTransformer<T> transformer) {
return _eventStreams
.putIfAbsent(
method,
() => onNotification
.where((event) => event.method == method)
.map(transformer))
.cast();
}
@override
Stream<GlobalObjectClearedEvent> get onGlobalObjectCleared => eventStream(
'Debugger.globalObjectCleared',
(WipEvent event) => GlobalObjectClearedEvent(event.json));
@override
Stream<DebuggerPausedEvent> get onPaused => eventStream(
'Debugger.paused', (WipEvent event) => DebuggerPausedEvent(event.json));
@override
Stream<DebuggerResumedEvent> get onResumed => eventStream(
'Debugger.resumed', (WipEvent event) => DebuggerResumedEvent(event.json));
@override
Stream<ScriptParsedEvent> get onScriptParsed => eventStream(
'Debugger.scriptParsed',
(WipEvent event) => ScriptParsedEvent(event.json));
@override
Stream<TargetCrashedEvent> get onTargetCrashed => eventStream(
'Inspector.targetCrashed',
(WipEvent event) => TargetCrashedEvent(event.json));
@override
Map<String, WipScript> get scripts => UnmodifiableMapView(_scripts);
String _pauseStateToString(PauseState state) {
switch (state) {
case PauseState.all:
return 'all';
case PauseState.none:
return 'none';
case PauseState.uncaught:
return 'uncaught';
default:
throw ArgumentError('unknown state: $state');
}
}
Map<String, dynamic> _validateResult(Map<String, dynamic>? result) {
if (result == null) {
throw ChromeDebugException({'text': 'null result from Chrome Devtools'});
}
if (result.containsKey('exceptionDetails')) {
throw ChromeDebugException(
result['exceptionDetails'] as Map<String, dynamic>);
}
return result;
}
}