[dds] Add DAP support for 'hotReload' custom request that calls reloadSources

Change-Id: I413cd9ce8f72e97d14a3fb7762f98833a5991a1d
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/219793
Reviewed-by: Ben Konyi <bkonyi@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
diff --git a/pkg/dds/lib/src/dap/adapters/dart.dart b/pkg/dds/lib/src/dap/adapters/dart.dart
index 655d646..5f70e3b 100644
--- a/pkg/dds/lib/src/dap/adapters/dart.dart
+++ b/pkg/dds/lib/src/dap/adapters/dart.dart
@@ -53,6 +53,13 @@
 /// Typedef for handlers of VM Service stream events.
 typedef _StreamEventHandler<T> = FutureOr<void> Function(T data);
 
+/// A null result passed to `sendResponse` functions when there is no result.
+///
+/// Because the signature of `sendResponse` is generic, an argument must be
+/// provided even when the generic type is `void`. This value is used to make
+/// it clearer in calling code that the result is unused.
+const _noResult = null;
+
 /// Pattern for extracting useful error messages from an evaluation exception.
 final _evalErrorMessagePattern = RegExp('Error: (.*)');
 
@@ -661,26 +668,26 @@
   ) async {
     switch (request.command) {
 
-      /// Used by tests to validate available protocols (e.g. DDS). There may be
-      /// value in making this available to clients in future, but for now it's
-      /// internal.
+      // Used by tests to validate available protocols (e.g. DDS). There may be
+      // value in making this available to clients in future, but for now it's
+      // internal.
       case '_getSupportedProtocols':
         final protocols = await vmService?.getSupportedProtocols();
         sendResponse(protocols?.toJson());
         break;
 
-      /// Used to toggle debug settings such as whether SDK/Packages are
-      /// debuggable while the session is in progress.
+      // Used to toggle debug settings such as whether SDK/Packages are
+      // debuggable while the session is in progress.
       case 'updateDebugOptions':
         if (args != null) {
           await _updateDebugOptions(args.args);
         }
-        sendResponse(null);
+        sendResponse(_noResult);
         break;
 
-      /// Allows an editor to call a service/service extension that it was told
-      /// about via a custom 'dart.serviceRegistered' or
-      /// 'dart.serviceExtensionAdded' event.
+      // Allows an editor to call a service/service extension that it was told
+      // about via a custom 'dart.serviceRegistered' or
+      // 'dart.serviceExtensionAdded' event.
       case 'callService':
         final method = args?.args['method'] as String?;
         if (method == null) {
@@ -696,6 +703,15 @@
         sendResponse(response?.json);
         break;
 
+      // Used to reload sources for all isolates. This supports Hot Reload for
+      // Dart apps. Flutter's DAP handles this command itself (and sends it
+      // through the run daemon) as it needs to perform additional work to
+      // rebuild widgets afterwards.
+      case 'hotReload':
+        await _isolateManager.reloadSources();
+        sendResponse(_noResult);
+        break;
+
       default:
         await super.customRequest(request, args, sendResponse);
     }
diff --git a/pkg/dds/lib/src/dap/base_debug_adapter.dart b/pkg/dds/lib/src/dap/base_debug_adapter.dart
index 2d82bbd..215a975 100644
--- a/pkg/dds/lib/src/dap/base_debug_adapter.dart
+++ b/pkg/dds/lib/src/dap/base_debug_adapter.dart
@@ -376,7 +376,7 @@
   /// passes an unused arg so that `Function()` can be passed to a function
   /// accepting `Function<T>(T x)` where `T` happens to be `void`.
   ///
-  /// This allows handlers to simple call sendResponse() where they have no
+  /// This allows handlers to simply call sendResponse() where they have no
   /// return value but need to send a valid response.
   _VoidArgRequestHandler<TArg> _withVoidResponse<TArg>(
     _VoidNoArgRequestHandler<TArg> handler,
diff --git a/pkg/dds/lib/src/dap/isolate_manager.dart b/pkg/dds/lib/src/dap/isolate_manager.dart
index fd2d342..47a8dde 100644
--- a/pkg/dds/lib/src/dap/isolate_manager.dart
+++ b/pkg/dds/lib/src/dap/isolate_manager.dart
@@ -136,10 +136,7 @@
     vm.Event event, {
     bool resumeIfStarting = true,
   }) async {
-    final isolateId = event.isolate?.id;
-    if (isolateId == null) {
-      return;
-    }
+    final isolateId = event.isolate?.id!;
 
     final eventKind = event.kind;
     if (eventKind == vm.EventKind.kIsolateStart ||
@@ -203,12 +200,16 @@
     return info;
   }
 
+  /// Calls reloadSources for all isolates.
+  Future<void> reloadSources() async {
+    await Future.wait(_threadsByThreadId.values.map(
+      (isolate) => _reloadSources(isolate.isolate),
+    ));
+  }
+
   Future<void> resumeIsolate(vm.IsolateRef isolateRef,
       [String? resumeType]) async {
-    final isolateId = isolateRef.id;
-    if (isolateId == null) {
-      return;
-    }
+    final isolateId = isolateRef.id!;
 
     final thread = _threadsByIsolateId[isolateId];
     if (thread == null) {
@@ -527,6 +528,18 @@
     }
   }
 
+  /// Calls reloadSources for the given isolate.
+  Future<void> _reloadSources(vm.IsolateRef isolateRef) async {
+    final service = _adapter.vmService;
+    if (!debug || service == null) {
+      return;
+    }
+
+    final isolateId = isolateRef.id!;
+
+    await service.reloadSources(isolateId);
+  }
+
   /// Sets breakpoints for an individual isolate.
   ///
   /// If [uri] is provided, only breakpoints for that URI will be sent (used
@@ -592,10 +605,7 @@
       return;
     }
 
-    final isolateId = isolateRef.id;
-    if (isolateId == null) {
-      return;
-    }
+    final isolateId = isolateRef.id!;
 
     final isolate = await service.getIsolate(isolateId);
     final libraries = isolate.libraries;
diff --git a/pkg/dds/test/dap/integration/debug_test.dart b/pkg/dds/test/dap/integration/debug_test.dart
index 12f17b4..d6df8e6 100644
--- a/pkg/dds/test/dap/integration/debug_test.dart
+++ b/pkg/dds/test/dap/integration/debug_test.dart
@@ -173,6 +173,34 @@
       unawaited(dap.client.event('thread').then((_) => dap.client.terminate()));
       await dap.client.start(file: testFile);
     });
+
+    test('can hot reload', () async {
+      const originalText = 'ORIGINAL TEXT';
+      const newText = 'NEW TEXT';
+
+      // Create a script that prints 'ORIGINAL TEXT'.
+      final testFile = dap.createTestFile(stringPrintingProgram(originalText));
+
+      // Start the program and wait for 'ORIGINAL TEXT' to be printed.
+      await Future.wait([
+        dap.client.initialize(),
+        dap.client.launch(testFile.path),
+      ], eagerError: true);
+
+      // Expect the original text.
+      await dap.client.outputEvents
+          .firstWhere((event) => event.output.trim() == originalText);
+
+      // Update the file and hot reload.
+      testFile.writeAsStringSync(stringPrintingProgram(newText));
+      await dap.client.hotReload();
+
+      // Expect the new text.
+      await dap.client.outputEvents
+          .firstWhere((event) => event.output.trim() == newText);
+
+      await dap.client.terminate();
+    });
     // These tests can be slow due to starting up the external server process.
   }, timeout: Timeout.none);
 
diff --git a/pkg/dds/test/dap/integration/test_client.dart b/pkg/dds/test/dap/integration/test_client.dart
index af40d1c..55f21c0 100644
--- a/pkg/dds/test/dap/integration/test_client.dart
+++ b/pkg/dds/test/dap/integration/test_client.dart
@@ -148,7 +148,7 @@
       sendRequest(ContinueArguments(threadId: threadId));
 
   /// Sends a custom request to the server and waits for a response.
-  Future<Response> custom(String name, Object? args) async {
+  Future<Response> custom(String name, [Object? args]) async {
     return sendRequest(args, overrideCommand: name);
   }
 
@@ -191,6 +191,11 @@
     _serverRequestHandlers[request] = handler;
   }
 
+  /// Send a custom 'hotReload' request to the server.
+  Future<Response> hotReload() async {
+    return custom('hotReload');
+  }
+
   /// Send an initialize request to the server.
   ///
   /// This occurs before the request to start running/debugging a script and is
diff --git a/pkg/dds/test/dap/integration/test_scripts.dart b/pkg/dds/test/dap/integration/test_scripts.dart
index 2b24727..59bc718 100644
--- a/pkg/dds/test/dap/integration/test_scripts.dart
+++ b/pkg/dds/test/dap/integration/test_scripts.dart
@@ -2,6 +2,8 @@
 // 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:convert';
+
 import 'package:test/test.dart';
 
 /// A marker used in some test scripts/tests for where to set breakpoints.
@@ -53,6 +55,24 @@
   }
 ''';
 
+/// Returns a simple Dart script that prints the provided string repeatedly.
+String stringPrintingProgram(String text) {
+  // jsonEncode the string to get it into a quoted/escaped form that can be
+  // embedded in the string.
+  final encodedTextString = jsonEncode(text);
+  return '''
+  import 'dart:async';
+
+  main() async {
+    Timer.periodic(Duration(milliseconds: 10), (_) => printSomething());
+  }
+
+  void printSomething() {
+    print($encodedTextString);
+  }
+''';
+}
+
 /// A simple async Dart script that when stopped at the line of '// BREAKPOINT'
 /// will contain multiple stack frames across some async boundaries.
 const simpleAsyncProgram = '''
diff --git a/pkg/dds/tool/dap/README.md b/pkg/dds/tool/dap/README.md
index a65f707..ac86324 100644
--- a/pkg/dds/tool/dap/README.md
+++ b/pkg/dds/tool/dap/README.md
@@ -74,6 +74,17 @@
 }
 ```
 
+### `hotReload`
+
+`hotReload` calls the VM's `reloadSources` service for each active isolate, reloading all modified source files.
+
+```
+{
+	"method": "hotReload",
+	"params": null
+}
+```
+
 ## Custom Events
 
 The debug adapter may emit several custom events that are useful to clients.