Wrap VM service API methods in an error handler to convert any exceptions into type `RPCError` (#2144)

Fixes https://github.com/dart-lang/webdev/issues/2140, work towards https://github.com/flutter/flutter/issues/126362
diff --git a/dwds/CHANGELOG.md b/dwds/CHANGELOG.md
index e47b955..4472831 100644
--- a/dwds/CHANGELOG.md
+++ b/dwds/CHANGELOG.md
@@ -6,7 +6,8 @@
 - Display type objects concisely. - [#2103](https://github.com/dart-lang/webdev/pull/2103)
 - Support using scope in `ChromeProxyService.evaluateInFrame`. - [#2122](https://github.com/dart-lang/webdev/pull/2122)
 - Check for new events more often in batched stream. - [#2123](https://github.com/dart-lang/webdev/pull/2123)
-- Map Chrome error on `resume` to the same error returned by the Dart VM. - [#2133](https://github.com/dart-lang/webdev/issues/2133) 
+- Map Chrome error on `resume` to the same error returned by the Dart VM. - [#2133](https://github.com/dart-lang/webdev/issues/2133)
+- VM service API methods throw type `RPCError`, same as the Dart VM. - [#2144](https://github.com/dart-lang/webdev/pull/2144)
 
 ## 19.0.0
 
diff --git a/dwds/lib/src/services/chrome_proxy_service.dart b/dwds/lib/src/services/chrome_proxy_service.dart
index 2d57576..f023049 100644
--- a/dwds/lib/src/services/chrome_proxy_service.dart
+++ b/dwds/lib/src/services/chrome_proxy_service.dart
@@ -383,6 +383,18 @@
     int line, {
     int? column,
   }) async {
+    return wrapInErrorHandlerAsync(
+      'addBreakpoint',
+      () => _addBreakpoint(isolateId, scriptId, line),
+    );
+  }
+
+  Future<Breakpoint> _addBreakpoint(
+    String isolateId,
+    String scriptId,
+    int line, {
+    int? column,
+  }) async {
     await isInitialized;
     _checkIsolate('addBreakpoint', isolateId);
     return (await debuggerFuture).addBreakpoint(scriptId, line, column: column);
@@ -399,6 +411,22 @@
     String scriptUri,
     int line, {
     int? column,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'addBreakpointWithScriptUri',
+        () => _addBreakpointWithScriptUri(
+          isolateId,
+          scriptUri,
+          line,
+          column: column,
+        ),
+      );
+
+  Future<Breakpoint> _addBreakpointWithScriptUri(
+    String isolateId,
+    String scriptUri,
+    int line, {
+    int? column,
   }) async {
     await isInitialized;
     _checkIsolate('addBreakpointWithScriptUri', isolateId);
@@ -431,6 +459,20 @@
     String method, {
     String? isolateId,
     Map? args,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'callServiceExtension',
+        () => _callServiceExtension(
+          method,
+          isolateId: isolateId,
+          args: args,
+        ),
+      );
+
+  Future<Response> _callServiceExtension(
+    String method, {
+    String? isolateId,
+    Map? args,
   }) async {
     await isInitialized;
     isolateId ??= _inspector?.isolate.id;
@@ -539,6 +581,24 @@
     String expression, {
     Map<String, String>? scope,
     bool? disableBreakpoints,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'evaluate',
+        () => _evaluate(
+          isolateId,
+          targetId,
+          expression,
+          scope: scope,
+          disableBreakpoints: disableBreakpoints,
+        ),
+      );
+
+  Future<Response> _evaluate(
+    String isolateId,
+    String targetId,
+    String expression, {
+    Map<String, String>? scope,
+    bool? disableBreakpoints,
   }) {
     // TODO(798) - respect disableBreakpoints.
     return captureElapsedTime(
@@ -578,6 +638,24 @@
     String expression, {
     Map<String, String>? scope,
     bool? disableBreakpoints,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'evaluateInFrame',
+        () => _evaluateInFrame(
+          isolateId,
+          frameIndex,
+          expression,
+          scope: scope,
+          disableBreakpoints: disableBreakpoints,
+        ),
+      );
+
+  Future<Response> _evaluateInFrame(
+    String isolateId,
+    int frameIndex,
+    String expression, {
+    Map<String, String>? scope,
+    bool? disableBreakpoints,
   }) {
     // TODO(798) - respect disableBreakpoints.
 
@@ -643,7 +721,12 @@
   }
 
   @override
-  Future<Isolate> getIsolate(String isolateId) {
+  Future<Isolate> getIsolate(String isolateId) => wrapInErrorHandlerAsync(
+        'getIsolate',
+        () => _getIsolate(isolateId),
+      );
+
+  Future<Isolate> _getIsolate(String isolateId) {
     return captureElapsedTime(
       () async {
         await isInitialized;
@@ -655,7 +738,13 @@
   }
 
   @override
-  Future<MemoryUsage> getMemoryUsage(String isolateId) async {
+  Future<MemoryUsage> getMemoryUsage(String isolateId) =>
+      wrapInErrorHandlerAsync(
+        'getMemoryUsage',
+        () => _getMemoryUsage(isolateId),
+      );
+
+  Future<MemoryUsage> _getMemoryUsage(String isolateId) async {
     await isInitialized;
     _checkIsolate('getMemoryUsage', isolateId);
     return inspector.getMemoryUsage();
@@ -667,6 +756,22 @@
     String objectId, {
     int? offset,
     int? count,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'getObject',
+        () => _getObject(
+          isolateId,
+          objectId,
+          offset: offset,
+          count: count,
+        ),
+      );
+
+  Future<Obj> _getObject(
+    String isolateId,
+    String objectId, {
+    int? offset,
+    int? count,
   }) async {
     await isInitialized;
     _checkIsolate('getObject', isolateId);
@@ -674,7 +779,12 @@
   }
 
   @override
-  Future<ScriptList> getScripts(String isolateId) {
+  Future<ScriptList> getScripts(String isolateId) => wrapInErrorHandlerAsync(
+        'getScripts',
+        () => _getScripts(isolateId),
+      );
+
+  Future<ScriptList> _getScripts(String isolateId) {
     return captureElapsedTime(
       () async {
         await isInitialized;
@@ -695,6 +805,30 @@
     bool? forceCompile,
     bool? reportLines,
     List<String>? libraryFilters,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'getSourceReport',
+        () => _getSourceReport(
+          isolateId,
+          reports,
+          scriptId: scriptId,
+          tokenPos: tokenPos,
+          endTokenPos: endTokenPos,
+          forceCompile: forceCompile,
+          reportLines: reportLines,
+          libraryFilters: libraryFilters,
+        ),
+      );
+
+  Future<SourceReport> _getSourceReport(
+    String isolateId,
+    List<String> reports, {
+    String? scriptId,
+    int? tokenPos,
+    int? endTokenPos,
+    bool? forceCompile,
+    bool? reportLines,
+    List<String>? libraryFilters,
   }) {
     return captureElapsedTime(
       () async {
@@ -720,7 +854,13 @@
   ///
   /// The returned stack will contain up to [limit] frames if provided.
   @override
-  Future<Stack> getStack(String isolateId, {int? limit}) async {
+  Future<Stack> getStack(String isolateId, {int? limit}) =>
+      wrapInErrorHandlerAsync(
+        'getStack',
+        () => _getStack(isolateId, limit: limit),
+      );
+
+  Future<Stack> _getStack(String isolateId, {int? limit}) async {
     await isInitialized;
     await isStarted;
     _checkIsolate('getStack', isolateId);
@@ -728,7 +868,9 @@
   }
 
   @override
-  Future<VM> getVM() {
+  Future<VM> getVM() => wrapInErrorHandlerAsync('getVM', _getVM);
+
+  Future<VM> _getVM() {
     return captureElapsedTime(
       () async {
         await isInitialized;
@@ -752,7 +894,10 @@
   }
 
   @override
-  Future<Version> getVersion() async {
+  Future<Version> getVersion() =>
+      wrapInErrorHandlerAsync('getVersion', _getVersion);
+
+  Future<Version> _getVersion() async {
     final version = semver.Version.parse(vmServiceVersion);
     return Version(major: version.major, minor: version.minor);
   }
@@ -764,6 +909,24 @@
     String selector,
     List argumentIds, {
     bool? disableBreakpoints,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'invoke',
+        () => _invoke(
+          isolateId,
+          targetId,
+          selector,
+          argumentIds,
+          disableBreakpoints: disableBreakpoints,
+        ),
+      );
+
+  Future<Response> _invoke(
+    String isolateId,
+    String targetId,
+    String selector,
+    List argumentIds, {
+    bool? disableBreakpoints,
   }) async {
     await isInitialized;
     _checkIsolate('invoke', isolateId);
@@ -819,7 +982,10 @@
   }
 
   @override
-  Future<Success> pause(String isolateId) async {
+  Future<Success> pause(String isolateId) =>
+      wrapInErrorHandlerAsync('pause', () => _pause(isolateId));
+
+  Future<Success> _pause(String isolateId) async {
     await isInitialized;
     _checkIsolate('pause', isolateId);
     return (await debuggerFuture).pause();
@@ -832,6 +998,16 @@
     String isolateId,
     List<String> uris, {
     bool? local,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'lookupResolvedPackageUris',
+        () => _lookupResolvedPackageUris(isolateId, uris, local: local),
+      );
+
+  Future<UriList> _lookupResolvedPackageUris(
+    String isolateId,
+    List<String> uris, {
+    bool? local,
   }) async {
     await isInitialized;
     _checkIsolate('lookupResolvedPackageUris', isolateId);
@@ -839,7 +1015,16 @@
   }
 
   @override
-  Future<UriList> lookupPackageUris(String isolateId, List<String> uris) async {
+  Future<UriList> lookupPackageUris(String isolateId, List<String> uris) =>
+      wrapInErrorHandlerAsync(
+        'lookupPackageUris',
+        () => _lookupPackageUris(isolateId, uris),
+      );
+
+  Future<UriList> _lookupPackageUris(
+    String isolateId,
+    List<String> uris,
+  ) async {
     await isInitialized;
     _checkIsolate('lookupPackageUris', isolateId);
     return UriList(uris: uris.map(DartUri.toPackageUri).toList());
@@ -871,6 +1056,15 @@
   Future<Success> removeBreakpoint(
     String isolateId,
     String breakpointId,
+  ) =>
+      wrapInErrorHandlerAsync(
+        'removeBreakpoint',
+        () => _removeBreakpoint(isolateId, breakpointId),
+      );
+
+  Future<Success> _removeBreakpoint(
+    String isolateId,
+    String breakpointId,
   ) async {
     await isInitialized;
     _checkIsolate('removeBreakpoint', isolateId);
@@ -884,6 +1078,20 @@
     String isolateId, {
     String? step,
     int? frameIndex,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'resume',
+        () => _resume(
+          isolateId,
+          step: step,
+          frameIndex: frameIndex,
+        ),
+      );
+
+  Future<Success> _resume(
+    String isolateId, {
+    String? step,
+    int? frameIndex,
   }) async {
     try {
       if (inspector.appConnection.isStarted) {
@@ -933,6 +1141,20 @@
     String isolateId, {
     String? exceptionPauseMode,
     bool? shouldPauseOnExit,
+  }) =>
+      wrapInErrorHandlerAsync(
+        'setIsolatePauseMode',
+        () => _setIsolatePauseMode(
+          isolateId,
+          exceptionPauseMode: exceptionPauseMode,
+          shouldPauseOnExit: shouldPauseOnExit,
+        ),
+      );
+
+  Future<Success> _setIsolatePauseMode(
+    String isolateId, {
+    String? exceptionPauseMode,
+    bool? shouldPauseOnExit,
   }) async {
     // TODO(elliette): Is there a way to respect the shouldPauseOnExit parameter
     // in Chrome?
@@ -957,7 +1179,13 @@
   }
 
   @override
-  Future<Success> setName(String isolateId, String name) async {
+  Future<Success> setName(String isolateId, String name) =>
+      wrapInErrorHandlerAsync(
+        'setName',
+        () => _setName(isolateId, name),
+      );
+
+  Future<Success> _setName(String isolateId, String name) async {
     await isInitialized;
     _checkIsolate('setName', isolateId);
     inspector.isolate.name = name;
@@ -965,7 +1193,10 @@
   }
 
   @override
-  Future<Success> setVMName(String name) async {
+  Future<Success> setVMName(String name) =>
+      wrapInErrorHandlerAsync('setVMName', () => _setVMName(name));
+
+  Future<Success> _setVMName(String name) async {
     _vm.name = name;
     _streamNotify(
       'VM',
@@ -992,7 +1223,10 @@
   }
 
   @override
-  Future<Success> streamListen(String streamId) async {
+  Future<Success> streamListen(String streamId) =>
+      wrapInErrorHandlerAsync('streamListen', () => _streamListen(streamId));
+
+  Future<Success> _streamListen(String streamId) async {
     // TODO: This should return an error if the stream is already being listened
     // to.
     onEvent(streamId);
@@ -1247,7 +1481,12 @@
   }
 
   @override
-  Future<ProtocolList> getSupportedProtocols() async {
+  Future<ProtocolList> getSupportedProtocols() => wrapInErrorHandlerAsync(
+        'getSupportedProtocols',
+        _getSupportedProtocols,
+      );
+
+  Future<ProtocolList> _getSupportedProtocols() async {
     final version = semver.Version.parse(vmServiceVersion);
     return ProtocolList(
       protocols: [
diff --git a/dwds/lib/src/utilities/shared.dart b/dwds/lib/src/utilities/shared.dart
index df7e342..16a79f0 100644
--- a/dwds/lib/src/utilities/shared.dart
+++ b/dwds/lib/src/utilities/shared.dart
@@ -25,3 +25,26 @@
       _logger.warning('Error in unawaited Future:', error, stackTrace);
   unawaited(future.catchError(onError));
 }
+
+/// Throws an [RPCError] if the [asyncCallback] has an exception.
+///
+/// Only throws a new exception if the original exception type was not
+/// [RPCError] or [SentinelException] (the two supported exception types of
+/// package:vm_service).
+Future<T> wrapInErrorHandlerAsync<T>(
+  String command,
+  Future<T> Function() asyncCallback,
+) {
+  return asyncCallback().catchError(
+    (error) {
+      return Future<T>.error(
+        RPCError(
+          command,
+          RPCErrorKind.kInternalError.code,
+          'Unexpected DWDS error for $command: $error',
+        ),
+      );
+    },
+    test: (e) => e is! RPCError && e is! SentinelException,
+  );
+}
diff --git a/dwds/test/utilities_test.dart b/dwds/test/utilities_test.dart
new file mode 100644
index 0000000..4da7176
--- /dev/null
+++ b/dwds/test/utilities_test.dart
@@ -0,0 +1,79 @@
+// Copyright 2023 The Dart Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+@Timeout(Duration(minutes: 1))
+import 'package:dwds/src/utilities/shared.dart';
+import 'package:test/test.dart';
+import 'package:vm_service/vm_service.dart';
+
+import 'fixtures/context.dart';
+
+void main() {
+  group('wrapInErrorHandlerAsync', () {
+    test('returns future success value if callback succeeds', () async {
+      Future<bool> successCallback() async {
+        await Future.delayed(Duration(milliseconds: 500));
+        return true;
+      }
+
+      final result =
+          await wrapInErrorHandlerAsync('successCallback', successCallback);
+      expect(result, equals(true));
+    });
+
+    test('throws RPCError if callback throws RPCError', () async {
+      Future<bool> rpcErrorCallback() async {
+        await Future.delayed(Duration(milliseconds: 500));
+        throw RPCError(
+          'rpcErrorCallback',
+          RPCErrorKind.kInvalidRequest.code,
+          'An error message',
+        );
+      }
+
+      await expectLater(
+        wrapInErrorHandlerAsync('rpcErrorCallback', rpcErrorCallback),
+        throwsRPCErrorWithMessage('An error message'),
+      );
+    });
+
+    test('throws SentinelException if callback throws SentinelException',
+        () async {
+      Future<bool> sentinelExceptionCallback() async {
+        await Future.delayed(Duration(milliseconds: 500));
+        throw SentinelException.parse(
+          'sentinelExceptionCallback',
+          {'message': 'a sentinel exception'},
+        );
+      }
+
+      await expectLater(
+        wrapInErrorHandlerAsync(
+          'sentinelExceptionCallback',
+          sentinelExceptionCallback,
+        ),
+        throwsSentinelException,
+      );
+    });
+
+    test('throws RPCError if callback throws other error type', () async {
+      Future<bool> exceptionCallback() async {
+        await Future.delayed(Duration(milliseconds: 500));
+        throw Exception('An unexpected exception');
+      }
+
+      try {
+        await wrapInErrorHandlerAsync('exceptionCallback', exceptionCallback);
+        fail("RPCError not thrown.");
+      } catch (error) {
+        expect(
+          error,
+          isRPCErrorWithMessage(
+            'Unexpected DWDS error for exceptionCallback: Exception: An unexpected exception',
+          ),
+        );
+      }
+    });
+  });
+}