[dds] Attach file/line/col metadata to DAP OutputEvents for detected call stacks

Change-Id: Ia91f40aaf244d892d2ff5ffb0e4a1e47f8ad9068
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/228040
Reviewed-by: Ben Konyi <bkonyi@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
diff --git a/pkg/dds/analysis_options.yaml b/pkg/dds/analysis_options.yaml
index 1f83658..6b1919a 100644
--- a/pkg/dds/analysis_options.yaml
+++ b/pkg/dds/analysis_options.yaml
@@ -5,3 +5,4 @@
     - depend_on_referenced_packages
     - directives_ordering
     - prefer_generic_function_type_aliases
+    - prefer_relative_imports
diff --git a/pkg/dds/lib/src/cpu_samples_manager.dart b/pkg/dds/lib/src/cpu_samples_manager.dart
index 14b5c22..aafb15d 100644
--- a/pkg/dds/lib/src/cpu_samples_manager.dart
+++ b/pkg/dds/lib/src/cpu_samples_manager.dart
@@ -2,9 +2,9 @@
 // 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 'package:dds/src/common/ring_buffer.dart';
 import 'package:vm_service/vm_service.dart';
 
+import 'common/ring_buffer.dart';
 import 'dds_impl.dart';
 
 /// Manages CPU sample caches for an individual [Isolate].
diff --git a/pkg/dds/lib/src/dap/adapters/dart.dart b/pkg/dds/lib/src/dap/adapters/dart.dart
index 5e1cda7..83c9d9d 100644
--- a/pkg/dds/lib/src/dap/adapters/dart.dart
+++ b/pkg/dds/lib/src/dap/adapters/dart.dart
@@ -21,6 +21,7 @@
 import '../protocol_converter.dart';
 import '../protocol_generated.dart';
 import '../protocol_stream.dart';
+import '../utils.dart';
 
 /// The mime type to send with source responses to the client.
 ///
@@ -382,6 +383,20 @@
   /// VM Service disconnects.
   bool isTerminating = false;
 
+  /// Whether isolates that pause in the PauseExit state should be automatically
+  /// resumed after any in-process log events have completed.
+  ///
+  /// Normally this will be true, but it may be set to false if the user
+  /// also manually passes pause-isolates-on-exit.
+  bool resumeIsolatesAfterPauseExit = true;
+
+  /// A [Future] that completes when the last queued OutputEvent has been sent.
+  ///
+  /// Calls to [SendOutput] will reserve their place in this queue and
+  /// subsequent calls will chain their own sends onto this (and replace it) to
+  /// preserve order.
+  Future? _lastOutputEvent;
+
   /// Removes any breakpoints or pause behaviour and resumes any paused
   /// isolates.
   ///
@@ -903,7 +918,11 @@
   }
 
   /// Sends a [TerminatedEvent] if one has not already been sent.
-  void handleSessionTerminate([String exitSuffix = '']) {
+  ///
+  /// Waits for any in-progress output events to complete first.
+  void handleSessionTerminate([String exitSuffix = '']) async {
+    await _waitForPendingOutputEvents();
+
     if (_hasSentTerminatedEvent) {
       return;
     }
@@ -911,8 +930,9 @@
     isTerminating = true;
     _hasSentTerminatedEvent = true;
     // Always add a leading newline since the last written text might not have
-    // had one.
-    sendOutput('console', '\nExited$exitSuffix.');
+    // had one. Send directly via sendEvent and not sendOutput to ensure no
+    // async since we're about to terminate.
+    sendEvent(OutputEventBody(output: '\nExited$exitSuffix.'));
     sendEvent(TerminatedEventBody());
   }
 
@@ -1100,9 +1120,32 @@
   }
 
   /// Sends an OutputEvent (without a newline, since calls to this method
-  /// may be used by buffered data).
-  void sendOutput(String category, String message) {
-    sendEvent(OutputEventBody(category: category, output: message));
+  /// may be using buffered data that is not split cleanly on newlines).
+  ///
+  /// If [category] is `stderr`, will also look for stack traces and extract
+  /// file/line information to add to the metadata of the event.
+  ///
+  /// To ensure output is sent to the client in the correct order even if
+  /// processing stack frames requires async calls, this function will insert
+  /// output events into a queue and only send them when previous calls have
+  /// been completed.
+  void sendOutput(String category, String message) async {
+    // Reserve our place in the queue be inserting a future that we can complete
+    // after we have sent the output event.
+    final completer = Completer<void>();
+    final _previousEvent = _lastOutputEvent ?? Future.value();
+    _lastOutputEvent = completer.future;
+
+    try {
+      final outputEvents = await _buildOutputEvents(category, message);
+
+      // Chain our sends onto the end of the previous one, and complete our Future
+      // once done so that the next one can go.
+      await _previousEvent;
+      outputEvents.forEach(sendEvent);
+    } finally {
+      completer.complete();
+    }
   }
 
   /// Sends an OutputEvent for [message], prefixed with [prefix] and with [message]
@@ -1577,6 +1620,108 @@
     return uri.replace(path: newPath);
   }
 
+  /// Creates one or more OutputEvents for the provided [message].
+  ///
+  /// Messages that contain stack traces may be split up into seperate events
+  /// for each frame to allow location metadata to be attached.
+  Future<List<OutputEventBody>> _buildOutputEvents(
+    String category,
+    String message,
+  ) async {
+    try {
+      if (category == 'stderr') {
+        return await _buildStdErrOutputEvents(message);
+      } else {
+        return [OutputEventBody(category: category, output: message)];
+      }
+    } catch (e, s) {
+      // Since callers of [sendOutput] may not await it, don't allow unhandled
+      // errors (for example if the VM Service quits while we were trying to
+      // map URIs), just log and return the event without metadata.
+      logger?.call('Failed to build OutputEvent: $e, $s');
+      return [OutputEventBody(category: category, output: message)];
+    }
+  }
+
+  /// Builds OutputEvents for stderr.
+  ///
+  /// If a stack trace can be parsed from [message], file/line information will
+  /// be included in the metadata of the event.
+  Future<List<OutputEventBody>> _buildStdErrOutputEvents(String message) async {
+    final events = <OutputEventBody>[];
+
+    // Extract all the URIs so we can send a batch request for resolving them.
+    final lines = message.split('\n');
+    final frames = lines.map(parseStackFrame).toList();
+    final uris = frames.whereNotNull().map((f) => f.uri).toList();
+
+    // We need an Isolate to resolve package URIs. Since we don't know what
+    // isolate printed an error to stderr, we just have to use the first one and
+    // hope the packages are available. If one is not available (which should
+    // never be the case), we will just skip resolution.
+    final thread = _isolateManager.threads.firstOrNull;
+
+    // Send a batch request. This will cache the results so we can easily use
+    // them in the loop below by calling the method again.
+    if (uris.isNotEmpty) {
+      try {
+        await thread?.resolveUrisToPathsBatch(uris);
+      } catch (e, s) {
+        // Ignore errors that may occur if the VM is shutting down before we got
+        // this request out. In most cases we will have pre-cached the results
+        // when the libraries were loaded (in order to check if they're user code)
+        // so it's likely this won't cause any issues (dart:isolate-patch is an
+        // exception seen that appears in the stack traces but was not previously
+        // seen/cached).
+        logger?.call('Failed to resolve URIs: $e\n$s');
+      }
+    }
+
+    // Convert any URIs to paths.
+    final paths = await Future.wait(frames.map((frame) async {
+      final uri = frame?.uri;
+      if (uri == null) return null;
+      if (uri.isScheme('file')) return uri.toFilePath();
+      if (isResolvableUri(uri)) {
+        try {
+          return await thread?.resolveUriToPath(uri);
+        } catch (e, s) {
+          // Swallow errors for the same reason noted above.
+          logger?.call('Failed to resolve URIs: $e\n$s');
+        }
+      }
+      return null;
+    }));
+
+    for (var i = 0; i < lines.length; i++) {
+      final line = lines[i];
+      final frame = frames[i];
+      final uri = frame?.uri;
+      final path = paths[i];
+      // For the name, we usually use the package URI, but if we only ended up
+      // with a file URI, try to make it relative to cwd so it's not so long.
+      final name = uri != null && path != null
+          ? (uri.isScheme('file')
+              ? _converter.convertToRelativePath(path)
+              : uri.toString())
+          : null;
+      // Because we split on newlines, all items exept the last one need to
+      // have their trailing newlines added back.
+      final output = i == lines.length - 1 ? line : '$line\n';
+      events.add(
+        OutputEventBody(
+          category: 'stderr',
+          output: output,
+          source: path != null ? Source(name: name, path: path) : null,
+          line: frame?.line,
+          column: frame?.column,
+        ),
+      );
+    }
+
+    return events;
+  }
+
   /// Handles evaluation of an expression that is (or begins with)
   /// `threadExceptionExpression` which corresponds to the exception at the top
   /// of [thread].
@@ -1618,6 +1763,18 @@
     await debuggerInitialized;
 
     await _isolateManager.handleEvent(event);
+
+    final eventKind = event.kind;
+    final isolate = event.isolate;
+    // We pause isolates on exit to allow requests for resolving URIs in
+    // stderr call stacks, so when we see an isolate pause, wait for any
+    // pending logs and then resume it (so it exits).
+    if (resumeIsolatesAfterPauseExit &&
+        eventKind == vm.EventKind.kPauseExit &&
+        isolate != null) {
+      await _waitForPendingOutputEvents();
+      await _isolateManager.resumeIsolate(isolate);
+    }
   }
 
   @protected
@@ -1857,6 +2014,20 @@
     return (data) => _withErrorHandling(() => handler(data));
   }
 
+  /// Waits for any pending async output events that might be in progress.
+  ///
+  /// If another output event is queued while waiting, the new event will be
+  /// waited for, until there are no more.
+  Future<void> _waitForPendingOutputEvents() async {
+    // Keep awaiting it as long as it's changing to allow for other
+    // events being queued up while it runs.
+    var lastEvent = _lastOutputEvent;
+    do {
+      lastEvent = _lastOutputEvent;
+      await lastEvent;
+    } while (lastEvent != _lastOutputEvent);
+  }
+
   /// Calls a function with an error handler that handles errors that occur when
   /// the VM Service/DDS shuts down.
   ///
diff --git a/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart b/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart
index 928f94a..06def69 100644
--- a/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart
+++ b/pkg/dds/lib/src/dap/adapters/dart_cli_adapter.dart
@@ -73,6 +73,14 @@
     terminatePids(ProcessSignal.sigkill);
   }
 
+  /// Checks whether [flag] is in [args], allowing for both underscore and
+  /// dash format.
+  bool _containsVmFlag(List<String> args, String flag) {
+    final flagUnderscores = flag.replaceAll('-', '_');
+    final flagDashes = flag.replaceAll('_', '-');
+    return args.contains(flagUnderscores) || args.contains(flagDashes);
+  }
+
   /// Called by [launchRequest] to request that we actually start the app to be
   /// run/debugged.
   ///
@@ -103,6 +111,17 @@
       ],
     ];
 
+    final toolArgs = args.toolArgs ?? [];
+    if (debug) {
+      // If the user has explicitly set pause-isolates-on-exit we need to
+      // not add it ourselves, and disable auto-resuming.
+      if (_containsVmFlag(toolArgs, '--pause_isolates_on_exit')) {
+        resumeIsolatesAfterPauseExit = false;
+      } else {
+        vmArgs.add('--pause_isolates_on_exit');
+      }
+    }
+
     // Handle customTool and deletion of any arguments for it.
     final executable = args.customTool ?? Platform.resolvedExecutable;
     final removeArgs = args.customToolReplacesArgs;
@@ -112,7 +131,7 @@
 
     final processArgs = [
       ...vmArgs,
-      ...?args.toolArgs,
+      ...toolArgs,
       args.program,
       ...?args.args,
     ];
diff --git a/pkg/dds/lib/src/dap/isolate_manager.dart b/pkg/dds/lib/src/dap/isolate_manager.dart
index b3fac35..ee48794 100644
--- a/pkg/dds/lib/src/dap/isolate_manager.dart
+++ b/pkg/dds/lib/src/dap/isolate_manager.dart
@@ -13,6 +13,7 @@
 import 'adapters/dart.dart';
 import 'exceptions.dart';
 import 'protocol_generated.dart';
+import 'utils.dart';
 
 /// Manages state of Isolates (called Threads by the DAP protocol).
 ///
@@ -215,8 +216,7 @@
     ));
   }
 
-  Future<void> resumeIsolate(vm.IsolateRef isolateRef,
-      [String? resumeType]) async {
+  Future<void> resumeIsolate(vm.IsolateRef isolateRef) async {
     final isolateId = isolateRef.id!;
 
     final thread = _threadsByIsolateId[isolateId];
@@ -823,7 +823,7 @@
   Future<List<String?>> resolveUrisToPathsBatch(List<Uri> uris) async {
     // First find the set of URIs we don't already have results for.
     final requiredUris = uris
-        .where((uri) => !uri.isScheme('file'))
+        .where(isResolvableUri)
         .where((uri) => !_resolvedPaths.containsKey(uri.toString()))
         .toSet() // Take only distinct values.
         .toList();
@@ -837,17 +837,24 @@
       completers.forEach(
         (uri, completer) => _resolvedPaths[uri] = completer.future,
       );
-      final results =
-          await _manager._lookupResolvedPackageUris(isolate, requiredUris);
-      if (results == null) {
-        // If no result, all of the results are null.
-        completers.forEach((uri, completer) => completer.complete(null));
-      } else {
-        // Otherwise, complete each one by index with the corresponding value.
-        results.map(_convertUriToFilePath).forEachIndexed((i, result) {
-          final uri = requiredUris[i].toString();
-          completers[uri]!.complete(result);
-        });
+      try {
+        final results =
+            await _manager._lookupResolvedPackageUris(isolate, requiredUris);
+        if (results == null) {
+          // If no result, all of the results are null.
+          completers.forEach((uri, completer) => completer.complete(null));
+        } else {
+          // Otherwise, complete each one by index with the corresponding value.
+          results.map(_convertUriToFilePath).forEachIndexed((i, result) {
+            final uri = requiredUris[i].toString();
+            completers[uri]!.complete(result);
+          });
+        }
+      } catch (e) {
+        // We can't leave dangling completers here because others may already
+        // be waiting on them, so propogate the error to them.
+        completers.forEach((uri, completer) => completer.completeError(e));
+        rethrow;
       }
     }
 
diff --git a/pkg/dds/lib/src/dap/utils.dart b/pkg/dds/lib/src/dap/utils.dart
new file mode 100644
index 0000000..fa47866
--- /dev/null
+++ b/pkg/dds/lib/src/dap/utils.dart
@@ -0,0 +1,42 @@
+// Copyright (c) 2022, 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 'package:stack_trace/stack_trace.dart' as stack;
+
+/// Returns whether this URI has a scheme that can be resolved to a file path
+/// via the VM Service.
+bool isResolvableUri(Uri uri) {
+  return !uri.isScheme('file') &&
+      // Parsed stack frames may have URIs with no scheme and the text
+      // "unparsed" if they looked like stack frames but had no file
+      // information.
+      !uri.isScheme('');
+}
+
+/// Attempts to parse a line as a stack frame in order to read path/line/col
+/// information.
+///
+/// It should not be assumed that if a [stack.Frame] is returned that the input
+/// was necessarily a stack frame or that calling `toString` will return the
+/// original input text.
+stack.Frame? parseStackFrame(String line) {
+  /// Helper to try parsing a frame with [parser], returning `null` if it
+  /// fails to parse.
+  stack.Frame? tryParseFrame(stack.Frame Function(String) parser) {
+    final frame = parser(line);
+    return frame is stack.UnparsedFrame ? null : frame;
+  }
+
+  // Try different formats of stack frames.
+  // pkg:stack_trace does not have a generic Frame.parse() and Trace.parse()
+  // doesn't work well when the content includes non-stack-frame lines
+  // (https://github.com/dart-lang/stack_trace/issues/115).
+  return tryParseFrame((line) => stack.Frame.parseVM(line)) ??
+      // TODO(dantup): Tidy up when constructor tear-offs are available.
+      tryParseFrame((line) => stack.Frame.parseV8(line)) ??
+      tryParseFrame((line) => stack.Frame.parseSafari(line)) ??
+      tryParseFrame((line) => stack.Frame.parseFirefox(line)) ??
+      tryParseFrame((line) => stack.Frame.parseIE(line)) ??
+      tryParseFrame((line) => stack.Frame.parseFriendly(line));
+}
diff --git a/pkg/dds/lib/src/devtools/devtools_handler.dart b/pkg/dds/lib/src/devtools/devtools_handler.dart
index 3b55bfb..ad7067d 100644
--- a/pkg/dds/lib/src/devtools/devtools_handler.dart
+++ b/pkg/dds/lib/src/devtools/devtools_handler.dart
@@ -4,12 +4,12 @@
 
 import 'dart:async';
 
-import 'package:dds/src/constants.dart';
 import 'package:devtools_shared/devtools_server.dart';
 import 'package:shelf/shelf.dart';
 import 'package:shelf_static/shelf_static.dart';
 import 'package:sse/server/sse_handler.dart';
 
+import '../constants.dart';
 import '../dds_impl.dart';
 import 'devtools_client.dart';
 
diff --git a/pkg/dds/pubspec.yaml b/pkg/dds/pubspec.yaml
index 92adad1..51e3d8d 100644
--- a/pkg/dds/pubspec.yaml
+++ b/pkg/dds/pubspec.yaml
@@ -23,6 +23,7 @@
   shelf_proxy: ^1.0.0
   shelf_static: ^1.0.0
   shelf_web_socket: ^1.0.0
+  stack_trace: ^1.10.0
   sse: ^4.0.0
   stream_channel: ^2.0.0
   vm_service: ^8.1.0
diff --git a/pkg/dds/test/dap/integration/debug_exceptions_test.dart b/pkg/dds/test/dap/integration/debug_exceptions_test.dart
index 752ce5f..157f136 100644
--- a/pkg/dds/test/dap/integration/debug_exceptions_test.dart
+++ b/pkg/dds/test/dap/integration/debug_exceptions_test.dart
@@ -70,6 +70,25 @@
         exceptionPauseMode: 'All',
       );
     });
+
+    test('parses line/column information from stack traces', () async {
+      final client = dap.client;
+      final testFile = dap.createTestFile(simpleThrowingProgram);
+      final exceptionLine = lineWith(testFile, 'throw');
+      final outputEvents = await client.collectOutput(file: testFile);
+
+      // Find the output event for the top of the printed stack trace.
+      // It should look something like:
+      // #0      main (file:///var/folders/[...]/app3JZLvu/test_file.dart:2:5)
+      final mainStackFrameEvent = outputEvents
+          .firstWhere((event) => event.output.startsWith('#0      main'));
+
+      // Expect that there is metadata attached that matches the file/location we
+      // expect.
+      expect(mainStackFrameEvent.source?.path, testFile.path);
+      expect(mainStackFrameEvent.line, exceptionLine);
+      expect(mainStackFrameEvent.column, 5);
+    });
     // These tests can be slow due to starting up the external server process.
   }, timeout: Timeout.none);
 }
diff --git a/pkg/dds/test/dap/integration/debug_test.dart b/pkg/dds/test/dap/integration/debug_test.dart
index 6655b58..974692f 100644
--- a/pkg/dds/test/dap/integration/debug_test.dart
+++ b/pkg/dds/test/dap/integration/debug_test.dart
@@ -94,6 +94,76 @@
       expect(proc!.exitCode, completes);
     });
 
+    test('does not resume isolates if user passes --pause-isolates-on-exit',
+        () async {
+      // Internally we always pass --pause-isolates-on-exit and resume the
+      // isolates after waiting for any output events to complete (in case they
+      // need to resolve URIs that involve API calls on an Isolate).
+      //
+      // However if a user passes this flag explicitly, we should not
+      // auto-resume because they might be trying to debug something.
+      final testFile = dap.createTestFile(simpleArgPrintingProgram);
+
+      // Run the script, expecting a Stopped event.
+      final stop = dap.client.expectStop('pause');
+      await Future.wait([
+        stop,
+        dap.client.initialize(),
+        dap.client
+            .launch(testFile.path, toolArgs: ["--pause-isolates-on-exit"]),
+      ], eagerError: true);
+
+      // Resume and expect termination.
+      await await Future.wait([
+        dap.client.event('terminated'),
+        dap.client.continue_((await stop).threadId!),
+      ], eagerError: true);
+    });
+
+    test('sends output events in the correct order', () async {
+      // Output events that have their URIs mapped will be processed slowly due
+      // the async requests for resolving the package URI. This should not cause
+      // them to appear out-of-order with other lines that do not require this
+      // work.
+      //
+      // Use a sample program that prints output to stderr that includes:
+      // - non stack frame lines
+      // - stack frames with file:// URIs
+      // - stack frames with package URIs (that need asynchronously resolving)
+      final fileUri = Uri.file(dap.createTestFile('').path);
+      final packageUri = await dap.createFooPackage();
+      final testFile =
+          dap.createTestFile(stderrPrintingProgram(fileUri, packageUri));
+
+      var outputEvents = await dap.client.collectOutput(
+        launch: () => dap.client.launch(testFile.path),
+      );
+      outputEvents = outputEvents.where((e) => e.category == 'stderr').toList();
+
+      // Verify the order of the stderr output events.
+      final output = outputEvents
+          .map((e) => '${e.output.trim()}')
+          .where((output) => output.isNotEmpty)
+          .join('\n');
+      expectLines(output, [
+        'Start',
+        '#0      main ($fileUri:1:2)',
+        '#1      main2 ($packageUri:1:2)',
+        'End',
+      ]);
+
+      // As a sanity check, verify we did actually do the async path mapping and
+      // got both frames with paths in our test folder.
+      final stackFramesWithPaths = outputEvents.where((e) =>
+          e.source?.path != null &&
+          path.isWithin(dap.testDir.path, e.source!.path!));
+      expect(
+        stackFramesWithPaths,
+        hasLength(2),
+        reason: 'Expected two frames within path ${dap.testDir.path}',
+      );
+    });
+
     test('provides a list of threads', () async {
       final client = dap.client;
       final testFile = dap.createTestFile(simpleBreakpointProgram);
diff --git a/pkg/dds/test/dap/integration/test_client.dart b/pkg/dds/test/dap/integration/test_client.dart
index ec3c7c0..8ce631d 100644
--- a/pkg/dds/test/dap/integration/test_client.dart
+++ b/pkg/dds/test/dap/integration/test_client.dart
@@ -224,6 +224,7 @@
   Future<Response> launch(
     String program, {
     List<String>? args,
+    List<String>? toolArgs,
     String? cwd,
     bool? noDebug,
     List<String>? additionalProjectPaths,
@@ -240,6 +241,7 @@
         program: program,
         cwd: cwd,
         args: args,
+        toolArgs: toolArgs,
         additionalProjectPaths: additionalProjectPaths,
         console: console,
         debugSdkLibraries: debugSdkLibraries,
diff --git a/pkg/dds/test/dap/integration/test_scripts.dart b/pkg/dds/test/dap/integration/test_scripts.dart
index 429018b..84eea06 100644
--- a/pkg/dds/test/dap/integration/test_scripts.dart
+++ b/pkg/dds/test/dap/integration/test_scripts.dart
@@ -55,6 +55,24 @@
   }
 ''';
 
+/// A simple Dart script that prints to stderr without throwing/terminating.
+///
+/// The output will contain stack traces include both the supplied file and
+/// package URIs.
+String stderrPrintingProgram(Uri fileUri, Uri packageUri) {
+  return '''
+  import 'dart:io';
+  import '$packageUri';
+
+  void main(List<String> args) async {
+    stderr.writeln('Start');
+    stderr.writeln('#0      main ($fileUri:1:2)');
+    stderr.writeln('#1      main2 ($packageUri:1:2)');
+    stderr.write('End');
+  }
+''';
+}
+
 /// 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
diff --git a/pkg/dds/test/dap/integration/test_support.dart b/pkg/dds/test/dap/integration/test_support.dart
index d4c2a8b..ebabce8 100644
--- a/pkg/dds/test/dap/integration/test_support.dart
+++ b/pkg/dds/test/dap/integration/test_support.dart
@@ -133,15 +133,15 @@
 class DapTestSession {
   DapTestServer server;
   DapTestClient client;
-  final Directory _testDir =
+  final Directory testDir =
       Directory.systemTemp.createTempSync('dart-sdk-dap-test');
   late final Directory testAppDir;
   late final Directory testPackagesDir;
 
   DapTestSession._(this.server, this.client) {
-    testAppDir = _testDir.createTempSync('app');
+    testAppDir = testDir.createTempSync('app');
     createPubspec(testAppDir, 'my_test_project');
-    testPackagesDir = _testDir.createTempSync('packages');
+    testPackagesDir = testDir.createTempSync('packages');
   }
 
   /// Adds package with [name] (optionally at [packageFolderUri]) to the
@@ -236,7 +236,7 @@
     await server.stop();
 
     // Clean up any temp folders created during the test runs.
-    _testDir.deleteSync(recursive: true);
+    testDir.deleteSync(recursive: true);
   }
 
   static Future<DapTestSession> setUp({List<String>? additionalArgs}) async {