| // 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 'package:dwds/src/config/tool_configuration.dart'; |
| import 'package:dwds/src/debugging/dart_scope.dart'; |
| import 'package:dwds/src/debugging/frame_computer.dart'; |
| import 'package:dwds/src/debugging/location.dart'; |
| import 'package:dwds/src/debugging/remote_debugger.dart'; |
| import 'package:dwds/src/debugging/skip_list.dart'; |
| import 'package:dwds/src/services/chrome_debug_exception.dart'; |
| import 'package:dwds/src/utilities/dart_uri.dart'; |
| import 'package:dwds/src/utilities/domain.dart'; |
| import 'package:dwds/src/utilities/objects.dart' show Property; |
| import 'package:dwds/src/utilities/server.dart'; |
| import 'package:dwds/src/utilities/shared.dart'; |
| import 'package:dwds/src/utilities/synchronized.dart'; |
| import 'package:logging/logging.dart'; |
| import 'package:vm_service/vm_service.dart'; |
| import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' |
| hide StackTrace; |
| |
| /// Adds [event] to the stream with [streamId] if there is anybody listening |
| /// on that stream. |
| typedef StreamNotify = void Function(String streamId, Event event); |
| |
| /// Converts from ExceptionPauseMode strings to [PauseState] enums. |
| /// |
| /// Values defined in: |
| /// https://chromedevtools.github.io/devtools-protocol/tot/Debugger#method-setPauseOnExceptions |
| const _pauseModePauseStates = { |
| 'none': PauseState.none, |
| 'all': PauseState.all, |
| 'unhandled': PauseState.uncaught, |
| }; |
| |
| class Debugger extends Domain { |
| static final logger = Logger('Debugger'); |
| |
| final RemoteDebugger _remoteDebugger; |
| |
| final StreamNotify _streamNotify; |
| final Locations _locations; |
| final SkipLists _skipLists; |
| final String _root; |
| |
| Debugger._( |
| this._remoteDebugger, |
| this._streamNotify, |
| this._locations, |
| this._skipLists, |
| this._root, |
| ) : _breakpoints = _Breakpoints( |
| locations: _locations, |
| remoteDebugger: _remoteDebugger, |
| root: _root, |
| ); |
| |
| /// The breakpoints we have set so far, indexable by either |
| /// Dart or JS ID. |
| final _Breakpoints _breakpoints; |
| |
| PauseState _pauseState = PauseState.none; |
| |
| // TODO(elliette): https://github.com/dart-lang/webdev/issues/1501 Re-enable |
| // after checking with Chrome team if there is a way to check if the Chrome |
| // DevTools is showing an overlay. Both cannot be shown at the same time: |
| // bool _pausedOverlayVisible = false; |
| |
| String get pauseState => _pauseModePauseStates.entries |
| .firstWhere((entry) => entry.value == _pauseState) |
| .key; |
| |
| /// The JS frames at the current paused location. |
| /// |
| /// The most important thing here is that frames are identified by |
| /// frameIndex in the Dart API, but by frame Id in Chrome, so we need |
| /// to keep the JS frames and their Ids around. |
| FrameComputer? stackComputer; |
| |
| bool _isStepping = false; |
| DartLocation? _previousSteppingLocation; |
| |
| void updateInspector(AppInspectorInterface appInspector) { |
| inspector = appInspector; |
| _breakpoints.inspector = appInspector; |
| } |
| |
| Future<Success> pause() async { |
| _isStepping = false; |
| final result = await _remoteDebugger.pause(); |
| handleErrorIfPresent(result); |
| return Success(); |
| } |
| |
| Future<Success> setExceptionPauseMode(String mode) async { |
| mode = mode.toLowerCase(); |
| final state = _pauseModePauseStates[mode]; |
| if (state == null) { |
| throwInvalidParam('setExceptionPauseMode', 'Unsupported mode: $mode'); |
| } else { |
| _pauseState = state; |
| } |
| await _remoteDebugger.setPauseOnExceptions(_pauseState); |
| return Success(); |
| } |
| |
| /// Resumes the debugger. |
| /// |
| /// Step parameter options: |
| /// https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md#resume |
| /// |
| /// If the step parameter is not provided, the program will resume regular |
| /// execution. |
| /// |
| /// If the step parameter is provided, it indicates what form of |
| /// single-stepping to use. |
| /// |
| /// Note that stepping will automatically continue until Chrome is paused at |
| /// a location for which we have source information. |
| Future<Success> resume({String? step, int? frameIndex}) async { |
| try { |
| if (frameIndex != null) { |
| throw ArgumentError('FrameIndex is currently unsupported.'); |
| } |
| WipResponse? result; |
| if (step != null) { |
| _isStepping = true; |
| switch (step) { |
| case 'Over': |
| result = await _remoteDebugger.stepOver(); |
| break; |
| case 'Out': |
| result = await _remoteDebugger.stepOut(); |
| break; |
| case 'Into': |
| result = await _remoteDebugger.stepInto(); |
| break; |
| default: |
| throwInvalidParam('resume', 'Unexpected value for step: $step'); |
| } |
| } else { |
| _isStepping = false; |
| _previousSteppingLocation = null; |
| result = await _remoteDebugger.resume(); |
| } |
| handleErrorIfPresent(result); |
| return Success(); |
| } on WipError catch (e) { |
| final errorMessage = e.message; |
| if (errorMessage != null && |
| errorMessage.contains('Can only perform operation while paused')) { |
| throw RPCError( |
| 'resume', |
| RPCErrorKind.kIsolateMustBePaused.code, |
| errorMessage, |
| ); |
| } |
| rethrow; |
| } |
| } |
| |
| /// Returns the current Dart stack for the paused debugger. |
| /// |
| /// Throws RPCError if the debugger is not paused. |
| /// |
| /// The returned stack will contain up to [limit] frames if provided. |
| Future<Stack> getStack({int? limit}) async { |
| if (stackComputer == null) { |
| throw RPCError( |
| 'getStack', |
| RPCErrorKind.kInternalError.code, |
| 'Cannot compute stack when application is not paused', |
| ); |
| } |
| |
| final frames = await stackComputer!.calculateFrames(limit: limit); |
| return Stack( |
| frames: frames, |
| messages: [], |
| truncated: limit != null && frames.length == limit, |
| ); |
| } |
| |
| static Future<Debugger> create( |
| RemoteDebugger remoteDebugger, |
| StreamNotify streamNotify, |
| Locations locations, |
| SkipLists skipLists, |
| String root, |
| ) async { |
| final debugger = Debugger._( |
| remoteDebugger, |
| streamNotify, |
| locations, |
| skipLists, |
| root, |
| ); |
| await debugger._initialize(); |
| return debugger; |
| } |
| |
| Future<void> _initialize() async { |
| // We must add a listener before enabling the debugger otherwise we will |
| // miss events. |
| // Allow a null debugger/connection for unit tests. |
| runZonedGuarded(() { |
| _remoteDebugger.onPaused.listen(_pauseHandler); |
| _remoteDebugger.onResumed.listen(_resumeHandler); |
| _remoteDebugger.onTargetCrashed.listen(_crashHandler); |
| }, (e, StackTrace s) { |
| logger.warning('Error handling Chrome event', e, s); |
| }); |
| |
| handleErrorIfPresent(await _remoteDebugger.enablePage()); |
| await _remoteDebugger.enable(); |
| |
| // Enable collecting information about async frames when paused. |
| handleErrorIfPresent( |
| await _remoteDebugger.sendCommand( |
| 'Debugger.setAsyncCallStackDepth', |
| params: { |
| 'maxDepth': 128, |
| }, |
| ), |
| ); |
| } |
| |
| /// Resumes the Isolate from start. |
| /// |
| /// The JS VM is technically not paused at the start of the Isolate so there |
| /// will not be a corresponding [DebuggerResumedEvent]. |
| void resumeFromStart() => _resumeHandler(null); |
| |
| /// Notify the debugger the [Isolate] is paused at the application start. |
| void notifyPausedAtStart() { |
| stackComputer = FrameComputer(this, []); |
| } |
| |
| /// Add a breakpoint at the given position. |
| /// |
| /// Note that line and column are Dart source locations and are one-based. |
| Future<Breakpoint> addBreakpoint( |
| String scriptId, |
| int line, { |
| int? column, |
| }) async { |
| column ??= 0; |
| final breakpoint = await _breakpoints.add(scriptId, line, column); |
| _notifyBreakpoint(breakpoint); |
| return breakpoint; |
| } |
| |
| Future<ScriptRef?> _updatedScriptRefFor(Breakpoint breakpoint) async { |
| final oldRef = (breakpoint.location as SourceLocation).script; |
| final uri = oldRef?.uri; |
| if (uri == null) return null; |
| final dartUri = DartUri(uri, _root); |
| return await inspector.scriptRefFor(dartUri.serverPath); |
| } |
| |
| Future<void> reestablishBreakpoints( |
| Set<Breakpoint> previousBreakpoints, |
| Set<Breakpoint> disabledBreakpoints, |
| ) async { |
| // Previous breakpoints were never removed from Chrome since we use |
| // `setBreakpointByUrl`. We simply need to update the references. |
| for (var breakpoint in previousBreakpoints) { |
| final dartBpId = breakpoint.id!; |
| final scriptRef = await _updatedScriptRefFor(breakpoint); |
| final scriptUri = scriptRef?.uri; |
| if (scriptRef != null && scriptUri != null) { |
| final jsBpId = _breakpoints.jsIdFor(dartBpId)!; |
| final updatedLocation = await _locations.locationForDart( |
| DartUri(scriptUri, _root), |
| _lineNumberFor(breakpoint), |
| _columnNumberFor(breakpoint), |
| ); |
| if (updatedLocation != null) { |
| final updatedBreakpoint = _breakpoints._dartBreakpoint( |
| scriptRef, |
| updatedLocation, |
| dartBpId, |
| ); |
| _breakpoints._note(bp: updatedBreakpoint, jsId: jsBpId); |
| _notifyBreakpoint(updatedBreakpoint); |
| } else { |
| logger.warning('Cannot update breakpoint $dartBpId:' |
| ' cannot update location.'); |
| } |
| } else { |
| logger.warning('Cannot update breakpoint $dartBpId:' |
| ' cannot find script ref.'); |
| } |
| } |
| |
| // Disabled breakpoints were actually removed from Chrome so simply add |
| // them back. |
| for (var breakpoint in disabledBreakpoints) { |
| final scriptRef = await _updatedScriptRefFor(breakpoint); |
| final scriptId = scriptRef?.id; |
| if (scriptId != null) { |
| await addBreakpoint( |
| scriptId, |
| _lineNumberFor(breakpoint), |
| column: _columnNumberFor(breakpoint), |
| ); |
| } else { |
| logger.warning('Cannot update disabled breakpoint ${breakpoint.id}:' |
| ' cannot find script ref.'); |
| } |
| } |
| } |
| |
| void _notifyBreakpoint(Breakpoint breakpoint) { |
| final event = Event( |
| kind: EventKind.kBreakpointAdded, |
| timestamp: DateTime.now().millisecondsSinceEpoch, |
| isolate: inspector.isolateRef, |
| ); |
| event.breakpoint = breakpoint; |
| _streamNotify('Debug', event); |
| } |
| |
| /// Remove a Dart breakpoint. |
| Future<Success> removeBreakpoint(String breakpointId) async { |
| if (_breakpoints.breakpointFor(breakpointId) == null) { |
| throwInvalidParam( |
| 'removeBreakpoint', |
| 'invalid breakpoint id $breakpointId', |
| ); |
| } |
| final jsId = _breakpoints.jsIdFor(breakpointId); |
| if (jsId == null) { |
| throw RPCError( |
| 'removeBreakpoint', |
| RPCErrorKind.kInternalError.code, |
| 'invalid JS breakpoint id $jsId', |
| ); |
| } |
| await _removeBreakpoint(jsId); |
| |
| final bp = await _breakpoints.remove(jsId: jsId, dartId: breakpointId); |
| if (bp != null) { |
| _streamNotify( |
| 'Debug', |
| Event( |
| kind: EventKind.kBreakpointRemoved, |
| timestamp: DateTime.now().millisecondsSinceEpoch, |
| isolate: inspector.isolateRef, |
| )..breakpoint = bp, |
| ); |
| } |
| return Success(); |
| } |
| |
| /// Call the Chrome protocol removeBreakpoint. |
| Future<void> _removeBreakpoint(String breakpointId) async { |
| try { |
| final response = await _remoteDebugger.removeBreakpoint(breakpointId); |
| handleErrorIfPresent(response); |
| } on WipError catch (e) { |
| throw RPCError('removeBreakpoint', 102, '$e'); |
| } |
| } |
| |
| /// Returns Chrome script uri for Chrome script ID. |
| String? urlForScriptId(String scriptId) => |
| _remoteDebugger.scripts[scriptId]?.url; |
| |
| /// Returns source [Location] for the paused event. |
| /// |
| /// If we do not have [Location] data for the embedded JS location, null is |
| /// returned. |
| Future<Location?> _sourceLocation(DebuggerPausedEvent e) async { |
| final frame = e.params?['callFrames']?[0]; |
| final location = frame?['location']; |
| if (location == null) return null; |
| |
| final scriptId = location['scriptId'] as String?; |
| final line = location['lineNumber'] as int?; |
| if (scriptId == null || line == null) return null; |
| |
| final column = location['columnNumber'] as int?; |
| final url = urlForScriptId(scriptId); |
| if (url == null) return null; |
| |
| final loc = await _locations.locationForJs(url, line, column); |
| if (loc == null || loc.dartLocation == _previousSteppingLocation) { |
| return null; |
| } |
| _previousSteppingLocation = loc.dartLocation; |
| return loc; |
| } |
| |
| /// Returns script ID for the paused event. |
| String? _frameScriptId(DebuggerPausedEvent e) { |
| final frame = e.params?['callFrames']?[0]; |
| return frame?['location']?['scriptId'] as String?; |
| } |
| |
| /// The variables visible in a frame in Dart protocol [BoundVariable] form. |
| Future<List<BoundVariable>> variablesFor(WipCallFrame frame) async { |
| // TODO(alanknight): Can these be moved to dart_scope.dart? |
| final properties = |
| await visibleVariables(inspector: inspector, frame: frame); |
| final boundVariables = await Future.wait( |
| properties.map(_boundVariable), |
| ); |
| |
| // Filter out variables that do not come from dart code, such as native |
| // JavaScript objects |
| return boundVariables |
| .where((bv) => inspector.isDisplayableObject(bv?.value)) |
| .toList() |
| .cast(); |
| } |
| |
| Future<BoundVariable?> _boundVariable(Property property) async { |
| // TODO(annagrin): value might be null in the future for variables |
| // optimized by V8. Return appropriate sentinel values for them. |
| if (property.value != null) { |
| final value = property.value!; |
| // We return one level of properties from this object. Sub-properties are |
| // another round trip. |
| final instanceRef = await inspector.instanceRefFor(value); |
| // Skip null instance refs, which we get for weird objects, e.g. |
| // properties that are getter/setter pairs. |
| // TODO(alanknight): Handle these properly. |
| if (instanceRef == null) return null; |
| |
| return BoundVariable( |
| name: property.name, |
| value: instanceRef, |
| // TODO(grouma) - Provide actual token positions. |
| declarationTokenPos: -1, |
| scopeStartTokenPos: -1, |
| scopeEndTokenPos: -1, |
| ); |
| } |
| return null; |
| } |
| |
| // TODO(elliette): https://github.com/dart-lang/webdev/issues/1501 Re-enable |
| // after checking with Chrome team if there is a way to check if the Chrome |
| // DevTools is showing an overlay. Both cannot be shown at the same time: |
| |
| // Renders the paused at breakpoint overlay over the application. |
| // void _showPausedOverlay() async { |
| // if (_pausedOverlayVisible) return; |
| // handleErrorIfPresent(await _remoteDebugger?.sendCommand('DOM.enable')); |
| // handleErrorIfPresent(await _remoteDebugger?.sendCommand('Overlay.enable')); |
| // handleErrorIfPresent(await _remoteDebugger |
| // ?.sendCommand('Overlay.setPausedInDebuggerMessage', params: { |
| // 'message': 'Paused', |
| // })); |
| // _pausedOverlayVisible = true; |
| // } |
| |
| // Removes the paused at breakpoint overlay from the application. |
| // void _hidePausedOverlay() async { |
| // if (!_pausedOverlayVisible) return; |
| // handleErrorIfPresent(await _remoteDebugger?.sendCommand('Overlay.disable')); |
| // _pausedOverlayVisible = false; |
| // } |
| |
| /// Returns a Dart [Frame] for a JS [frame]. |
| Future<Frame?> calculateDartFrameFor( |
| WipCallFrame frame, |
| int frameIndex, { |
| bool populateVariables = true, |
| }) async { |
| final location = frame.location; |
| final line = location.lineNumber; |
| final column = location.columnNumber; |
| |
| final url = urlForScriptId(location.scriptId); |
| if (url == null) { |
| logger.fine('Failed to create dart frame for ${frame.functionName}: ' |
| 'cannot find url for script ${location.scriptId}'); |
| return null; |
| } |
| |
| final bestLocation = await _locations.locationForJs(url, line, column ?? 0); |
| if (bestLocation == null) return null; |
| |
| final script = |
| await inspector.scriptRefFor(bestLocation.dartLocation.uri.serverPath); |
| // We think we found a location, but for some reason we can't find the |
| // script. Just drop the frame. |
| // TODO(#700): Understand when this can happen and have a better fix. |
| if (script == null) return null; |
| |
| final functionName = _prettifyMember((frame.functionName).split('.').last); |
| final codeRefName = functionName.isEmpty ? '<closure>' : functionName; |
| |
| final dartFrame = Frame( |
| index: frameIndex, |
| code: CodeRef( |
| name: codeRefName, |
| kind: CodeKind.kDart, |
| id: createId(), |
| ), |
| location: SourceLocation( |
| line: bestLocation.dartLocation.line, |
| column: bestLocation.dartLocation.column, |
| tokenPos: bestLocation.tokenPos, |
| script: script, |
| ), |
| kind: FrameKind.kRegular, |
| ); |
| |
| // Don't populate variables for async frames. |
| if (populateVariables) { |
| dartFrame.vars = await variablesFor(frame); |
| } |
| |
| return dartFrame; |
| } |
| |
| /// Handles pause events coming from the Chrome connection. |
| Future<void> _pauseHandler(DebuggerPausedEvent e) async { |
| final isolate = inspector.isolate; |
| Event event; |
| final timestamp = DateTime.now().millisecondsSinceEpoch; |
| final jsBreakpointIds = e.hitBreakpoints ?? []; |
| if (jsBreakpointIds.isNotEmpty) { |
| final breakpointIds = jsBreakpointIds |
| .map((id) => _breakpoints._dartIdByJsId[id]) |
| // In case the breakpoint was set in Chrome DevTools outside of |
| // package:dwds. |
| .where((entry) => entry != null) |
| .toSet(); |
| final pauseBreakpoints = isolate.breakpoints |
| ?.where((bp) => breakpointIds.contains(bp.id)) |
| .toList(); |
| event = Event( |
| kind: EventKind.kPauseBreakpoint, |
| timestamp: timestamp, |
| isolate: inspector.isolateRef, |
| )..pauseBreakpoints = pauseBreakpoints; |
| } else if (e.reason == 'exception' || e.reason == 'assert') { |
| InstanceRef? exception; |
| |
| if (e.data is Map<String, dynamic>) { |
| final map = e.data as Map<String, dynamic>; |
| if (map['type'] == 'object') { |
| final obj = RemoteObject(map); |
| exception = await inspector.instanceRefFor(obj); |
| if (exception != null && inspector.isNativeJsError(exception)) { |
| if (obj.description != null) { |
| // Create a string exception object. |
| final description = |
| await inspector.mapExceptionStackTrace(obj.description!); |
| exception = await inspector.instanceRefFor(description); |
| } else { |
| exception = null; |
| } |
| } |
| } |
| } |
| |
| event = Event( |
| kind: EventKind.kPauseException, |
| timestamp: timestamp, |
| isolate: inspector.isolateRef, |
| exception: exception, |
| ); |
| } else { |
| // Continue stepping until we hit a dart location, |
| // avoiding stepping through library loading code. |
| if (_isStepping) { |
| final scriptId = _frameScriptId(e); |
| if (scriptId == null) { |
| logger.severe('Stepping failed: ' |
| 'cannot find script id for event $e'); |
| throw StateError('Stepping failed on event $e'); |
| } |
| final url = urlForScriptId(scriptId); |
| if (url == null) { |
| logger.severe('Stepping failed: ' |
| 'cannot find url for script $scriptId'); |
| throw StateError('Stepping failed in script $scriptId'); |
| } |
| |
| if (url.contains( |
| globalToolConfiguration.loadStrategy.loadLibrariesModule, |
| )) { |
| await _remoteDebugger.stepOut(); |
| return; |
| } else if ((await _sourceLocation(e)) == null) { |
| // TODO(grouma) - In the future we should send all previously computed |
| // skipLists. |
| await _remoteDebugger.stepInto( |
| params: { |
| 'skipList': _skipLists.compute( |
| scriptId, |
| await _locations.locationsForUrl(url), |
| ), |
| }, |
| ); |
| return; |
| } |
| } |
| event = Event( |
| kind: EventKind.kPauseInterrupted, |
| timestamp: timestamp, |
| isolate: inspector.isolateRef, |
| ); |
| } |
| |
| // Calculate the frames (and handle any exceptions that may occur). |
| stackComputer = FrameComputer( |
| this, |
| e.getCallFrames().toList(), |
| asyncStackTrace: e.asyncStackTrace, |
| ); |
| |
| try { |
| final frames = await stackComputer!.calculateFrames(limit: 1); |
| event.topFrame = frames.isNotEmpty ? frames.first : null; |
| } catch (e, s) { |
| // TODO: Return information about the error to the user. |
| logger.warning('Error calculating Dart frames', e, s); |
| } |
| |
| // TODO(elliette): https://github.com/dart-lang/webdev/issues/1501 Re-enable |
| // after checking with Chrome team if there is a way to check if the Chrome |
| // DevTools is showing an overlay. Both cannot be shown at the same time. |
| // _showPausedOverlay(); |
| isolate.pauseEvent = event; |
| _streamNotify('Debug', event); |
| } |
| |
| /// Handles resume events coming from the Chrome connection. |
| void _resumeHandler(DebuggerResumedEvent? _) { |
| // We can receive a resume event in the middle of a reload which will result |
| // in a null isolate. |
| final isolate = inspector.isolate; |
| |
| stackComputer = null; |
| final event = Event( |
| kind: EventKind.kResume, |
| timestamp: DateTime.now().millisecondsSinceEpoch, |
| isolate: inspector.isolateRef, |
| ); |
| |
| // TODO(elliette): https://github.com/dart-lang/webdev/issues/1501 Re-enable |
| // after checking with Chrome team if there is a way to check if the Chrome |
| // DevTools is showing an overlay. Both cannot be shown at the same time. |
| // _hidePausedOverlay(); |
| isolate.pauseEvent = event; |
| _streamNotify('Debug', event); |
| } |
| |
| /// Handles targetCrashed events coming from the Chrome connection. |
| void _crashHandler(TargetCrashedEvent _) { |
| // We can receive a resume event in the middle of a reload which will result |
| // in a null isolate. |
| final isolate = inspector.isolate; |
| |
| stackComputer = null; |
| final event = Event( |
| kind: EventKind.kIsolateExit, |
| timestamp: DateTime.now().millisecondsSinceEpoch, |
| isolate: inspector.isolateRef, |
| ); |
| isolate.pauseEvent = event; |
| _streamNotify('Isolate', event); |
| logger.severe('Target crashed!'); |
| } |
| |
| WipCallFrame? jsFrameForIndex(int frameIndex) { |
| final computer = stackComputer; |
| if (computer == null) { |
| throw RPCError( |
| 'evaluateInFrame', |
| 106, |
| 'Cannot evaluate on a call frame when the program is not paused', |
| ); |
| } |
| return computer.jsFrameForIndex(frameIndex); |
| } |
| |
| /// Evaluate [expression] by calling Chrome's Runtime.evaluateOnCallFrame on |
| /// the call frame with index [frameIndex] in the currently saved stack. |
| /// |
| /// If the program is not paused, so there is no current stack, throws a |
| /// [StateError]. |
| Future<RemoteObject> evaluateJsOnCallFrameIndex( |
| int frameIndex, |
| String expression, |
| ) { |
| final index = jsFrameForIndex(frameIndex)?.callFrameId; |
| if (index == null) { |
| // This might happen on async frames. |
| throw StateError('No frame for frame index: $index'); |
| } |
| return evaluateJsOnCallFrame(index, expression); |
| } |
| |
| /// Evaluate [expression] by calling Chrome's Runtime.evaluateOnCallFrame on |
| /// the call frame with id [callFrameId]. |
| Future<RemoteObject> evaluateJsOnCallFrame( |
| String callFrameId, |
| String expression, |
| ) async { |
| // TODO(alanknight): Support a version with arguments if needed. |
| try { |
| return await _remoteDebugger.evaluateOnCallFrame(callFrameId, expression); |
| } on ExceptionDetails catch (e) { |
| throw ChromeDebugException( |
| e.json, |
| evalContents: expression, |
| ); |
| } |
| } |
| } |
| |
| Future<T> sendCommandAndValidateResult<T>( |
| RemoteDebugger remoteDebugger, { |
| required String method, |
| required String resultField, |
| Map<String, dynamic>? params, |
| }) async { |
| final response = await remoteDebugger.sendCommand(method, params: params); |
| final result = response.result?[resultField]; |
| if (result == null) { |
| throw RPCError( |
| method, |
| RPCErrorKind.kInternalError.code, |
| '$resultField not found in result from sendCommand', |
| params, |
| ); |
| } |
| return result; |
| } |
| |
| /// Returns the Dart line number for the provided breakpoint. |
| int _lineNumberFor(Breakpoint breakpoint) => |
| int.parse(breakpoint.id!.split('#').last.split(':').first); |
| |
| /// Returns the Dart column number for the provided breakpoint. |
| int _columnNumberFor(Breakpoint breakpoint) => |
| int.parse(breakpoint.id!.split('#').last.split(':').last); |
| |
| /// Returns the breakpoint ID for the provided Dart script ID and Dart line |
| /// number. |
| String breakpointIdFor(String scriptId, int line, int column) => |
| 'bp/$scriptId#$line:$column'; |
| |
| /// Keeps track of the Dart and JS breakpoint Ids that correspond. |
| class _Breakpoints extends Domain { |
| final _logger = Logger('Breakpoints'); |
| final _dartIdByJsId = <String, String>{}; |
| final _jsIdByDartId = <String, String>{}; |
| |
| final _bpByDartId = <String, Future<Breakpoint>>{}; |
| |
| final _queue = AtomicQueue(); |
| |
| final Locations locations; |
| final RemoteDebugger remoteDebugger; |
| |
| /// The root URI from which the application is served. |
| final String root; |
| |
| _Breakpoints({ |
| required this.locations, |
| required this.remoteDebugger, |
| required this.root, |
| }); |
| |
| Future<Breakpoint> _createBreakpoint( |
| String id, |
| String scriptId, |
| int line, |
| int column, |
| ) async { |
| final dartScript = inspector.scriptWithId(scriptId); |
| final dartScriptUri = dartScript?.uri; |
| Location? location; |
| if (dartScriptUri != null) { |
| final dartUri = DartUri(dartScriptUri, root); |
| location = await locations.locationForDart(dartUri, line, column); |
| } |
| // TODO: Handle cases where a breakpoint can't be set exactly at that line. |
| if (location == null) { |
| _logger.fine('Failed to set breakpoint $id ' |
| '($scriptId:$line:$column): ' |
| 'cannot find Dart location.'); |
| throw RPCError( |
| 'addBreakpoint', |
| 102, |
| 'The VM is unable to add a breakpoint $id ' |
| 'at the specified line or function: ($scriptId:$line:$column): ' |
| ' cannot find Dart location.'); |
| } |
| |
| try { |
| final dartBreakpoint = _dartBreakpoint(dartScript!, location, id); |
| final jsBreakpointId = await _setJsBreakpoint(location); |
| if (jsBreakpointId == null) { |
| _logger.fine('Failed to set breakpoint $id ' |
| '($scriptId:$line:$column): ' |
| 'cannot set JS breakpoint.'); |
| throw RPCError( |
| 'addBreakpoint', |
| 102, |
| 'The VM is unable to add a breakpoint $id ' |
| 'at the specified line or function: ($scriptId:$line:$column): ' |
| 'cannot set JS breakpoint at $location'); |
| } |
| _note(jsId: jsBreakpointId, bp: dartBreakpoint); |
| return dartBreakpoint; |
| } on WipError catch (wipError) { |
| throw RPCError('addBreakpoint', 102, '$wipError'); |
| } |
| } |
| |
| /// Adds a breakpoint at [scriptId] and [line] or returns an existing one if |
| /// present. |
| Future<Breakpoint> add(String scriptId, int line, int column) { |
| final id = breakpointIdFor(scriptId, line, column); |
| return _bpByDartId.putIfAbsent( |
| id, |
| () => _createBreakpoint(id, scriptId, line, column), |
| ); |
| } |
| |
| /// Create a Dart breakpoint at [location] in [dartScript] with [id]. |
| Breakpoint _dartBreakpoint( |
| ScriptRef dartScript, |
| Location location, |
| String id, |
| ) { |
| final breakpoint = Breakpoint( |
| id: id, |
| breakpointNumber: int.parse(createId()), |
| resolved: true, |
| location: SourceLocation( |
| script: dartScript, |
| tokenPos: location.tokenPos, |
| line: location.dartLocation.line, |
| column: location.dartLocation.column, |
| ), |
| enabled: true, |
| )..id = id; |
| return breakpoint; |
| } |
| |
| /// Calls the Chrome protocol setBreakpoint and returns the remote ID. |
| Future<String?> _setJsBreakpoint(Location location) { |
| // The module can be loaded from a nested path and contain an ETAG suffix. |
| final urlRegex = '.*${location.jsLocation.module}.*'; |
| // Prevent `Aww, snap!` errors when setting multiple breakpoints |
| // simultaneously by serializing the requests. |
| return _queue.run(() async { |
| final breakPointId = await sendCommandAndValidateResult<String>( |
| remoteDebugger, |
| method: 'Debugger.setBreakpointByUrl', |
| resultField: 'breakpointId', |
| params: { |
| 'urlRegex': urlRegex, |
| 'lineNumber': location.jsLocation.line, |
| 'columnNumber': location.jsLocation.column, |
| }, |
| ); |
| return breakPointId; |
| }); |
| } |
| |
| /// Records the internal Dart <=> JS breakpoint id mapping and adds the |
| /// breakpoint to the current isolates list of breakpoints. |
| void _note({required Breakpoint bp, required String jsId}) { |
| final bpId = bp.id; |
| if (bpId != null) { |
| _dartIdByJsId[jsId] = bpId; |
| _jsIdByDartId[bpId] = jsId; |
| final isolate = inspector.isolate; |
| isolate.breakpoints?.add(bp); |
| } |
| } |
| |
| Future<Breakpoint?> remove({ |
| required String jsId, |
| required String dartId, |
| }) async { |
| final isolate = inspector.isolate; |
| _dartIdByJsId.remove(jsId); |
| _jsIdByDartId.remove(dartId); |
| isolate.breakpoints?.removeWhere((b) => b.id == dartId); |
| return await _bpByDartId.remove(dartId); |
| } |
| |
| Future<Breakpoint>? breakpointFor(String dartId) => _bpByDartId[dartId]; |
| String? jsIdFor(String dartId) => _jsIdByDartId[dartId]; |
| } |
| |
| final escapedPipe = '\$124'; |
| final escapedPound = '\$35'; |
| |
| /// Reformats a JS member name to make it look more Dart-like. |
| /// |
| /// Logic copied from build/build_web_compilers/web/stack_trace_mapper.dart. |
| /// TODO(https://github.com/dart-lang/sdk/issues/38869): Remove this logic when |
| /// DDC stack trace deobfuscation is overhauled. |
| String _prettifyMember(String member) { |
| member = member.replaceAll(escapedPipe, '|'); |
| if (member.contains('|')) { |
| return _prettifyExtension(member); |
| } else { |
| if (member.startsWith('[') && member.endsWith(']')) { |
| member = member.substring(1, member.length - 1); |
| } |
| return member; |
| } |
| } |
| |
| /// Reformats a JS member name as an extension method invocation. |
| String _prettifyExtension(String member) { |
| var isSetter = false; |
| final pipeIndex = member.indexOf('|'); |
| final spaceIndex = member.indexOf(' '); |
| final poundIndex = member.indexOf(escapedPound); |
| if (spaceIndex >= 0) { |
| // Here member is a static field or static getter/setter. |
| isSetter = member.substring(0, spaceIndex) == 'set'; |
| member = member.substring(spaceIndex + 1, member.length); |
| } else if (poundIndex >= 0) { |
| // Here member is a tear-off or local property getter/setter. |
| isSetter = member.substring(pipeIndex + 1, poundIndex) == 'set'; |
| member = member.replaceRange(pipeIndex + 1, poundIndex + 3, ''); |
| } else { |
| final body = member.substring(pipeIndex + 1, member.length); |
| if (body.startsWith('unary') || body.startsWith('\$')) { |
| // Here member's an operator, so it's safe to unescape everything lazily. |
| member = _unescape(member); |
| } |
| } |
| member = member.replaceAll('|', '.'); |
| return isSetter ? '$member=' : member; |
| } |
| |
| /// Un-escapes a DDC-escaped JS identifier name. |
| /// |
| /// Identifier names that contain illegal JS characters are escaped by DDC to a |
| /// decimal representation of the symbol's UTF-16 value. |
| /// Warning: this greedily escapes characters, so it can be unsafe in the event |
| /// that an escaped sequence precedes a number literal in the JS name. |
| String _unescape(String name) { |
| return name.replaceAllMapped( |
| RegExp(r'\$[0-9]+'), |
| (m) => String.fromCharCode(int.parse(name.substring(m.start + 1, m.end))), |
| ); |
| } |