blob: 3498b5c3444ca926c8aa85c9bf560563df8059d0 [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:convert';
import 'package:dwds/src/events.dart';
import 'package:dwds/src/services/chrome_debug_exception.dart';
import 'package:dwds/src/services/chrome_proxy_service.dart';
import 'package:dwds/src/services/debug_service.dart';
import 'package:dwds/src/utilities/shared.dart';
import 'package:dwds/src/utilities/synchronized.dart';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';
import 'package:vm_service_interface/vm_service_interface.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
final _logger = Logger('DwdsVmClient');
/// Type of requests added to the request controller.
typedef VmRequest = Map<String, Object>;
/// Type of responses added to the response controller.
typedef VmResponse = Map<String, Object?>;
enum _NamespacedServiceExtension {
extDwdsEmitEvent(method: 'ext.dwds.emitEvent'),
extDwdsReload(method: 'ext.dwds.reload'),
extDwdsRestart(method: 'ext.dwds.restart'),
extDwdsScreenshot(method: 'ext.dwds.screenshot'),
extDwdsSendEvent(method: 'ext.dwds.sendEvent'),
flutterListViews(method: '_flutter.listViews');
const _NamespacedServiceExtension({required this.method});
final String method;
}
// A client of the vm service that registers some custom extensions like
// hotRestart.
class DwdsVmClient {
final VmService client;
final StreamController<Map<String, Object>> _requestController;
final StreamController<Map<String, Object?>> _responseController;
/// Null until [close] is called.
///
/// All subsequent calls to [close] will return this future.
Future<void>? _closed;
/// Synchronizes hot restarts to avoid races.
final _hotRestartQueue = AtomicQueue();
DwdsVmClient(this.client, this._requestController, this._responseController);
Future<void> close() =>
_closed ??= () async {
await _requestController.close();
await _responseController.close();
await client.dispose();
}();
static Future<DwdsVmClient> create(
DebugService debugService,
DwdsStats dwdsStats,
Uri? ddsUri,
) async {
final chromeProxyService =
debugService.chromeProxyService as ChromeProxyService;
final responseController = StreamController<VmResponse>();
final responseSink = responseController.sink;
// Response stream must be a broadcast stream so that it can have multiple
// listeners:
final responseStream = responseController.stream.asBroadcastStream();
final requestController = StreamController<VmRequest>();
final requestSink = requestController.sink;
final requestStream = requestController.stream;
final clientCompleter = Completer<VmService>();
_setUpVmServerConnection(
chromeProxyService: chromeProxyService,
debugService: debugService,
responseStream: responseStream,
responseSink: responseSink,
requestStream: requestStream,
requestSink: requestSink,
dwdsStats: dwdsStats,
clientFuture: clientCompleter.future,
);
final client =
ddsUri == null
? _setUpVmClient(
responseStream: responseStream,
requestController: requestController,
requestSink: requestSink,
)
: await _setUpDdsClient(ddsUri: ddsUri);
if (!clientCompleter.isCompleted) {
clientCompleter.complete(client);
}
final dwdsVmClient = DwdsVmClient(
client,
requestController,
responseController,
);
await _registerServiceExtensions(
client: client,
chromeProxyService: chromeProxyService,
dwdsVmClient: dwdsVmClient,
);
return dwdsVmClient;
}
/// Establishes a VM service client that is connected via DDS and registers
/// the service extensions on that client.
static Future<VmService> _setUpDdsClient({required Uri ddsUri}) async {
final client = await vmServiceConnectUri(ddsUri.toString());
return client;
}
/// Establishes a VM service client that bypasses DDS and registers service
/// extensions on that client.
///
/// Note: This is only used in the rare cases where DDS is disabled.
static VmService _setUpVmClient({
required Stream<VmResponse> responseStream,
required StreamSink<VmRequest> requestSink,
required StreamController<VmRequest> requestController,
}) {
final client = VmService(responseStream.map(jsonEncode), (request) {
if (requestController.isClosed) {
_logger.warning(
'Attempted to send a request but the connection is closed:\n\n'
'$request',
);
return;
}
requestSink.add(Map<String, Object>.from(jsonDecode(request)));
});
return client;
}
/// Establishes a direct connection with the VM Server.
///
/// This is used to register the [_NamespacedServiceExtension]s. Because
/// namespaced service extensions are supposed to be registered by the engine,
/// we need to register them on the VM server connection instead of via DDS.
///
/// TODO(https://github.com/dart-lang/webdev/issues/1315): Ideally the engine
/// should register all Flutter service extensions. However, to do so we will
/// need to implement the missing isolate-related dart:developer APIs so that
/// the engine has access to this information.
static void _setUpVmServerConnection({
required ChromeProxyService chromeProxyService,
required DwdsStats dwdsStats,
required DebugService debugService,
required Stream<VmResponse> responseStream,
required StreamSink<VmResponse> responseSink,
required Stream<VmRequest> requestStream,
required StreamSink<VmRequest> requestSink,
required Future<VmService> clientFuture,
}) {
responseStream.listen((request) async {
final response = await _maybeHandleServiceExtensionRequest(
request,
chromeProxyService: chromeProxyService,
dwdsStats: dwdsStats,
clientFuture: clientFuture,
);
if (response != null) {
requestSink.add(response);
}
});
final vmServerConnection = VmServerConnection(
requestStream,
responseSink,
debugService.serviceExtensionRegistry,
debugService.chromeProxyService,
);
for (final extension in _NamespacedServiceExtension.values) {
debugService.serviceExtensionRegistry.registerExtension(
extension.method,
vmServerConnection,
);
}
}
static Future<VmRequest?> _maybeHandleServiceExtensionRequest(
VmResponse request, {
required ChromeProxyService chromeProxyService,
required DwdsStats dwdsStats,
required Future<VmService> clientFuture,
}) async {
VmRequest? response;
final method = request['method'];
if (method == _NamespacedServiceExtension.flutterListViews.method) {
response = await _flutterListViewsHandler(chromeProxyService);
} else if (method == _NamespacedServiceExtension.extDwdsEmitEvent.method) {
response = _extDwdsEmitEventHandler(request);
} else if (method == _NamespacedServiceExtension.extDwdsReload.method) {
response = await _extDwdsReloadHandler(chromeProxyService);
} else if (method == _NamespacedServiceExtension.extDwdsRestart.method) {
final client = await clientFuture;
response = await _extDwdsRestartHandler(chromeProxyService, client);
} else if (method == _NamespacedServiceExtension.extDwdsSendEvent.method) {
response = await _extDwdsSendEventHandler(request, dwdsStats);
} else if (method == _NamespacedServiceExtension.extDwdsScreenshot.method) {
response = await _extDwdsScreenshotHandler(chromeProxyService);
}
if (response != null) {
response['id'] = request['id'] as String;
// This is necessary even though DWDS doesn't use package:json_rpc_2.
// Without it, the response will be treated as invalid:
// https://github.com/dart-lang/json_rpc_2/blob/639857be892050159f5164c749d7947694976a4a/lib/src/server.dart#L252
response['jsonrpc'] = '2.0';
}
return response;
}
static Future<Map<String, Object>> _flutterListViewsHandler(
ChromeProxyService chromeProxyService,
) async {
final vm = await chromeProxyService.getVM();
final isolates = vm.isolates;
return <String, Object>{
'result': <String, Object>{
'views': <Object>[
for (final isolate in isolates ?? [])
<String, Object>{'id': isolate.id, 'isolate': isolate.toJson()},
],
},
};
}
static Future<Map<String, Object>> _extDwdsScreenshotHandler(
ChromeProxyService chromeProxyService,
) async {
await chromeProxyService.remoteDebugger.enablePage();
final response = await chromeProxyService.remoteDebugger.sendCommand(
'Page.captureScreenshot',
);
return {'result': response.result as Object};
}
static Future<Map<String, Object>> _extDwdsSendEventHandler(
VmResponse request,
DwdsStats dwdsStats,
) async {
_processSendEvent(request, dwdsStats);
return {'result': Success().toJson()};
}
static Map<String, Object> _extDwdsEmitEventHandler(VmResponse request) {
final event = request['params'] as Map<String, dynamic>?;
if (event != null) {
final type = event['type'] as String?;
final payload = event['payload'] as Map<String, dynamic>?;
if (type != null && payload != null) {
emitEvent(DwdsEvent(type, payload));
}
}
return {'result': Success().toJson()};
}
static Future<Map<String, Object>> _extDwdsReloadHandler(
ChromeProxyService chromeProxyService,
) async {
await _fullReload(chromeProxyService);
return {'result': Success().toJson()};
}
static Future<Map<String, Object>> _extDwdsRestartHandler(
ChromeProxyService chromeProxyService,
VmService client,
) async {
await _hotRestart(chromeProxyService, client);
return {'result': Success().toJson()};
}
static Future<void> _registerServiceExtensions({
required VmService client,
required ChromeProxyService chromeProxyService,
required DwdsVmClient dwdsVmClient,
}) async {
client.registerServiceCallback(
'hotRestart',
(request) => captureElapsedTime(
() => dwdsVmClient.hotRestart(chromeProxyService, client),
(_) => DwdsEvent.hotRestart(),
),
);
await client.registerService('hotRestart', 'DWDS');
client.registerServiceCallback(
'fullReload',
(request) => captureElapsedTime(
() => _fullReload(chromeProxyService),
(_) => DwdsEvent.fullReload(),
),
);
await client.registerService('fullReload', 'DWDS');
}
Future<Map<String, dynamic>> hotRestart(
ChromeProxyService chromeProxyService,
VmService client,
) {
return _hotRestartQueue.run(() => _hotRestart(chromeProxyService, client));
}
}
void _processSendEvent(Map<String, dynamic> request, DwdsStats dwdsStats) {
final event = request['params'] as Map<String, dynamic>?;
if (event == null) return;
final type = event['type'] as String?;
final payload = event['payload'] as Map<String, dynamic>?;
switch (type) {
case 'DevtoolsEvent':
{
_logger.finest('Received DevTools event: $event');
final action = payload?['action'] as String?;
final screen = payload?['screen'] as String?;
if (screen != null && action == 'pageReady') {
_recordDwdsStats(dwdsStats, screen);
} else {
_logger.finest('Ignoring unknown event: $event');
}
}
}
}
void _recordDwdsStats(DwdsStats dwdsStats, String screen) {
if (dwdsStats.isFirstDebuggerReady) {
final devToolsStart = dwdsStats.devToolsStart;
final debuggerStart = dwdsStats.debuggerStart;
if (devToolsStart != null) {
final devToolLoadTime =
DateTime.now().difference(devToolsStart).inMilliseconds;
emitEvent(DwdsEvent.devToolsLoad(devToolLoadTime, screen));
_logger.fine('DevTools load time: $devToolLoadTime ms');
}
if (debuggerStart != null) {
final debuggerReadyTime =
DateTime.now().difference(debuggerStart).inMilliseconds;
emitEvent(DwdsEvent.debuggerReady(debuggerReadyTime, screen));
_logger.fine('Debugger ready time: $debuggerReadyTime ms');
}
} else {
_logger.finest('Debugger and DevTools stats are already recorded.');
}
}
Future<int> tryGetContextId(
ChromeProxyService chromeProxyService, {
int retries = 3,
}) async {
const waitInMs = 50;
for (var retry = 0; retry < retries; retry++) {
final tryId = await chromeProxyService.executionContext.id;
if (tryId != null) return tryId;
await Future.delayed(const Duration(milliseconds: waitInMs));
}
throw StateError('No context with the running Dart application.');
}
Future<Map<String, dynamic>> _hotRestart(
ChromeProxyService chromeProxyService,
VmService client,
) async {
_logger.info('Attempting a hot restart');
chromeProxyService.terminatingIsolates = true;
await _disableBreakpointsAndResume(client, chromeProxyService);
try {
_logger.info('Attempting to get execution context ID.');
await tryGetContextId(chromeProxyService);
_logger.info('Got execution context ID.');
} on StateError catch (e) {
// We couldn't find the execution context. `hotRestart` may have been
// triggered in the middle of a full reload.
return {
'error': {'code': RPCErrorKind.kInternalError.code, 'message': e.message},
};
}
// Start listening for isolate create events before issuing a hot
// restart. Only return success after the isolate has fully started.
final stream = chromeProxyService.onEvent('Isolate');
try {
// If we should pause isolates on start, then only run main once we get a
// resume event.
final pauseIsolatesOnStart = chromeProxyService.pauseIsolatesOnStart;
if (pauseIsolatesOnStart) {
_waitForResumeEventToRunMain(chromeProxyService);
}
// Generate run id to hot restart all apps loaded into the tab.
final runId = const Uuid().v4().toString();
_logger.info('Issuing \$dartHotRestartDwds request');
await chromeProxyService.inspector.jsEvaluate(
'\$dartHotRestartDwds(\'$runId\', $pauseIsolatesOnStart);',
awaitPromise: true,
);
_logger.info('\$dartHotRestartDwds request complete.');
} on WipError catch (exception) {
final code = exception.error?['code'];
final message = exception.error?['message'];
// This corresponds to `Execution context was destroyed` which can
// occur during a hot restart that must fall back to a full reload.
if (code != RPCErrorKind.kServerError.code) {
return {
'error': {'code': code, 'message': message, 'data': exception},
};
}
} on ChromeDebugException catch (exception) {
// Exceptions thrown by the injected client during hot restart.
return {
'error': {
'code': RPCErrorKind.kInternalError.code,
'message': '$exception',
},
};
}
_logger.info('Waiting for Isolate Start event.');
await stream.firstWhere((event) => event.kind == EventKind.kIsolateStart);
chromeProxyService.terminatingIsolates = false;
_logger.info('Successful hot restart');
return {'result': Success().toJson()};
}
void _waitForResumeEventToRunMain(ChromeProxyService chromeProxyService) {
final issuedReadyToRunMainCompleter = Completer<void>();
final resumeEventsSubscription = chromeProxyService
.resumeAfterRestartEventsStream
.listen((_) async {
await chromeProxyService.inspector.jsEvaluate(
'\$dartReadyToRunMain();',
);
if (!issuedReadyToRunMainCompleter.isCompleted) {
issuedReadyToRunMainCompleter.complete();
}
});
safeUnawaited(
issuedReadyToRunMainCompleter.future.then((_) {
resumeEventsSubscription.cancel();
}),
);
}
Future<Map<String, dynamic>> _fullReload(
ChromeProxyService chromeProxyService,
) async {
_logger.info('Attempting a full reload');
await chromeProxyService.remoteDebugger.enablePage();
await chromeProxyService.remoteDebugger.pageReload();
_logger.info('Successful full reload');
return {'result': Success().toJson()};
}
Future<void> _disableBreakpointsAndResume(
VmService client,
ChromeProxyService chromeProxyService,
) async {
_logger.info('Attempting to disable breakpoints and resume the isolate');
final vm = await client.getVM();
final isolates = vm.isolates;
if (isolates == null || isolates.isEmpty) {
throw StateError('No active isolate to resume.');
}
final isolateId = isolates.first.id;
if (isolateId == null) {
throw StateError('No active isolate to resume.');
}
await chromeProxyService.disableBreakpoints();
try {
// Any checks for paused status result in race conditions or hangs
// at this point:
//
// - `getIsolate()` and check for status:
// the app might still pause on existing breakpoint.
//
// - `pause()` and wait for `Debug.paused` event:
// chrome does not send the `Debug.Paused `notification
// without shifting focus to chrome.
//
// Instead, just try resuming and
// ignore failures indicating that the app is already running:
//
// WipError -32000 Can only perform operation while paused.
await client.resume(isolateId);
} on RPCError catch (e, s) {
if (!e.message.contains('Can only perform operation while paused')) {
_logger.severe('Hot restart failed to resume exiting isolate', e, s);
rethrow;
}
}
_logger.info('Successfully disabled breakpoints and resumed the isolate');
}