[dds/dap] Use ID Zones for evaluation requests and invalidate them on resume

Fixes https://github.com/dart-lang/sdk/issues/56794

Change-Id: Ied408503d22c4abb2c4f996f6c9b7847b8ad82be
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/389960
Reviewed-by: Derek Xu <derekx@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
Reviewed-by: Ben Konyi <bkonyi@google.com>
diff --git a/pkg/dds/CHANGELOG.md b/pkg/dds/CHANGELOG.md
index d5443de..c2ad2e3 100644
--- a/pkg/dds/CHANGELOG.md
+++ b/pkg/dds/CHANGELOG.md
@@ -3,6 +3,11 @@
 - Updated the `devtools_shared` dependency to version `^11.0.0`.
 - Made `runDartDevelopmentServiceFromCLI` pass the specified bind address
   directly into `startDartDevelopmentService` without resolving the address.
+- [DAP] Evaluations now use Service ID Zones to more precisely control the
+  lifetime of instance references returned. This should avoid instances being
+  collected while execution is paused, while releasing them once execution
+  resumes.
+- Updated `vm_service` constraint to ^14.3.0.
 
 # 4.2.7
 - Added a new constant `RpcErrorCodes.kConnectionDisposed = -32010` for requests
diff --git a/pkg/dds/lib/src/dap/adapters/dart.dart b/pkg/dds/lib/src/dap/adapters/dart.dart
index 191f507..8c6b66a 100644
--- a/pkg/dds/lib/src/dap/adapters/dart.dart
+++ b/pkg/dds/lib/src/dap/adapters/dart.dart
@@ -956,6 +956,22 @@
         sendResponse(_noResult);
         break;
 
+      // Used by tests to force a GC for a given DAP threadId.
+      case '_collectAllGarbage':
+        final threadId = args?.args['threadId'] as int;
+        final isolateId = isolateManager.getThread(threadId)?.isolate.id;
+        // Trigger the GC.
+        if (isolateId != null) {
+          await vmService?.callMethod(
+            '_collectAllGarbage',
+            isolateId: isolateId,
+          );
+        }
+
+        // Respond to the incoming request.
+        sendResponse(_noResult);
+        break;
+
       default:
         await super.customRequest(request, args, sendResponse);
     }
@@ -1088,11 +1104,10 @@
           thread,
         );
       } else if (thread != null && frameIndex != null) {
-        result = await vmService?.evaluateInFrame(
-          thread.isolate.id!,
+        result = await vmEvaluateInFrame(
+          thread,
           frameIndex,
           expression,
-          disableBreakpoints: true,
         );
       } else if (targetScriptFileUri != null &&
           // Since we can't currently get a thread, we assume the first thread is
@@ -1105,12 +1120,7 @@
           throw 'Unable to find the library for $targetScriptFileUri';
         }
 
-        result = await vmService?.evaluate(
-          thread.isolate.id!,
-          library.id!,
-          expression,
-          disableBreakpoints: true,
-        );
+        result = await vmEvaluate(thread, library.id!, expression);
       }
     } catch (e) {
       final rawMessage = '$e';
@@ -2384,11 +2394,54 @@
     final expressionWithoutExceptionExpression =
         expression.substring(threadExceptionExpression.length + 1);
 
-    return vmService?.evaluate(
-      thread.isolate.id!,
+    return vmEvaluate(
+      thread,
       exception.id!,
       expressionWithoutExceptionExpression,
-      disableBreakpoints: true,
+    );
+  }
+
+  /// Sends a VM 'evaluate' request for [thread].
+  Future<vm.Response?> vmEvaluate(
+    ThreadInfo thread,
+    String targetId,
+    String expression, {
+    bool? disableBreakpoints = true,
+  }) async {
+    final isolateId = thread.isolate.id!;
+    final futureOrEvalZoneId = thread.currentEvaluationZoneId;
+    final evalZoneId = futureOrEvalZoneId is String
+        ? futureOrEvalZoneId
+        : await futureOrEvalZoneId;
+
+    return vmService?.evaluate(
+      isolateId,
+      targetId,
+      expression,
+      disableBreakpoints: disableBreakpoints,
+      idZoneId: evalZoneId,
+    );
+  }
+
+  /// Sends a VM 'evaluateInFrame' request for [thread].
+  Future<vm.Response?> vmEvaluateInFrame(
+    ThreadInfo thread,
+    int frameIndex,
+    String expression, {
+    bool? disableBreakpoints = true,
+  }) async {
+    final isolateId = thread.isolate.id!;
+    final futureOrEvalZoneId = thread.currentEvaluationZoneId;
+    final evalZoneId = futureOrEvalZoneId is String
+        ? futureOrEvalZoneId
+        : await futureOrEvalZoneId;
+
+    return vmService?.evaluateInFrame(
+      isolateId,
+      frameIndex,
+      expression,
+      disableBreakpoints: disableBreakpoints,
+      idZoneId: evalZoneId,
     );
   }
 
diff --git a/pkg/dds/lib/src/dap/isolate_manager.dart b/pkg/dds/lib/src/dap/isolate_manager.dart
index 8f77086..28989f6 100644
--- a/pkg/dds/lib/src/dap/isolate_manager.dart
+++ b/pkg/dds/lib/src/dap/isolate_manager.dart
@@ -356,7 +356,7 @@
 
     // Finally, when we're resuming, all stored objects become invalid and
     // we can drop them to save memory.
-    thread.clearStoredData();
+    await thread.clearTemporaryData();
 
     thread.hasPendingUserResume = true;
     try {
@@ -400,13 +400,14 @@
       }
     }
 
-    // When we're resuming, all stored objects become invalid and we can drop
-    // to save memory.
-    thread.clearStoredData();
-
+    final isolateId = thread.isolate.id!;
     try {
-      thread.hasPendingDapResume = true;
-      await _adapter.vmService?.readyToResume(thread.isolate.id!);
+      // When we're resuming, all stored objects become invalid and we can drop
+      // to save memory.
+      await thread.clearTemporaryData();
+
+      // Finally, signal that we're ready to resume.
+      await _adapter.vmService?.readyToResume(isolateId);
     } on UnimplementedError {
       // Fallback to a regular resume if the DDS version doesn't support
       // `readyToResume`:
@@ -426,8 +427,6 @@
       } else {
         rethrow;
       }
-    } finally {
-      thread.hasPendingDapResume = false;
     }
   }
 
@@ -570,12 +569,7 @@
     String type,
   ) async {
     try {
-      final result = await _adapter.vmService?.evaluateInFrame(
-        thread.isolate.id!,
-        0,
-        expression,
-        disableBreakpoints: true,
-      );
+      final result = await _adapter.vmEvaluateInFrame(thread, 0, expression);
 
       if (result is vm.InstanceRef) {
         return result;
@@ -1118,6 +1112,32 @@
   var atAsyncSuspension = false;
   int? exceptionReference;
 
+  /// A [Completer] that completes with the evaluation zone ID for this thread.
+  ///
+  /// The completer is created when the request to create an evaluation zone is
+  /// started (which is lazy, the first time evaluation is performed).
+  ///
+  /// When the Debug Adapter is ready to resume this Isolate, it will first
+  /// invalidate all evaluation IDs in this zone so that they can be collected.
+  /// If the [Completer] is null, no evaluation has occurred and invalidation
+  /// can be skipped.
+  Completer<String?>? _currentEvaluationZoneIdCompleter;
+
+  /// Returns the current evaluation zone ID.
+  ///
+  /// To avoid additional 'await's, may return a String? directly if the value
+  /// is already available.
+  FutureOr<String?> get currentEvaluationZoneId {
+    // We already have the value, avoid the Future.
+    if (_currentEvaluationZoneId != null) {
+      return _currentEvaluationZoneId;
+    }
+    return _createOrGetEvaluationZoneId();
+  }
+
+  /// The current evaluation zone ID (if available).
+  String? _currentEvaluationZoneId;
+
   /// Whether this thread is currently known to be paused in the VM.
   ///
   /// Because requests are async, this is not guaranteed to be always correct
@@ -1165,10 +1185,6 @@
   /// has not yet been responded to.
   var hasPendingUserResume = false;
 
-  /// Whether this isolate has an in-flight DAP (readyToResume) resume request
-  /// that has not yet been responded to.
-  var hasPendingDapResume = false;
-
   ThreadInfo(this._manager, this.threadId, this.isolate);
 
   Future<T> getObject<T extends vm.Response>(vm.ObjRef ref) =>
@@ -1188,6 +1204,42 @@
     return _manager.getScripts(isolate);
   }
 
+  /// Returns the evaluation zone ID for this thread.
+  ///
+  /// If it has not been created yet, creates it. If creation is in progress,
+  /// returns the existing future.
+  Future<String?> _createOrGetEvaluationZoneId() async {
+    // If we already have a completer, the request is already in flight (or
+    // has completed).
+    var completer = _currentEvaluationZoneIdCompleter;
+    if (completer != null) {
+      return completer.future;
+    }
+
+    // Otherwise, we need to start the request.
+    _currentEvaluationZoneIdCompleter = completer = Completer();
+
+    try {
+      final response = await _manager._adapter.vmService?.createIdZone(
+        isolate.id!,
+        vm.IdZoneBackingBufferKind.kRing,
+        vm.IdAssignmentPolicy.kAlwaysAllocate,
+        // Default capacity is 512. Since these are short-lived (only while
+        // paused) and we don't want to prevent expanding Lists, use something a
+        // little bigger.
+        capacity: 2048,
+      );
+      _currentEvaluationZoneId = response?.id;
+    } catch (_) {
+      // If this request fails for any reason (perhaps the target VM does not
+      // support this request), we should just use `null` as the zone ID and not
+      // prevent any evaluation requests.
+      _currentEvaluationZoneId = null;
+    }
+    completer.complete(_currentEvaluationZoneId);
+    return _currentEvaluationZoneId;
+  }
+
   /// Resolves a source file path (or URI) into a URI for the VM.
   ///
   /// sdk-path/lib/core/print.dart -> dart:core/print.dart
@@ -1489,11 +1541,32 @@
         pathSegments: fileLikeUri.pathSegments.sublist(0, keepSegments));
   }
 
-  /// Clears all data stored for this thread.
+  /// Clears all temporary stored for this thread. This includes:
   ///
-  /// References to stored data become invalid when the thread is resumed.
-  void clearStoredData() {
+  /// - dropping any variablesReferences
+  /// - invalidating the evaluation ID zone
+  ///
+  /// This is generally called when requesting execution continues, since any
+  /// evaluated references are not expected to live past this point.
+  ///
+  /// https://microsoft.github.io/debug-adapter-protocol/overview#lifetime-of-objects-references
+  Future<void> clearTemporaryData() async {
+    // Clear variablesReferences.
     _manager.clearStoredData(this);
+
+    // Invalidate all existing references in this evaluation zone.
+    // If the completer is null, no zone has ever been created (or started to
+    // be created), so this can be skipped.
+    if (_currentEvaluationZoneIdCompleter != null) {
+      final futureOrEvalZoneId = currentEvaluationZoneId;
+      final evalZoneId = futureOrEvalZoneId is String
+          ? futureOrEvalZoneId
+          : await futureOrEvalZoneId;
+      if (evalZoneId != null) {
+        await _manager._adapter.vmService
+            ?.invalidateIdZone(isolate.id!, evalZoneId);
+      }
+    }
   }
 
   /// Attempts to get a [vm.LibraryRef] for the given [scriptFileUri].
diff --git a/pkg/dds/lib/src/dap/protocol_converter.dart b/pkg/dds/lib/src/dap/protocol_converter.dart
index 120f307..53cfc25 100644
--- a/pkg/dds/lib/src/dap/protocol_converter.dart
+++ b/pkg/dds/lib/src/dap/protocol_converter.dart
@@ -335,8 +335,8 @@
     required bool allowCallingToString,
     required VariableFormat? format,
   }) async {
-    final response = await service.evaluate(
-      thread.isolate.id!,
+    final response = await _adapter.vmEvaluate(
+      thread,
       instance.id!,
       getterName,
     );
diff --git a/pkg/dds/pubspec.yaml b/pkg/dds/pubspec.yaml
index e1fb7b4..da45485 100644
--- a/pkg/dds/pubspec.yaml
+++ b/pkg/dds/pubspec.yaml
@@ -29,7 +29,7 @@
   sse: ^4.0.0
   stack_trace: ^1.10.0
   stream_channel: ^2.0.0
-  vm_service: ^14.0.0
+  vm_service: ^14.3.0
   web_socket_channel: '>=2.0.0 <4.0.0'
 
 # We use 'any' version constraints here as we get our package versions from
diff --git a/pkg/dds/test/dap/integration/debug_breakpoints_test.dart b/pkg/dds/test/dap/integration/debug_breakpoints_test.dart
index cbd8e7f..f5e578c 100644
--- a/pkg/dds/test/dap/integration/debug_breakpoints_test.dart
+++ b/pkg/dds/test/dap/integration/debug_breakpoints_test.dart
@@ -606,7 +606,7 @@
       final thread1 = stop1.threadId!;
 
       // Attach a second debug adapter to it.
-      final dap2 = await DapTestSession.setUp();
+      final dap2 = await DapTestSession.setUp(logPrefix: '(CLIENT2) ');
       final client2 = dap2.client;
       await Future.wait([
         // We'll still get event for existing pause.
diff --git a/pkg/dds/test/dap/integration/debug_eval_test.dart b/pkg/dds/test/dap/integration/debug_eval_test.dart
index 6328880..6cd9dc6 100644
--- a/pkg/dds/test/dap/integration/debug_eval_test.dart
+++ b/pkg/dds/test/dap/integration/debug_eval_test.dart
@@ -284,6 +284,182 @@
       );
     });
 
+    test('variableReferences remain valid while an isolate is paused',
+        () async {
+      final client = dap.client;
+      final testFile =
+          dap.createTestFile(simpleBreakpointProgramWith50ExtraLines);
+      final breakpointLine = lineWith(testFile, breakpointMarker);
+      final stop = await client.hitBreakpoint(testFile, breakpointLine);
+
+      // We're paused at a breakpoint. Our evaluate results should continue to
+      // work even after GCs.
+      final threadId = stop.threadId!;
+      final topFrameId = await client.getTopFrameId(threadId);
+
+      // Evaluate something and get back a variablesReference.
+      final result = await client.expectEvalResult(
+        topFrameId,
+        'DateTime(2000, 1, 1)',
+        'DateTime',
+      );
+
+      // Ensure it remains valid even after GCs. This ensures both DAP preserves
+      // the variablesReference, and also that the VM preserved the instance
+      // reference.
+      for (var i = 0; i < 5; i++) {
+        await client.expectVariables(
+          result.variablesReference,
+          'isUtc: false, eval: DateTime(2000, 1, 1).isUtc',
+        );
+        // Force GC.
+        await client.forceGc(threadId);
+      }
+    });
+
+    test('variableReferences become invalid after a resume', () async {
+      final client = dap.client;
+      final testFile =
+          dap.createTestFile(simpleBreakpointProgramWith50ExtraLines);
+      final breakpointLine = lineWith(testFile, breakpointMarker);
+      final stop = await client.hitBreakpoint(
+        testFile,
+        breakpointLine,
+        additionalBreakpoints: [breakpointLine + 1],
+      );
+
+      // We're paused at a breakpoint. Our evaluate results should only continue
+      // to work until we resume.
+      final threadId = stop.threadId!;
+      final topFrameId = await client.getTopFrameId(threadId);
+
+      // Evaluate something and get back a variablesReference.
+      final evalResult = await client.expectEvalResult(
+        topFrameId,
+        'DateTime(2000, 1, 1)',
+        'DateTime',
+      );
+
+      // Resume, which should invalidate the variablesReference.
+      await client.continue_(threadId);
+
+      // Verify the reference is no longer valid. This is because we clear
+      // the threads variable data on resume, and not because of VM Service
+      // Zone IDs. We have no way to validate the VM behaviour because we
+      // don't have the reference (it is abstracted from the DAP client) to
+      // verify.
+      expectResponseError(
+        client.variables(evalResult.variablesReference),
+        equals('Bad state: variablesReference is no longer valid'),
+      );
+    });
+
+    test('variableReferences become invalid after a step', () async {
+      final client = dap.client;
+      final testFile =
+          dap.createTestFile(simpleBreakpointProgramWith50ExtraLines);
+      final breakpointLine = lineWith(testFile, breakpointMarker);
+      final stop = await client.hitBreakpoint(testFile, breakpointLine);
+
+      // We're paused at a breakpoint. Our evaluate results should only continue
+      // to work until we resume.
+      final threadId = stop.threadId!;
+      final topFrameId = await client.getTopFrameId(threadId);
+
+      // Evaluate something and get back a variablesReference.
+      final evalResult = await client.expectEvalResult(
+        topFrameId,
+        'DateTime(2000, 1, 1)',
+        'DateTime',
+      );
+
+      // Step, which should also invalidate because we're basically unpausing
+      // and then pausing again.
+      await client.next(threadId);
+
+      // Verify the reference is no longer valid. This is because we clear
+      // the threads variable data on resume/step, and not because of VM Service
+      // Zone IDs. We have no way to validate the VM behaviour because we
+      // don't have the reference (it is abstracted from the DAP client) to
+      // verify.
+      expectResponseError(
+        client.variables(evalResult.variablesReference),
+        equals('Bad state: variablesReference is no longer valid'),
+      );
+    });
+
+    test('evaluation service zones are invalidated on resume', () async {
+      final client = dap.client;
+      final testFile = dap.createTestFile(simpleBreakpointProgram);
+      final breakpointLine = lineWith(testFile, breakpointMarker);
+      final stop = await client.hitBreakpoint(testFile, breakpointLine);
+
+      // Enable logging and capture any requests to the VM that are calling
+      // invalidateIdZone.
+      final loggedEvaluateInFrameRequests = client
+          .events('dart.log')
+          .map((event) => event.body as Map<String, Object?>)
+          .map((body) => body['message'] as String)
+          .where((message) => message.contains('"method":"invalidateIdZone"'))
+          .toList();
+      await client.custom('updateSendLogsToClient', {'enabled': true});
+
+      // Trigger an evaluation because evaluation zones are created lazily
+      // and we won't invalidate anything if we haven't created one.
+      await client.evaluate('0', frameId: stop.threadId!);
+
+      // Resume and wait for the app to terminate.
+      await Future.wait([
+        client.continue_(stop.threadId!),
+        client.event('terminated'),
+      ]);
+
+      // Verify that invalidate had been called.
+      expect(
+        await loggedEvaluateInFrameRequests,
+        isNotEmpty,
+      );
+    });
+
+    test('evaluation service zones are invalidated on step', () async {
+      final client = dap.client;
+      final testFile = dap.createTestFile(simpleBreakpointProgram);
+      final breakpointLine = lineWith(testFile, breakpointMarker);
+      final stop = await client.hitBreakpoint(testFile, breakpointLine);
+
+      // Enable logging and capture any requests to the VM that are calling
+      // invalidateIdZone.
+      final loggedEvaluateInFrameRequests = client
+          .events('dart.log')
+          .map((event) => event.body as Map<String, Object?>)
+          .map((body) => body['message'] as String)
+          .where((message) => message.contains('"method":"invalidateIdZone"'))
+          .toList();
+      await client.custom('updateSendLogsToClient', {'enabled': true});
+
+      // Trigger an evaluation because evaluation zones are created lazily
+      // and we won't invalidate anything if we haven't created one.
+      await client.evaluate('0', frameId: stop.threadId!);
+
+      // Step, which should also invalidate because we're basically unpausing
+      // and then pausing again.
+      await client.next(stop.threadId!);
+
+      // Then disable logging (so we don't get false positives from shutdown)
+      // and terminate.
+      await client.custom('updateSendLogsToClient', {'enabled': false});
+      await Future.wait([
+        client.terminate(),
+        client.event('terminated'),
+      ]);
+
+      // Verify that invalidate had been called.
+      expect(
+        await loggedEvaluateInFrameRequests,
+        isNotEmpty,
+      );
+    });
+
     group('global evaluation', () {
       test('can evaluate in a bin/ file when not paused given a bin/ URI',
           () async {
diff --git a/pkg/dds/test/dap/integration/debug_logging_test.dart b/pkg/dds/test/dap/integration/debug_logging_test.dart
index 5e20f23..7643689 100644
--- a/pkg/dds/test/dap/integration/debug_logging_test.dart
+++ b/pkg/dds/test/dap/integration/debug_logging_test.dart
@@ -75,7 +75,6 @@
         client.terminate(),
         client.event('terminated'),
       ]);
-      print(await loggedEvaluateInFrameRequests);
 
       // Verify that the middle evaluation was logged but the others were not.
       expect(
diff --git a/pkg/dds/test/dap/integration/test_client.dart b/pkg/dds/test/dap/integration/test_client.dart
index 9e743b6..59a540b 100644
--- a/pkg/dds/test/dap/integration/test_client.dart
+++ b/pkg/dds/test/dap/integration/test_client.dart
@@ -1170,6 +1170,17 @@
         response.body as Map<String, Object?>);
   }
 
+  /// A helper that calls the private `_collectAllGarbage` custom request to
+  /// force a GC.
+  Future<void> forceGc(int threadId) async {
+    await sendRequest(
+      {
+        'threadId': threadId,
+      },
+      overrideCommand: '_collectAllGarbage',
+    );
+  }
+
   /// A helper that verifies the variables list matches [expectedVariables].
   ///
   /// [expectedVariables] is a simple text format of `name: value` for each
diff --git a/pkg/dds/test/dap/integration/test_support.dart b/pkg/dds/test/dap/integration/test_support.dart
index 0634566..4b34242 100644
--- a/pkg/dds/test/dap/integration/test_support.dart
+++ b/pkg/dds/test/dap/integration/test_support.dart
@@ -306,12 +306,17 @@
   static Future<DapTestSession> setUp({
     List<String>? additionalArgs,
     bool forceVerboseLogging = false,
+    String? logPrefix = '',
   }) async {
+    final logger = forceVerboseLogging || verboseLogging
+        ? (s) => print('$logPrefix$s')
+        : null;
+
     final server = await startServer(additionalArgs: additionalArgs);
     final client = await DapTestClient.connect(
       server,
       captureVmServiceTraffic: forceVerboseLogging || verboseLogging,
-      logger: forceVerboseLogging || verboseLogging ? print : null,
+      logger: logger,
     );
     return DapTestSession._(server, client);
   }