blob: 53b01c49b38ffee1139c8308f0ec7b0cc7f0855a [file] [log] [blame]
// Copyright (c) 2025, 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/data/hot_reload_request.dart';
import 'package:dwds/data/hot_reload_response.dart';
import 'package:dwds/data/hot_restart_request.dart';
import 'package:dwds/data/hot_restart_response.dart';
import 'package:dwds/data/service_extension_request.dart';
import 'package:dwds/data/service_extension_response.dart';
import 'package:dwds/src/connections/app_connection.dart';
import 'package:dwds/src/debugging/web_socket_inspector.dart';
import 'package:dwds/src/services/proxy_service.dart';
import 'package:dwds/src/services/web_socket/web_socket_debug_service.dart';
import 'package:dwds/src/utilities/shared.dart';
import 'package:logging/logging.dart';
import 'package:vm_service/vm_service.dart' as vm_service;
import 'package:vm_service/vm_service.dart';
/// Defines callbacks for sending messages to the connected client.
/// Returns the number of clients the request was successfully sent to.
typedef SendClientRequest = int Function(Object request);
const _pauseIsolatesOnStartFlag = 'pause_isolates_on_start';
/// Grace period before destroying isolate when no clients are detected.
/// This handles the race condition during page refresh where the old connection
/// closes before the new connection is established, preventing premature isolate destruction.
const _isolateDestructionGracePeriod = Duration(seconds: 15);
/// Error message when no clients are available for hot reload/restart.
const kNoClientsAvailable = 'No clients available.';
/// Tracks hot reload responses from multiple browser windows/tabs.
class _HotReloadTracker {
final String requestId;
final Completer<HotReloadResponse> completer;
final int expectedResponses;
final List<HotReloadResponse> responses = [];
final Timer timeoutTimer;
_HotReloadTracker({
required this.requestId,
required this.completer,
required this.expectedResponses,
required this.timeoutTimer,
});
bool get gotAllResponses => responses.length >= expectedResponses;
void addResponse(HotReloadResponse response) {
responses.add(response);
}
bool get allSuccessful => responses.every((r) => r.success);
void dispose() {
timeoutTimer.cancel();
}
}
/// Tracks hot restart responses from multiple browser windows/tabs.
class _HotRestartTracker {
final String requestId;
final Completer<HotRestartResponse> completer;
final int expectedResponses;
final List<HotRestartResponse> responses = [];
final Timer timeoutTimer;
_HotRestartTracker({
required this.requestId,
required this.completer,
required this.expectedResponses,
required this.timeoutTimer,
});
bool get gotAllResponses => responses.length >= expectedResponses;
void addResponse(HotRestartResponse response) {
responses.add(response);
}
bool get allSuccessful => responses.every((r) => r.success);
void dispose() {
timeoutTimer.cancel();
}
}
/// Tracks service extension responses from multiple browser windows/tabs.
class _ServiceExtensionTracker {
final String requestId;
final Completer<ServiceExtensionResponse> completer;
final int expectedResponses;
final List<ServiceExtensionResponse> responses = [];
final Timer timeoutTimer;
_ServiceExtensionTracker({
required this.requestId,
required this.completer,
required this.expectedResponses,
required this.timeoutTimer,
});
bool get gotAllResponses => responses.length >= expectedResponses;
void addResponse(ServiceExtensionResponse response) {
responses.add(response);
}
bool get allSuccessful => responses.every((r) => r.success);
void dispose() {
timeoutTimer.cancel();
}
}
/// Exception thrown when no browser clients are connected to DWDS.
class NoClientsAvailableException implements Exception {
final String message;
NoClientsAvailableException._(this.message);
@override
String toString() => 'NoClientsAvailableException: $message';
}
/// WebSocket-based VM service proxy for web debugging.
final class WebSocketProxyService extends ProxyService<WebSocketAppInspector> {
final _logger = Logger('WebSocketProxyService');
/// Active service extension trackers by request ID.
final _pendingServiceExtensionTrackers = <String, _ServiceExtensionTracker>{};
/// Sends messages to the client.
final SendClientRequest sendClientRequest;
/// App connection for this service.
AppConnection appConnection;
/// Active hot reload trackers by request ID.
final _pendingHotReloads = <String, _HotReloadTracker>{};
/// Active hot restart trackers by request ID.
final _pendingHotRestarts = <String, _HotRestartTracker>{};
/// App connection cleanup subscriptions by connection instance ID.
final _appConnectionDoneSubscriptions = <String, StreamSubscription<void>>{};
/// Active connection count for this service.
int _activeConnectionCount = 0;
WebSocketProxyService._({
required this.sendClientRequest,
required super.vm,
required super.root,
required super.debugService,
required this.appConnection,
});
static Future<WebSocketProxyService> create(
SendClientRequest sendClientRequest,
AppConnection appConnection,
String root,
WebSocketDebugService debugService,
) async {
final vm = vm_service.VM(
name: 'WebSocketDebugProxy',
operatingSystem: 'web',
startTime: DateTime.now().millisecondsSinceEpoch,
version: 'unknown',
isolates: [],
isolateGroups: [],
systemIsolates: [],
systemIsolateGroups: [],
targetCPU: 'Web',
hostCPU: 'DWDS',
architectureBits: -1,
pid: -1,
);
final service = WebSocketProxyService._(
sendClientRequest: sendClientRequest,
vm: vm,
root: root,
debugService: debugService,
appConnection: appConnection,
);
safeUnawaited(service.createIsolate(appConnection));
return service;
}
// Isolate state
vm_service.Event? _currentPauseEvent;
bool _mainHasStarted = false;
/// Creates a new isolate for WebSocket debugging.
@override
Future<void> createIsolate(
AppConnection appConnection, {
bool newConnection = false,
}) async {
// Update app connection
this.appConnection = appConnection;
// Track this connection
final connectionId = appConnection.request.instanceId;
// Check if this connection is already being tracked
final isNewConnection =
newConnection ||
!_appConnectionDoneSubscriptions.containsKey(connectionId);
if (isNewConnection) {
_activeConnectionCount++;
_logger.fine(
'Adding new connection: $connectionId (total: $_activeConnectionCount)',
);
} else {
_logger.fine(
'Reconnecting existing connection: $connectionId (total: $_activeConnectionCount)',
);
}
// Auto-cleanup on connection close
final existingSubscription = _appConnectionDoneSubscriptions[connectionId];
await existingSubscription?.cancel();
_appConnectionDoneSubscriptions[connectionId] = appConnection.onDone
.asStream()
.listen((_) {
_handleConnectionClosed(connectionId);
});
// If we already have a running isolate, just update the connection and return
if (isIsolateRunning) {
_logger.fine(
'Reusing existing isolate ${inspector.isolateRef.id} for connection: $connectionId',
);
return;
}
inspector = await WebSocketAppInspector.create(this, appConnection, root);
final isolateRef = inspector.isolateRef;
vm.isolates?.add(isolateRef);
final timestamp = DateTime.now().millisecondsSinceEpoch;
_logger.fine(
'Created new isolate: ${isolateRef.id} for connection: $connectionId',
);
// Send lifecycle events
streamNotify(
vm_service.EventStreams.kIsolate,
vm_service.Event(
kind: vm_service.EventKind.kIsolateStart,
timestamp: timestamp,
isolate: isolateRef,
),
);
streamNotify(
vm_service.EventStreams.kIsolate,
vm_service.Event(
kind: vm_service.EventKind.kIsolateRunnable,
timestamp: timestamp,
isolate: isolateRef,
),
);
// Send pause event if enabled
if (pauseIsolatesOnStart) {
final pauseEvent = vm_service.Event(
kind: vm_service.EventKind.kPauseStart,
timestamp: timestamp,
isolate: isolateRef,
);
_currentPauseEvent = pauseEvent;
streamNotify(vm_service.EventStreams.kDebug, pauseEvent);
}
// Complete initialization after isolate is set up
if (!initializedCompleter.isCompleted) initializedCompleter.complete();
}
/// Handles a connection being closed.
void _handleConnectionClosed(String connectionId) {
_logger.fine('Connection closed: $connectionId');
// Only decrement if this connection was actually being tracked
if (_appConnectionDoneSubscriptions.containsKey(connectionId)) {
// Remove the subscription for this connection
_appConnectionDoneSubscriptions[connectionId]?.cancel();
_appConnectionDoneSubscriptions.remove(connectionId);
// Decrease active connection count
_activeConnectionCount--;
_logger.fine(
'Removed connection: $connectionId (remaining: $_activeConnectionCount)',
);
_logger.fine(
'Current tracked connections: ${_appConnectionDoneSubscriptions.keys.toList()}',
);
// Instead of destroying the isolate immediately, check if there are still
// clients that can receive hot reload requests
if (_activeConnectionCount <= 0) {
// Double-check by asking the sendClientRequest callback how many clients are available
final actualClientCount = sendClientRequest({'type': 'ping'});
_logger.fine(
'Actual client count from sendClientRequest: $actualClientCount',
);
if (actualClientCount == 0) {
_logger.fine(
'No clients available for hot reload, scheduling isolate destruction',
);
// Add a delay before destroying the isolate to handle page refresh race condition
Timer(_isolateDestructionGracePeriod, () {
// Double-check client count again before destroying
final finalClientCount = sendClientRequest({'type': 'ping'});
if (finalClientCount == 0) {
_logger.fine(
'Final check confirmed no clients, destroying isolate',
);
destroyIsolate();
} else {
_logger.fine(
'Final check found $finalClientCount clients, keeping isolate alive',
);
_activeConnectionCount = finalClientCount;
}
});
} else {
_logger.fine(
'Still have $actualClientCount clients available, keeping isolate alive',
);
// Update our internal counter to match reality
_activeConnectionCount = actualClientCount;
}
} else {
_logger.fine(
'Still have $_activeConnectionCount active connections, keeping isolate alive',
);
}
} else {
_logger.warning(
'Attempted to close connection that was not tracked: $connectionId',
);
}
}
/// Destroys the isolate and cleans up state.
@override
void destroyIsolate() {
_logger.fine('Destroying isolate');
if (!isIsolateRunning) {
_logger.fine('Isolate already destroyed, ignoring');
return;
}
// Cancel all connection subscriptions
for (final subscription in _appConnectionDoneSubscriptions.values) {
subscription.cancel();
}
_appConnectionDoneSubscriptions.clear();
_activeConnectionCount = 0;
final isolateRef = inspector.isolateRef;
// Send exit event
streamNotify(
vm_service.EventStreams.kIsolate,
vm_service.Event(
kind: vm_service.EventKind.kIsolateExit,
timestamp: DateTime.now().millisecondsSinceEpoch,
isolate: isolateRef,
),
);
vm.isolates?.removeWhere((ref) => ref.id == isolateRef.id);
// Reset state
inspector = null;
_currentPauseEvent = null;
_mainHasStarted = false;
if (initializedCompleter.isCompleted) {
initializedCompleter = Completer<void>();
}
}
@override
Future<Success> setIsolatePauseMode(
String isolateId, {
String? exceptionPauseMode,
bool? shouldPauseOnExit,
}) async {
// Not supported in WebSocket mode - return success for compatibility
return Success();
}
@override
Future<vm_service.ReloadReport> reloadSources(
String isolateId, {
bool? force,
bool? pause,
String? rootLibUri,
String? packagesUri,
}) async {
_logger.info('Attempting a hot reload');
try {
await _performWebSocketHotReload();
_logger.info('Hot reload completed successfully');
return _ReloadReportWithMetadata(success: true);
} on NoClientsAvailableException catch (e) {
// Throw RPC error with kIsolateCannotReload code when no browser clients are
// connected.
throw vm_service.RPCError(
'reloadSources',
vm_service.RPCErrorKind.kIsolateCannotReload.code,
'Hot reload failed: ${e.message}',
);
} catch (e) {
_logger.warning('Hot reload failed: $e');
return _ReloadReportWithMetadata(success: false, notices: [e.toString()]);
}
}
/// Handles hot restart requests.
Future<Map<String, Object?>> hotRestart() async {
_logger.info('Attempting a hot restart');
try {
await _performWebSocketHotRestart();
_logger.info('Hot restart completed successfully');
return {'result': vm_service.Success().toJson()};
} on NoClientsAvailableException catch (e) {
// Throw RPC error with kIsolateCannotReload code when no browser clients are
// connected.
throw vm_service.RPCError(
'hotRestart',
vm_service.RPCErrorKind.kIsolateCannotReload.code,
'Hot restart failed: ${e.message}',
);
} catch (e) {
_logger.warning('Hot restart failed: $e');
return {
'error': {
'code': vm_service.RPCErrorKind.kInternalError.code,
'message': 'Hot restart failed: $e',
},
};
}
}
/// Completes hot reload with response from client.
@override
void completeHotReload(HotReloadResponse response) {
final tracker = _pendingHotReloads[response.id];
if (tracker == null) {
_logger.warning(
'Received hot reload response but no pending tracker found (id: ${response.id})',
);
return;
}
tracker.addResponse(response);
if (tracker.gotAllResponses) {
_pendingHotReloads.remove(response.id);
tracker.dispose();
if (tracker.allSuccessful) {
tracker.completer.complete(response);
} else {
final failedResponses = tracker.responses.where((r) => !r.success);
final errorMessages = failedResponses
.map((r) => r.errorMessage ?? 'Unknown error')
.join('; ');
tracker.completer.completeError(
'Hot reload failed in some clients: $errorMessages',
);
}
}
}
/// Completes hot restart with response from client.
@override
void completeHotRestart(HotRestartResponse response) {
final tracker = _pendingHotRestarts[response.id];
if (tracker == null) {
_logger.warning(
'Received hot restart response but no pending tracker found (id: ${response.id})',
);
return;
}
tracker.addResponse(response);
if (tracker.gotAllResponses) {
_pendingHotRestarts.remove(response.id);
tracker.dispose();
if (tracker.allSuccessful) {
tracker.completer.complete(response);
} else {
final failedResponses = tracker.responses.where((r) => !r.success);
final errorMessages = failedResponses
.map((r) => r.errorMessage ?? 'Unknown error')
.join('; ');
tracker.completer.completeError(
'Hot restart failed in some clients: $errorMessages',
);
}
}
}
/// Performs WebSocket-based hot reload.
Future<void> _performWebSocketHotReload({String? requestId}) async {
final id = requestId ?? createId();
// Check if there's already a pending hot reload with this ID
if (_pendingHotReloads.containsKey(id)) {
throw StateError('Hot reload already pending for ID: $id');
}
const timeout = Duration(seconds: 10);
_logger.info('Sending HotReloadRequest with ID ($id) to client');
// Send the request and get the number of connected clients
final clientCount = await Future.microtask(() {
return sendClientRequest(HotReloadRequest((b) => b.id = id));
});
if (clientCount == 0) {
_logger.warning(kNoClientsAvailable);
throw NoClientsAvailableException._(kNoClientsAvailable);
}
// Create tracker for this hot reload request
final completer = Completer<HotReloadResponse>();
final timeoutTimer = Timer(timeout, () {
final tracker = _pendingHotReloads.remove(id);
if (tracker != null) {
tracker.dispose();
if (!completer.isCompleted) {
completer.completeError(
TimeoutException(
'Hot reload timed out - received ${tracker.responses.length}/$clientCount responses',
timeout,
),
);
}
}
});
final tracker = _HotReloadTracker(
requestId: id,
completer: completer,
expectedResponses: clientCount,
timeoutTimer: timeoutTimer,
);
_pendingHotReloads[id] = tracker;
try {
final response = await completer.future;
if (!response.success) {
throw Exception(response.errorMessage ?? 'Client reported failure');
}
} catch (e) {
// Clean up tracker if still present
final remainingTracker = _pendingHotReloads.remove(id);
remainingTracker?.dispose();
rethrow;
}
}
/// Performs WebSocket-based hot restart.
Future<void> _performWebSocketHotRestart({String? requestId}) async {
final id = requestId ?? createId();
// Check if there's already a pending hot restart with this ID
if (_pendingHotRestarts.containsKey(id)) {
throw StateError('Hot restart already pending for ID: $id');
}
const timeout = Duration(seconds: 15);
_logger.info('Sending HotRestartRequest with ID ($id) to client');
// Send the request and get the number of connected clients
final clientCount = await Future.microtask(() {
return sendClientRequest(HotRestartRequest((b) => b.id = id));
});
if (clientCount == 0) {
_logger.warning(kNoClientsAvailable);
throw NoClientsAvailableException._(kNoClientsAvailable);
}
// Create tracker for this hot restart request
final completer = Completer<HotRestartResponse>();
final timeoutTimer = Timer(timeout, () {
final tracker = _pendingHotRestarts.remove(id);
if (tracker != null) {
tracker.dispose();
if (!completer.isCompleted) {
completer.completeError(
TimeoutException(
'Hot restart timed out - received ${tracker.responses.length}/$clientCount responses',
timeout,
),
);
}
}
});
final tracker = _HotRestartTracker(
requestId: id,
completer: completer,
expectedResponses: clientCount,
timeoutTimer: timeoutTimer,
);
_pendingHotRestarts[id] = tracker;
try {
final response = await completer.future;
if (!response.success) {
throw Exception(response.errorMessage ?? 'Client reported failure');
}
} catch (e) {
// Clean up tracker if still present
final remainingTracker = _pendingHotRestarts.remove(id);
remainingTracker?.dispose();
rethrow;
}
}
@override
Future<Response> callServiceExtension(
String method, {
String? isolateId,
Map? args,
}) => wrapInErrorHandlerAsync(
'callServiceExtension',
() => _callServiceExtension(method, args: args),
);
/// Calls a service extension on the client.
Future<Response> _callServiceExtension(String method, {Map? args}) async {
final requestId = createId();
// Check if there's already a pending service extension with this ID
if (_pendingServiceExtensionTrackers.containsKey(requestId)) {
throw StateError(
'Service extension call already pending for ID: $requestId',
);
}
args ??= <String, String>{};
final request = ServiceExtensionRequest.fromArgs(
id: requestId,
method: method,
// Arguments must be converted to their string representation, otherwise
// we'll encounter a TypeError when trying to cast args to a
// Map<String, String> in the service extension handler.
args: args.map<String, String>(
(k, v) => MapEntry(
k is String ? k : jsonEncode(k),
v is String ? v : jsonEncode(v),
),
),
);
// Send the request and get the number of connected clients
final clientCount = sendClientRequest(request);
if (clientCount == 0) {
throw StateError('No clients available for service extension');
}
// Create tracker for this service extension request
const timeout = Duration(seconds: 10);
final completer = Completer<ServiceExtensionResponse>();
final timeoutTimer = Timer(timeout, () {
final tracker = _pendingServiceExtensionTrackers.remove(requestId);
if (tracker != null) {
tracker.dispose();
if (!completer.isCompleted) {
completer.completeError(
TimeoutException(
'Service extension $method timed out - received ${tracker.responses.length}/$clientCount responses',
timeout,
),
);
}
}
});
final tracker = _ServiceExtensionTracker(
requestId: requestId,
completer: completer,
expectedResponses: clientCount,
timeoutTimer: timeoutTimer,
);
_pendingServiceExtensionTrackers[requestId] = tracker;
try {
final response = await completer.future;
if (response.errorMessage != null) {
throw RPCError(
method,
response.errorCode ?? RPCErrorKind.kServerError.code,
response.errorMessage!,
);
}
return Response()..json = response.result;
} catch (e) {
// Clean up tracker if still present
final remainingTracker = _pendingServiceExtensionTrackers.remove(
requestId,
);
remainingTracker?.dispose();
rethrow;
}
}
/// Completes service extension with response.
@override
void completeServiceExtension(ServiceExtensionResponse response) {
final id = response.id;
final tracker = _pendingServiceExtensionTrackers[id];
if (tracker == null) {
_logger.warning(
'No pending tracker found for service extension (id: $id)',
);
return;
}
tracker.addResponse(response);
if (tracker.gotAllResponses) {
_pendingServiceExtensionTrackers.remove(id);
tracker.dispose();
if (tracker.allSuccessful) {
tracker.completer.complete(response);
} else {
final failedResponses = tracker.responses.where((r) => !r.success);
final errorMessages = failedResponses
.map((r) => r.errorMessage ?? 'Unknown error')
.join('; ');
tracker.completer.completeError(
'Service extension failed in some clients: $errorMessages',
);
}
}
}
@override
Future<Success> setFlag(String name, String value) =>
wrapInErrorHandlerAsync('setFlag', () => _setFlag(name, value));
Future<Success> _setFlag(String name, String value) async {
if (!currentVmServiceFlags.containsKey(name)) {
return rpcNotSupportedFuture('setFlag');
}
assert(value == 'true' || value == 'false');
final oldValue = currentVmServiceFlags[name];
currentVmServiceFlags[name] = value == 'true';
// Handle pause_isolates_on_start flag changes
if (name == _pauseIsolatesOnStartFlag &&
value == 'true' &&
oldValue == false) {
// Send pause event for existing isolate if not already paused
if (isIsolateRunning && _currentPauseEvent == null) {
final pauseEvent = vm_service.Event(
kind: vm_service.EventKind.kPauseStart,
timestamp: DateTime.now().millisecondsSinceEpoch,
isolate: inspector.isolateRef,
);
_currentPauseEvent = pauseEvent;
streamNotify(vm_service.EventStreams.kDebug, pauseEvent);
}
}
return Success();
}
/// Pauses execution of the isolate.
@override
Future<Success> pause(String isolateId) =>
// Can't pause with the web socket implementation, so do nothing.
Future.value(Success());
/// Resumes execution of the isolate.
@override
Future<Success> resume(String isolateId, {String? step, int? frameIndex}) =>
wrapInErrorHandlerAsync('resume', () => _resume(isolateId));
Future<Success> _resume(String isolateId) async {
if (hasPendingRestart && !resumeAfterRestartEventsController.isClosed) {
resumeAfterRestartEventsController.add(isolateId);
} else {
if (!_mainHasStarted) {
try {
appConnection.runMain();
_mainHasStarted = true;
} catch (e) {
if (e.toString().contains('Main has already started')) {
_mainHasStarted = true;
} else {
rethrow;
}
}
}
}
// Clear pause state and send resume event to notify debugging tools
if (_currentPauseEvent != null) {
_currentPauseEvent = null;
final resumeEvent = vm_service.Event(
kind: vm_service.EventKind.kResume,
timestamp: DateTime.now().millisecondsSinceEpoch,
isolate: inspector.isolateRef,
);
streamNotify(vm_service.EventStreams.kDebug, resumeEvent);
}
return Success();
}
@override
Future<FlagList> getFlagList() =>
wrapInErrorHandlerAsync('getFlagList', _getFlagList);
Future<FlagList> _getFlagList() async {
// Return basic flag list for WebSocket mode
return FlagList(
flags: [
Flag(
name: _pauseIsolatesOnStartFlag,
comment: 'If enabled, isolates are paused on start',
valueAsString: pauseIsolatesOnStart.toString(),
),
],
);
}
@override
Future<vm_service.Stack> getStack(
String isolateId, {
String? idZoneId,
int? limit,
}) => wrapInErrorHandlerAsync('getStack', () => _getStack(isolateId));
Future<vm_service.Stack> _getStack(String isolateId) async {
if (!isIsolateRunning) {
throw vm_service.RPCError(
'getStack',
vm_service.RPCErrorKind.kInvalidParams.code,
'No running isolate found for id: $isolateId',
);
}
if (inspector.isolateRef.id != isolateId) {
throw vm_service.RPCError(
'getStack',
vm_service.RPCErrorKind.kInvalidParams.code,
'Isolate with id $isolateId not found.',
);
}
// Return empty stack since we're in WebSocket mode without Chrome debugging
return vm_service.Stack(
frames: [],
asyncCausalFrames: [],
awaiterFrames: [],
);
}
}
/// Extended ReloadReport that includes additional metadata in JSON output.
class _ReloadReportWithMetadata extends vm_service.ReloadReport {
final List<String>? notices;
_ReloadReportWithMetadata({super.success, this.notices});
@override
Map<String, dynamic> toJson() {
final jsonified = <String, Object?>{
'type': 'ReloadReport',
'success': success ?? false,
};
if (notices != null) {
jsonified['notices'] = notices!.map((e) => {'message': e}).toList();
}
return jsonified;
}
}