Resolve process leak in test compiler (#2664)

Check for early closure after async operations and resolve a deadlock
during close where the frontend server is busy and is holding a pool
resource during shutdown.

Add a mechanism for injecting a fake `FrontendServerClient` to allow
controlling timing during a test without arbitrary delays trying line up
with the external process.
diff --git a/pkgs/test_core/lib/src/runner/vm/test_compiler.dart b/pkgs/test_core/lib/src/runner/vm/test_compiler.dart
index b4eb512..618f473 100644
--- a/pkgs/test_core/lib/src/runner/vm/test_compiler.dart
+++ b/pkgs/test_core/lib/src/runner/vm/test_compiler.dart
@@ -46,9 +46,14 @@
   /// A prefix used for the dill files for each compiler that is created.
   final String _dillCachePrefix;
 
+  final FrontendClientFactory _clientFactory;
+
   /// No work is done until the first call to [compile] is received, at which
   /// point the compiler process is started.
-  TestCompiler(this._dillCachePrefix);
+  TestCompiler(
+    this._dillCachePrefix, {
+    FrontendClientFactory clientFactory = FrontendServerClient.start,
+  }) : _clientFactory = clientFactory;
 
   /// Compiles [mainDart], using a separate compiler per language version of
   /// the tests.
@@ -62,6 +67,7 @@
       () => _TestCompilerForLanguageVersion(
         _dillCachePrefix,
         languageVersionComment,
+        _clientFactory,
       ),
     );
     return compiler.compile(mainDart);
@@ -79,6 +85,7 @@
   final _closeMemo = AsyncMemoizer<void>();
   final _compilePool = Pool(1);
   final String _dillCachePath;
+  final FrontendClientFactory _clientFactory;
   FrontendServerClient? _frontendServerClient;
   final String _languageVersionComment;
   late final _outputDill = File(
@@ -96,6 +103,7 @@
   _TestCompilerForLanguageVersion(
     String dillCachePrefix,
     this._languageVersionComment,
+    this._clientFactory,
   ) : _dillCachePath =
           '$dillCachePrefix.'
           '${_dillCacheSuffix(_languageVersionComment, enabledExperiments)}';
@@ -119,15 +127,16 @@
     final testCache = File(_dillCachePath);
 
     try {
-      if (_frontendServerClient == null) {
+      if (_frontendServerClient case final frontendServerClient?) {
+        compilerOutput = await frontendServerClient.compile(<Uri>[
+          tempFile.uri,
+        ]);
+      } else {
         if (await testCache.exists()) {
           await testCache.copy(_outputDill.path);
         }
         compilerOutput = await _createCompiler(tempFile.uri);
-      } else {
-        compilerOutput = await _frontendServerClient!.compile(<Uri>[
-          tempFile.uri,
-        ]);
+        if (_closeMemo.hasRun) return CompilationResponse._wasShutdown;
       }
     } catch (e, s) {
       if (_closeMemo.hasRun) return CompilationResponse._wasShutdown;
@@ -192,7 +201,7 @@
       }
     }
 
-    var client = _frontendServerClient = await FrontendServerClient.start(
+    var client = await _clientFactory(
       testUri.toString(),
       _outputDill.path,
       platformDill,
@@ -202,10 +211,19 @@
       nativeAssets: nativeAssetsYaml?.toFilePath(),
       printIncrementalDependencies: false,
     );
+
+    if (_closeMemo.hasRun) {
+      client.kill();
+      return null;
+    }
+
+    _frontendServerClient = client;
     return client.compile();
   }
 
   Future<void> dispose() => _closeMemo.runOnce(() async {
+    _frontendServerClient?.kill();
+    _frontendServerClient = null;
     await _compilePool.close();
     if (_dillToCache != null) {
       var testCache = File(_dillCachePath);
@@ -214,14 +232,24 @@
       }
       _dillToCache!.copySync(_dillCachePath);
     }
-    _frontendServerClient?.kill();
-    _frontendServerClient = null;
     if (_outputDillDirectory.existsSync()) {
       await _outputDillDirectory.deleteWithRetry();
     }
   });
 }
 
+typedef FrontendClientFactory =
+    Future<FrontendServerClient> Function(
+      String entrypoint,
+      String outputDillPath,
+      String platformKernel, {
+      List<String>? enabledExperiments,
+      bool printIncrementalDependencies,
+      String sdkRoot,
+      String packagesJson,
+      String? nativeAssets,
+    });
+
 /// Computes a unique dill cache suffix for each [languageVersionComment]
 /// and [enabledExperiments] combination.
 String _dillCacheSuffix(
diff --git a/pkgs/test_core/pubspec.yaml b/pkgs/test_core/pubspec.yaml
index 37d3968..a69530c 100644
--- a/pkgs/test_core/pubspec.yaml
+++ b/pkgs/test_core/pubspec.yaml
@@ -34,3 +34,4 @@
 
 dev_dependencies:
   test: any
+  test_descriptor: ^2.0.0
diff --git a/pkgs/test_core/test/runner/vm/test_compiler_test.dart b/pkgs/test_core/test/runner/vm/test_compiler_test.dart
index b75a8b7..e220d3e 100644
--- a/pkgs/test_core/test/runner/vm/test_compiler_test.dart
+++ b/pkgs/test_core/test/runner/vm/test_compiler_test.dart
@@ -2,10 +2,17 @@
 // 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 'dart:convert';
+import 'dart:io';
 
+import 'package:frontend_server_client/frontend_server_client.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/fake.dart';
 import 'package:test/test.dart';
+import 'package:test_api/backend.dart';
 import 'package:test_core/src/runner/vm/test_compiler.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
 
 void main() {
   group('VM test templates', () {
@@ -24,4 +31,170 @@
       );
     });
   });
+
+  group('TestCompiler (integration)', () {
+    late String testPath;
+    late TestCompiler compiler;
+
+    setUp(() async {
+      await d.file('a_test.dart', 'void main() {}').create();
+      testPath = p.join(d.sandbox, 'a_test.dart');
+      compiler = TestCompiler(p.join(d.sandbox, 'dill_cache'));
+    });
+
+    tearDown(() async {
+      await compiler.dispose();
+    });
+
+    test('can compile a test', () async {
+      final response = await compiler.compile(Uri.file(testPath), Metadata());
+      expect(response.errorCount, 0);
+      expect(response.kernelOutputUri, isNotNull);
+      expect(File(response.kernelOutputUri!.toFilePath()).existsSync(), isTrue);
+    });
+  });
+
+  group('TestCompiler (with fakes)', () {
+    late String testPath;
+
+    setUp(() async {
+      await d.file('a_test.dart', 'void main() {}').create();
+      testPath = p.join(d.sandbox, 'a_test.dart');
+    });
+
+    test('can compile successfully with fake', () async {
+      final (fakeClient, clientStarter) = FakeFrontendServerClient.create;
+      final compiler = TestCompiler(
+        p.join(d.sandbox, 'dill_cache'),
+        clientFactory: clientStarter,
+      );
+
+      final compileFuture = compiler.compile(
+        Uri.file(testPath),
+        Metadata(languageVersionComment: '// @dart=3.0'),
+      );
+
+      await pumpEventQueue();
+
+      final outputDill = p.join(d.sandbox, 'output.dill');
+      File(outputDill).createSync();
+
+      fakeClient.completeCompile(
+        FakeCompileResult(
+          dillOutput: outputDill,
+          errorCount: 0,
+          compilerOutputLines: [],
+        ),
+      );
+
+      final response = await compileFuture;
+      expect(response.errorCount, 0);
+      expect(response.kernelOutputUri, isNotNull);
+
+      await compiler.dispose();
+    });
+
+    test('dispose kills active compiler and completes immediately', () async {
+      final (fakeClient, clientStarter) = FakeFrontendServerClient.create;
+      final compiler = TestCompiler(
+        p.join(d.sandbox, 'dill_cache'),
+        clientFactory: clientStarter,
+      );
+
+      final compileFuture = compiler.compile(
+        Uri.file(testPath),
+        Metadata(languageVersionComment: '// @dart=3.0'),
+      );
+
+      await pumpEventQueue();
+
+      expect(fakeClient.isCompileCalled, isTrue);
+      expect(fakeClient.isKilled, isFalse);
+
+      final disposeFuture = compiler.dispose();
+
+      await expectLater(disposeFuture, completes);
+      expect(fakeClient.isKilled, isTrue);
+
+      final response = await compileFuture;
+      expect(response.errorCount, 1);
+      expect(response.compilerOutput, contains('Compiler no longer active'));
+    });
+  });
+}
+
+class FakeCompileResult extends Fake implements CompileResult {
+  @override
+  final String? dillOutput;
+  @override
+  final int errorCount;
+  @override
+  final Iterable<String> compilerOutputLines;
+  @override
+  final Iterable<Uri> newSources;
+  @override
+  final Iterable<Uri> removedSources;
+
+  FakeCompileResult({
+    this.dillOutput,
+    this.errorCount = 0,
+    this.compilerOutputLines = const [],
+    this.newSources = const [],
+    this.removedSources = const [],
+  });
+}
+
+class FakeFrontendServerClient extends Fake implements FrontendServerClient {
+  var _compileCompleter = Completer<CompileResult>();
+  bool isKilled = false;
+  bool isCompileCalled = false;
+  int compileCallCount = 0;
+
+  static (FakeFrontendServerClient, FrontendClientFactory) get create {
+    final fakeClient = FakeFrontendServerClient();
+    return (
+      fakeClient,
+      (
+        _,
+        _,
+        _, {
+        List<String>? enabledExperiments,
+        bool printIncrementalDependencies = true,
+        String sdkRoot = '',
+        String packagesJson = '',
+        String? nativeAssets,
+      }) async => fakeClient,
+    );
+  }
+
+  @override
+  Future<CompileResult> compile([List<Uri>? sources]) {
+    isCompileCalled = true;
+    compileCallCount++;
+    if (_compileCompleter.isCompleted) {
+      _compileCompleter = Completer<CompileResult>();
+    }
+    return _compileCompleter.future;
+  }
+
+  void completeCompile(CompileResult result) {
+    if (!_compileCompleter.isCompleted) {
+      _compileCompleter.complete(result);
+    }
+  }
+
+  @override
+  bool kill({ProcessSignal processSignal = ProcessSignal.sigterm}) {
+    isKilled = true;
+    if (!_compileCompleter.isCompleted) {
+      _compileCompleter.completeError(StateError('Killed'));
+    }
+    return true;
+  }
+
+  @override
+  void accept() {}
+
+  @override
+  void reset() {}
 }