[ddc] Test DartPad-like hot reload workflow.

Refactors the hot reload test suite to support non-FE server based hot reload. Similar to DartPad it uses a DDC process running in '--persistent_worker' mode and sends bazel requests for each reload.

I call this "stateless" mode because the compilation process itself is not maintaining any state. The necessary metadata is passed from one compilation to the other via a delta dill. This differs from the "stateful" mode where the FE server persists a kernel AST in memory from one compilation to the next.

Disregarding the browsers there are effectively 3 run modes now:
- web stateful
- web stateless (new)
- vm stateful

One key difference between the "stateless" and "stateful" modes is the output format of the JS files. In stateful mode DDC emits a file per library being re-compiled. In stateless mode DDC is emitting a single file with all the libraries.

DartPad's workflow is slightly simpler than what's seen in the stateless mode here. It only supports editing a single library so we skip processing any metadata. To simulate this I've added the special 'main_only' which passes a single library for each reload generation. I've verified that this would fail if not for the change recently made to ddc_module_loader.js.

Change-Id: If05da6dbeded4dd20e9e6d9dfaed52151541a19b
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/434340
Commit-Queue: Nate Biggs <natebiggs@google.com>
Reviewed-by: Mark Zhou <markzipan@google.com>
Reviewed-by: Kevin Moore <kevmoo@google.com>
Reviewed-by: Nicholas Shahan <nshahan@google.com>
diff --git a/pkg/dev_compiler/lib/src/command/command.dart b/pkg/dev_compiler/lib/src/command/command.dart
index 671a2cb..cf27e76 100644
--- a/pkg/dev_compiler/lib/src/command/command.dart
+++ b/pkg/dev_compiler/lib/src/command/command.dart
@@ -394,8 +394,8 @@
   } else {
     if (reloadLastAcceptedKernel != null) {
       throw ArgumentError(
-        "Must provide 'new-reload-delta-kernel' if "
-        "'old-reload-delta-kernel' provided.",
+        "Must provide 'reload-delta-kernel' if "
+        "'reload-last-accepted-kernel' provided.",
       );
     }
   }
diff --git a/pkg/dev_compiler/test/hot_reload_suite.dart b/pkg/dev_compiler/test/hot_reload_suite.dart
index b63d204..5167908 100644
--- a/pkg/dev_compiler/test/hot_reload_suite.dart
+++ b/pkg/dev_compiler/test/hot_reload_suite.dart
@@ -10,6 +10,7 @@
 
 import 'package:_fe_analyzer_shared/src/util/relativize.dart' as fe_shared;
 import 'package:args/args.dart';
+import 'package:bazel_worker/driver.dart';
 import 'package:collection/collection.dart';
 import 'package:dev_compiler/dev_compiler.dart'
     as ddc_names
@@ -39,18 +40,27 @@
     print(options.usage);
     return;
   }
-  final runner = switch (options.runtime) {
-    RuntimePlatforms.chrome => ChromeSuiteRunner(options),
-    RuntimePlatforms.d8 => D8SuiteRunner(options),
-    RuntimePlatforms.vm => VMSuiteRunner(options),
-  };
-  await runner.runSuite(options);
+  final runner =
+      switch (options.runtime) {
+            RuntimePlatforms.chrome =>
+              options.useFeServer
+                  ? ChromeStandaloneSuiteRunner(options)
+                  : ChromeSuiteRunner(options),
+            RuntimePlatforms.d8 =>
+              options.useFeServer
+                  ? D8StandaloneSuiteRunner(options)
+                  : D8SuiteRunner(options),
+            RuntimePlatforms.vm => VMSuiteRunner(options),
+          }
+          as HotReloadSuiteRunner;
+  await runner.runSuite();
 }
 
 /// Command line options for the hot reload test suite.
 class Options {
   final bool help;
   final RuntimePlatforms runtime;
+  final bool useFeServer;
   final String namedConfiguration;
   final Uri? testResultsOutputDir;
   final RegExp testNameFilter;
@@ -61,6 +71,7 @@
   Options._({
     required this.help,
     required this.runtime,
+    required this.useFeServer,
     required this.namedConfiguration,
     required this.testResultsOutputDir,
     required this.testNameFilter,
@@ -100,6 +111,13 @@
       defaultsTo: r'.*',
       help: 'regexp filter over tests to run.',
     )
+    ..addFlag(
+      'use-fe-server',
+      defaultsTo: true,
+      help:
+          'Whether to run the suite in using DDC directly instead of the FE '
+          'server. Only applicable when targeting the web.',
+    )
     ..addOption(
       'diff',
       allowed: ['check', 'write', 'ignore'],
@@ -133,9 +151,10 @@
 
   factory Options.parse(List<String> args) {
     final results = _parser.parse(args);
-    return Options._(
+    final options = Options._(
       help: results.flag('help'),
       runtime: RuntimePlatforms.values.byName(results.option('runtime')!),
+      useFeServer: results.flag('use-fe-server'),
       namedConfiguration: results.option('named-configuration')!,
       testResultsOutputDir: results.wasParsed('output-directory')
           ? Uri.directory(results.option('output-directory')!)
@@ -150,6 +169,15 @@
       debug: results.flag('debug'),
       verbose: results.flag('verbose'),
     );
+
+    if (!options.useFeServer && options.runtime == RuntimePlatforms.vm) {
+      throw ArgumentError(
+        'Unsupported flag combination: '
+        '`--runtime=vm` and `--use-fe-server=false`',
+      );
+    }
+
+    return options;
   }
 }
 
@@ -255,30 +283,56 @@
   TestFileEdit(this.generation, this.fileUri);
 }
 
-abstract class HotReloadSuiteRunner {
+abstract class CompiledOutput {
+  final String outputDillPath;
+  int get errorCount;
+  String get outputText;
+
+  CompiledOutput(this.outputDillPath);
+}
+
+class FrontendServerOutput extends CompiledOutput {
+  final CompilerOutput _output;
+  @override
+  int get errorCount => _output.errorCount;
+  @override
+  String get outputText => _output.outputText;
+
+  FrontendServerOutput(super.outputDillPath, this._output);
+}
+
+class DdcWorkerOutput extends CompiledOutput {
+  final WorkResponse _response;
+  @override
+  int get errorCount =>
+      _response.exitCode == 0 ? 0 : _response.output.split('\n').length - 2;
+  @override
+  String get outputText => _response.output;
+
+  DdcWorkerOutput(super.outputDillPath, this._response);
+}
+
+/// Backend agnostic class to orchestrate running the hot reload test suite.
+///
+/// [T] is the type of a controller used to perform compilations of Dart code.
+abstract class HotReloadSuiteRunner<T> {
   Options options;
 
+  /// All test results that are reported after running the entire test suite.
+  final List<TestResultOutcome> testOutcomes = [];
+
+  HotReloadSuiteRunner(this.options);
+
   /// The root directory containing generated code for all tests.
   late final Directory generatedCodeDir = Directory.systemTemp.createTempSync();
 
-  /// The directory containing files emitted from Frontend Server compiles and
-  /// recompiles.
-  late final Directory frontendServerEmittedFilesDir = Directory.fromUri(
+  /// The directory containing files emitted by compiles and recompiles.
+  late final Directory emittedFilesDir = Directory.fromUri(
     generatedCodeDir.uri.resolve('.fes/'),
   )..createSync();
 
-  /// The output location for .dill file created by the front end server.
-  late final Uri outputDillUri = frontendServerEmittedFilesDir.uri.resolve(
-    'output.dill',
-  );
-
-  /// The output location for the incremental .dill file created by the front
-  /// end server.
-  late final Uri outputIncrementalDillUri = frontendServerEmittedFilesDir.uri
-      .resolve('output_incremental.dill');
-
-  /// All test results that are reported after running the entire test suite.
-  final List<TestResultOutcome> testOutcomes = [];
+  /// The output location for .dill file created by dart.
+  late final Uri outputDillUri = emittedFilesDir.uri.resolve('output.dill');
 
   /// The directory used as a temporary staging area to construct a compile-able
   /// test app across reload/restart generations.
@@ -286,6 +340,8 @@
     generatedCodeDir.uri.resolve('.snapshot/'),
   )..createSync();
 
+  final filesystemScheme = 'hot-reload-test';
+
   // TODO(markzipan): Support custom entrypoints.
   late final Uri snapshotEntrypointUri = snapshotDir.uri.resolve('main.dart');
   late final String snapshotEntrypointWithScheme = () {
@@ -297,120 +353,181 @@
     return '$filesystemScheme:///$snapshotEntrypointLibraryName';
   }();
 
-  HotReloadMemoryFilesystem? filesystem;
+  late final packageConfigUri = sdkRoot.resolve(
+    '.dart_tool/package_config.json',
+  );
+
+  final Uri dartSdkJSUri = buildRootUri.resolve(
+    'gen/utils/ddc/canary/sdk/ddc/dart_sdk.js',
+  );
+  final Uri ddcModuleLoaderJSUri = sdkRoot.resolve(
+    'pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js',
+  );
+  final String ddcPlatformDillFromSdkRoot = fe_shared.relativizeUri(
+    sdkRoot,
+    buildRootUri.resolve('ddc_outline.dill'),
+    fe_shared.isWindows,
+  );
+  final String entrypointModuleName = 'hot-reload-test:///main.dart';
+  late final String entrypointLibraryExportName = ddc_names
+      .libraryUriToJsIdentifier(snapshotEntrypointUri);
+
   final stopwatch = Stopwatch();
 
-  final filesystemScheme = 'hot-reload-test';
+  String? get modeNamePrefix => null;
+  T createController();
+  Future<void> stopController(T controller);
+  Future<CompiledOutput> sendCompile(T controller, HotReloadTest test);
+  Future<CompiledOutput> sendRecompile(
+    T controller,
+    HotReloadTest test,
+    int generation,
+    List<String> updatedFiles,
+  );
+  Future<bool> resolveOutput(
+    T controller,
+    HotReloadTest test,
+    CompiledOutput output,
+    int generation,
+  );
+  void registerOutputDirectory(HotReloadTest test, Uri outputDirectory);
 
-  HotReloadSuiteRunner(this.options);
+  /// Runs [test] from compiled and generated assets in [tempDirectory] and
+  /// returns `true` if it passes.
+  ///
+  /// All output (standard and errors) from running the test is written to
+  /// [outputSink].
+  Future<bool> runTest(
+    HotReloadTest test,
+    Directory tempDirectory,
+    IOSink outputSink,
+  );
 
-  Future<void> runSuite(Options options) async {
+  Future<void> shutdown(T controller) async {
+    await stopController(controller);
+
+    // Persist the temp directory for debugging.
+    if (!options.debug) {
+      _print('Deleting temporary directory: ${generatedCodeDir.path}.');
+      generatedCodeDir.deleteSync(recursive: true);
+    }
+  }
+
+  Future<void> runSuite() async {
     // TODO(nshahan): report time for collecting and validating test sources.
     final testSuite = collectTestSources(options);
     _debugPrint(
       'See generated hot reload framework code in ${generatedCodeDir.uri}',
     );
-    final controller = createFrontEndServer();
-    for (final test in testSuite) {
-      stopwatch
-        ..start()
-        ..reset();
-      diffCheck(test);
-      final tempDirectory = Directory.fromUri(
-        generatedCodeDir.uri.resolve('${test.name}/'),
-      )..createSync();
-      if (options.runtime.emitsJS) {
-        filesystem = HotReloadMemoryFilesystem(tempDirectory.uri);
-      }
-      var compileSuccess = false;
-      _print('Generating test assets.', label: test.name);
-      // TODO(markzipan): replace this with a test-configurable main entrypoint.
-      final mainDartFilePath = test.directory.uri
-          .resolve('main.dart')
-          .toFilePath();
-      _debugPrint('Test entrypoint: $mainDartFilePath', label: test.name);
-      _print(
-        'Generating code over ${test.generationCount} generations.',
-        label: test.name,
-      );
-      stopwatch
-        ..start()
-        ..reset();
-      for (
-        var generation = 0;
-        generation < test.generationCount;
-        generation++
-      ) {
-        final updatedFiles = copyGenerationSources(test, generation);
-        compileSuccess = await compileGeneration(
-          test,
-          generation,
-          tempDirectory,
-          updatedFiles,
-          controller,
-        );
-        if (!compileSuccess) break;
-      }
-      if (!compileSuccess) {
+    final controller = createController();
+    try {
+      for (final test in testSuite) {
+        stopwatch
+          ..start()
+          ..reset();
+        diffCheck(test);
+        final tempDirectory = Directory.fromUri(
+          generatedCodeDir.uri.resolve('${test.name}/'),
+        )..createSync();
+        registerOutputDirectory(test, tempDirectory.uri);
+        var compileSuccess = false;
+        _print('Generating test assets.', label: test.name);
+        // TODO(markzipan): replace this with a test-configurable main
+        //   entrypoint.
+        final mainDartFilePath = test.directory.uri
+            .resolve('main.dart')
+            .toFilePath();
+        _debugPrint('Test entrypoint: $mainDartFilePath', label: test.name);
         _print(
-          'Did not emit all assets due to compilation error.',
+          'Generating code over ${test.generationCount} generations.',
           label: test.name,
         );
-        // Skip to the next test and avoid execution if there is an unexpected
-        // compilation error.
-        continue;
+        stopwatch
+          ..start()
+          ..reset();
+        for (
+          var generation = 0;
+          generation < test.generationCount;
+          generation++
+        ) {
+          final updatedFiles = copyGenerationSources(test, generation);
+          compileSuccess = await compileGeneration(
+            test,
+            generation,
+            updatedFiles,
+            controller,
+          );
+          if (!compileSuccess) break;
+        }
+        if (!compileSuccess) {
+          _print(
+            'Did not emit all assets due to compilation error.',
+            label: test.name,
+          );
+          // Skip to the next test and avoid execution if there is an unexpected
+          // compilation error.
+          continue;
+        }
+        _print('Finished emitting assets.', label: test.name);
+        final testOutputStreamController = StreamController<List<int>>();
+        final testOutputBuffer = StringBuffer();
+        testOutputStreamController.stream
+            .transform(utf8.decoder)
+            .listen(testOutputBuffer.write);
+        final testPassed = await runTest(
+          test,
+          tempDirectory,
+          IOSink(testOutputStreamController.sink),
+        );
+        await reportTestOutcome(
+          test.name,
+          testOutputBuffer.toString(),
+          testPassed,
+        );
       }
-      _print('Finished emitting assets.', label: test.name);
-      final testOutputStreamController = StreamController<List<int>>();
-      final testOutputBuffer = StringBuffer();
-      testOutputStreamController.stream
-          .transform(utf8.decoder)
-          .listen(testOutputBuffer.write);
-      final testPassed = await runTest(
+      await reportAllResults();
+    } finally {
+      await shutdown(controller);
+    }
+  }
+
+  /// Compiles all [updatedFiles] in [test] for the given [generation] with the
+  /// [controller], copies all outputs to [outputDirectory], and returns whether
+  /// the compilation was successful.
+  ///
+  /// Reports test failures on compile time errors.
+  Future<bool> compileGeneration(
+    HotReloadTest test,
+    int generation,
+    List<String> updatedFiles,
+    T controller,
+  ) async {
+    // The first generation calls `compile`, but subsequent ones call
+    // `recompile`.
+    // Likewise, use the incremental output file for `recompile` calls.
+    // TODO(nshahan): Sending compile/recompile instructions is likely
+    // the same across backends and should be shared code.
+    _print('Compiling generation $generation.', label: test.name);
+    CompiledOutput compiledOutput;
+    if (generation == 0) {
+      _debugPrint(
+        'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
+        label: test.name,
+      );
+      compiledOutput = await sendCompile(controller, test);
+    } else {
+      _debugPrint(
+        'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme',
+        label: test.name,
+      );
+      compiledOutput = await sendRecompile(
+        controller,
         test,
-        tempDirectory,
-        IOSink(testOutputStreamController.sink),
-      );
-      await reportTestOutcome(
-        test.name,
-        testOutputBuffer.toString(),
-        testPassed,
+        generation,
+        updatedFiles,
       );
     }
-    await shutdown(controller);
-    await reportAllResults();
-  }
-
-  /// Custom command line arguments passed to the Front End Server on startup.
-  List<String> get platformFrontEndServerArgs;
-
-  /// Returns a controller for a freshly started front end server instance to
-  /// handle compile and recompile requests for a hot reload test.
-  HotReloadFrontendServerController createFrontEndServer() {
-    _print('Initializing the Frontend Server.');
-    final packageConfigUri = sdkRoot.resolve('.dart_tool/package_config.json');
-    final fesArgs = [
-      '--incremental',
-      '--filesystem-root=${snapshotDir.uri.toFilePath()}',
-      '--filesystem-scheme=$filesystemScheme',
-      '--output-dill=${outputDillUri.toFilePath()}',
-      '--output-incremental-dill=${outputIncrementalDillUri.toFilePath()}',
-      '--packages=${packageConfigUri.toFilePath()}',
-      '--sdk-root=${sdkRoot.toFilePath()}',
-      '--verbosity=${options.verbose ? 'all' : 'info'}',
-      ...platformFrontEndServerArgs,
-    ];
-    return HotReloadFrontendServerController(fesArgs)..start();
-  }
-
-  Future<void> shutdown(HotReloadFrontendServerController controller) async {
-    // Persist the temp directory for debugging.
-    await controller.stop();
-    _print('Frontend Server has shut down.');
-    if (!options.debug) {
-      _print('Deleting temporary directory: ${generatedCodeDir.path}.');
-      generatedCodeDir.deleteSync(recursive: true);
-    }
+    return await resolveOutput(controller, test, compiledOutput, generation);
   }
 
   /// Returns a suite of hot reload tests discovered in the directory
@@ -570,18 +687,19 @@
     bool testPassed,
   ) async {
     stopwatch.stop();
+    final fullTestName = '${modeNamePrefix ?? ''}$testName';
     final outcome = TestResultOutcome(
       configuration: options.namedConfiguration,
-      testName: testName,
+      testName: fullTestName,
       testOutput: testOutput,
     );
     outcome.elapsedTime = stopwatch.elapsed;
     outcome.matchedExpectations = testPassed;
     testOutcomes.add(outcome);
     if (testPassed) {
-      _print('PASSED with:\n  $testOutput', label: testName);
+      _print('PASSED with:\n  $testOutput', label: fullTestName);
     } else {
-      _print('FAILED with:\n  $testOutput', label: testName);
+      _print('FAILED with:\n  $testOutput', label: fullTestName);
     }
   }
 
@@ -948,29 +1066,93 @@
     return updatedFilesInCurrentGeneration;
   }
 
-  /// Compiles all [updatedFiles] in [test] for the given [generation] with the
-  /// front end server [controller], copies all outputs to [outputDirectory],
-  /// and returns whether the compilation was successful.
-  ///
-  /// Reports test failures on compile time errors.
-  Future<bool> compileGeneration(
-    HotReloadTest test,
-    int generation,
-    Directory outputDirectory,
-    List<String> updatedFiles,
-    HotReloadFrontendServerController controller,
-  );
+  /// Prints messages if 'debug' mode is enabled.
+  void _debugPrint(String message, {String? label}) {
+    if (options.debug) {
+      final labelText = label == null ? '' : '($label)';
+      print('DEBUG$labelText: $message');
+    }
+  }
 
-  /// Runs [test] from compiled and generated assets in [tempDirectory] and
-  /// returns `true` if it passes.
+  /// Prints messages if 'verbose' mode is enabled.
+  void _print(String message, {String? label}) {
+    if (options.verbose) {
+      final labelText = label == null ? '' : '($label)';
+      print('hot_reload_test$labelText: $message');
+    }
+  }
+
+  /// Returns the diff'd output between two files.
   ///
-  /// All output (standard and errors) from running the test is written to
-  /// [outputSink].
-  Future<bool> runTest(
-    HotReloadTest test,
-    Directory tempDirectory,
-    IOSink outputSink,
-  );
+  /// These diffs are appended at the end of updated file generations for better
+  /// test readability.
+  ///
+  /// If [commented] is set, the output will be wrapped in multiline comments
+  /// and the diff separator.
+  ///
+  /// If [trimHeaders] is set, the leading '+++' and '---' file headers will be
+  /// removed.
+  String _diffWithFileUris(
+    Uri file1,
+    Uri file2, {
+    String label = '',
+    bool commented = true,
+    bool trimHeaders = true,
+  }) {
+    final file1Path = file1.toFilePath();
+    final file2Path = file2.toFilePath();
+    final diffArgs = ['diff', '-u', file1Path, file2Path];
+    _debugPrint(
+      "Running diff with 'git diff ${diffArgs.join(' ')}'.",
+      label: label,
+    );
+    final diffProcess = Process.runSync('git', diffArgs);
+    final errOutput = diffProcess.stderr as String;
+    if (errOutput.isNotEmpty) {
+      throw Exception('git diff failed with:\n$errOutput');
+    }
+    var output = diffProcess.stdout as String;
+    if (trimHeaders) {
+      // Skip the diff header. 'git diff' has 5 lines in its header.
+      // TODO(markzipan): Add support for Windows-style line endings.
+      output = output.split('\n').skip(5).join('\n');
+    }
+    return commented ? '$testDiffSeparator\n/*\n$output*/' : output;
+  }
+
+  /// Returns the code and diff portions of [file] with all leading and trailing
+  /// whitespace trimmed.
+  (String, String) _splitTestByDiff(Uri file) {
+    final text = File.fromUri(file).readAsStringSync();
+    final diffIndex = text.indexOf(testDiffSeparator);
+    final diffSplitIndex = diffIndex == -1 ? text.length - 1 : diffIndex;
+    final codeText = text.substring(0, diffSplitIndex).trim();
+    final diffText = text.substring(diffSplitIndex, text.length - 1).trim();
+    return (codeText, diffText);
+  }
+
+  /// Runs the [command] with [args] in [environment].
+  ///
+  /// Will echo the commands to the console before running them when running in
+  /// `verbose` mode.
+  Future<Process> startProcess(
+    String name,
+    String command,
+    List<String> args, {
+    Map<String, String> environment = const {},
+    ProcessStartMode mode = ProcessStartMode.normal,
+  }) {
+    if (options.verbose) {
+      print('Running $name:\n$command ${args.join(' ')}\n');
+      if (environment.isNotEmpty) {
+        var environmentVariables = environment.entries
+            .map((e) => '${e.key}: ${e.value}')
+            .join('\n');
+        print('With environment:\n$environmentVariables\n');
+      }
+    }
+    return Process.start(command, args, mode: mode, environment: environment);
+  }
 
   /// Reports test results to standard out as well as the output .json file if
   /// requested.
@@ -1029,175 +1211,157 @@
       exit(0);
     }
   }
+}
 
-  /// Runs the [command] with [args] in [environment].
-  ///
-  /// Will echo the commands to the console before running them when running in
-  /// `verbose` mode.
-  Future<Process> startProcess(
-    String name,
-    String command,
-    List<String> args, {
-    Map<String, String> environment = const {},
-    ProcessStartMode mode = ProcessStartMode.normal,
-  }) {
-    if (options.verbose) {
-      print('Running $name:\n$command ${args.join(' ')}\n');
-      if (environment.isNotEmpty) {
-        var environmentVariables = environment.entries
-            .map((e) => '${e.key}: ${e.value}')
-            .join('\n');
-        print('With environment:\n$environmentVariables\n');
-      }
-    }
-    return Process.start(command, args, mode: mode, environment: environment);
-  }
+abstract class DdcStandaloneSuiteRunner
+    extends HotReloadSuiteRunner<BazelWorkerDriver>
+    with DdcResolver {
+  String? acceptedDill;
+  String? pendingDill;
+  late DdcStandaloneFileResolver _fileResolver;
+  FileResolver get fileResolver => _fileResolver;
 
-  /// Prints messages if 'verbose' mode is enabled.
-  void _print(String message, {String? label}) {
-    if (options.verbose) {
-      final labelText = label == null ? '' : '($label)';
-      print('hot_reload_test$labelText: $message');
-    }
-  }
+  DdcStandaloneSuiteRunner(super.options);
 
-  /// Prints messages if 'debug' mode is enabled.
-  void _debugPrint(String message, {String? label}) {
-    if (options.debug) {
-      final labelText = label == null ? '' : '($label)';
-      print('DEBUG$labelText: $message');
-    }
-  }
+  @override
+  String get modeNamePrefix => 'standalone_';
 
-  /// Returns the diff'd output between two files.
-  ///
-  /// These diffs are appended at the end of updated file generations for better
-  /// test readability.
-  ///
-  /// If [commented] is set, the output will be wrapped in multiline comments
-  /// and the diff separator.
-  ///
-  /// If [trimHeaders] is set, the leading '+++' and '---' file headers will be
-  /// removed.
-  String _diffWithFileUris(
-    Uri file1,
-    Uri file2, {
-    String label = '',
-    bool commented = true,
-    bool trimHeaders = true,
-  }) {
-    final file1Path = file1.toFilePath();
-    final file2Path = file2.toFilePath();
-    final diffArgs = ['diff', '-u', file1Path, file2Path];
-    _debugPrint(
-      "Running diff with 'git diff ${diffArgs.join(' ')}'.",
-      label: label,
+  @override
+  BazelWorkerDriver createController() {
+    final sdkPath = Uri.parse(p.dirname(Platform.resolvedExecutable));
+    final aotRuntime = sdkPath.resolve('bin/dartaotruntime');
+    final ddcSnapshot = sdkPath.resolve(
+      'bin/snapshots/dartdevc_aot.dart.snapshot',
     );
-    final diffProcess = Process.runSync('git', diffArgs);
-    final errOutput = diffProcess.stderr as String;
-    if (errOutput.isNotEmpty) {
-      throw Exception('git diff failed with:\n$errOutput');
-    }
-    var output = diffProcess.stdout as String;
-    if (trimHeaders) {
-      // Skip the diff header. 'git diff' has 5 lines in its header.
-      // TODO(markzipan): Add support for Windows-style line endings.
-      output = output.split('\n').skip(5).join('\n');
-    }
-    return commented ? '$testDiffSeparator\n/*\n$output*/' : output;
+    _print('Starting DDC worker with: ${aotRuntime.path} ${ddcSnapshot.path}');
+
+    return BazelWorkerDriver(
+      () => startProcess('DDC', aotRuntime.path, [
+        ddcSnapshot.path,
+        '--persistent_worker',
+      ]),
+      maxWorkers: 1,
+      maxRetries: 0,
+    );
   }
 
-  /// Returns the code and diff portions of [file] with all leading and trailing
-  /// whitespace trimmed.
-  (String, String) _splitTestByDiff(Uri file) {
-    final text = File.fromUri(file).readAsStringSync();
-    final diffIndex = text.indexOf(testDiffSeparator);
-    final diffSplitIndex = diffIndex == -1 ? text.length - 1 : diffIndex;
-    final codeText = text.substring(0, diffSplitIndex).trim();
-    final diffText = text.substring(diffSplitIndex, text.length - 1).trim();
-    return (codeText, diffText);
+  String _createDeltaKernelPath(int generation) {
+    return pendingDill = emittedFilesDir.uri
+        .resolve('delta$generation.dill')
+        .path;
+  }
+
+  List<String> _getDdcArguments(String deltaKernelPath, bool recompile) {
+    return [
+      '--modules=ddc',
+      '--canary',
+      '--packages=${packageConfigUri.toFilePath()}',
+      '--dart-sdk-summary=$ddcPlatformDillFromSdkRoot',
+      '--reload-delta-kernel=$deltaKernelPath',
+      '--multi-root=${snapshotDir.uri.toFilePath()}',
+      '--multi-root-scheme=$filesystemScheme',
+      '--experimental-output-compiled-kernel',
+      '--experimental-emit-debug-metadata',
+      if (recompile && acceptedDill != null)
+        '--reload-last-accepted-kernel=$acceptedDill',
+      '-o',
+      emittedFilesDir.uri.resolve('out.js').path,
+      snapshotEntrypointWithScheme,
+    ];
+  }
+
+  @override
+  Future<CompiledOutput> sendCompile(
+    BazelWorkerDriver controller,
+    HotReloadTest test,
+  ) async {
+    final deltaKernelPath = _createDeltaKernelPath(0);
+    final response = await controller.doWork(
+      WorkRequest(
+        arguments: _getDdcArguments(deltaKernelPath, false),
+        inputs: [Input(path: deltaKernelPath)],
+      ),
+    );
+    return DdcWorkerOutput(outputDillUri.path, response);
+  }
+
+  @override
+  Future<CompiledOutput> sendRecompile(
+    BazelWorkerDriver controller,
+    HotReloadTest test,
+    int generation,
+    List<String> updatedFiles,
+  ) async {
+    final deltaKernelPath = _createDeltaKernelPath(generation);
+    final response = await controller.doWork(
+      WorkRequest(
+        arguments: _getDdcArguments(deltaKernelPath, true),
+        inputs: [Input(path: deltaKernelPath)],
+      ),
+    );
+    return DdcWorkerOutput(outputDillUri.path, response);
+  }
+
+  @override
+  void accept(BazelWorkerDriver controller) {
+    if (pendingDill == null) {
+      throw StateError('No pending dill to accept.');
+    }
+    acceptedDill = pendingDill;
+    pendingDill = null;
+  }
+
+  @override
+  Future<void> reject(BazelWorkerDriver controller) async {
+    pendingDill = null;
+  }
+
+  @override
+  Future<void> stopController(BazelWorkerDriver controller) async {
+    await controller.terminateWorkers();
+    _print('DDC worker has shut down.');
+  }
+
+  @override
+  void registerOutputDirectory(HotReloadTest test, Uri outputDirectory) {
+    _fileResolver = DdcStandaloneFileResolver(test, outputDirectory);
+  }
+
+  @override
+  void emitFiles(HotReloadTest test, CompiledOutput output, int generation) {
+    _fileResolver.saveGenerationMetadata(
+      test,
+      File(output.outputDillPath).parent.uri.resolve('out.js.metadata'),
+      generation,
+    );
   }
 }
 
-/// Hot reload test suite runner for DDC specific behavior that is agnostic to
-/// the environment where the compiled code is eventually run.
-abstract class DdcSuiteRunner extends HotReloadSuiteRunner {
-  late final String entrypointLibraryExportName = ddc_names
-      .libraryUriToJsIdentifier(snapshotEntrypointUri);
-  final Uri dartSdkJSUri = buildRootUri.resolve(
-    'gen/utils/ddc/canary/sdk/ddc/dart_sdk.js',
-  );
-  final Uri ddcModuleLoaderJSUri = sdkRoot.resolve(
-    'pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js',
-  );
-  final String ddcPlatformDillFromSdkRoot = fe_shared.relativizeUri(
-    sdkRoot,
-    buildRootUri.resolve('ddc_outline.dill'),
-    fe_shared.isWindows,
-  );
-  final String entrypointModuleName = 'hot-reload-test:///main.dart';
-
-  DdcSuiteRunner(super.options);
+/// A mixin that provides common logic for resolving DDC compilation outputs.
+///
+/// [T] is the controller type being used to compile the DDC targets.
+mixin DdcResolver<T> on HotReloadSuiteRunner<T> {
+  void accept(T controller);
+  Future<void> reject(T controller);
+  void emitFiles(HotReloadTest test, CompiledOutput output, int generation);
 
   @override
-  List<String> get platformFrontEndServerArgs => [
-    '--dartdevc-module-format=ddc',
-    '--dartdevc-canary',
-    '--platform=$ddcPlatformDillFromSdkRoot',
-    '--target=dartdevc',
-  ];
-
-  @override
-  Future<bool> compileGeneration(
+  Future<bool> resolveOutput(
+    T controller,
     HotReloadTest test,
+    CompiledOutput output,
     int generation,
-    Directory outputDirectory,
-    List<String> updatedFiles,
-    HotReloadFrontendServerController controller,
   ) async {
-    // The first generation calls `compile`, but subsequent ones call
-    // `recompile`.
-    // Likewise, use the incremental output file for `recompile` calls.
-    // TODO(nshahan): Sending compile/recompile instructions is likely
-    // the same across backends and should be shared code.
-    String outputDillPath;
-    _print(
-      'Compiling generation $generation with the Frontend Server.',
-      label: test.name,
-    );
-    CompilerOutput compilerOutput;
-    if (generation == 0) {
-      _debugPrint(
-        'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
-        label: test.name,
-      );
-      outputDillPath = outputDillUri.toFilePath();
-      compilerOutput = await controller.sendCompile(
-        snapshotEntrypointWithScheme,
-      );
-    } else {
-      _debugPrint(
-        'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme',
-        label: test.name,
-      );
-      outputDillPath = outputIncrementalDillUri.toFilePath();
-      compilerOutput = await controller.sendRecompile(
-        snapshotEntrypointWithScheme,
-        invalidatedFiles: updatedFiles,
-        recompileRestart: test.isHotRestart[generation]!,
-      );
-    }
     final expectedError = test.expectedErrors[generation];
-    if (compilerOutput.errorCount > 0) {
+    if (output.errorCount > 0) {
       // Frontend Server reported compile errors.
-      await controller.sendReject();
-      if (expectedError != null &&
-          compilerOutput.outputText.contains(expectedError)) {
+      await reject(controller);
+      if (expectedError != null && output.outputText.contains(expectedError)) {
         // If the failure was an expected rejection it is OK to continue
         // compiling generations and run the test.
         _debugPrint(
           'DDC rejected generation $generation: '
-          '"${compilerOutput.outputText}"',
+          '"${output.outputText}"',
           label: test.name,
         );
         // Remove the expected error from this test to avoid expecting it to
@@ -1209,14 +1373,14 @@
         // Fail if the error was unexpected.
         await reportTestOutcome(
           test.name,
-          'Test failed with compile error: ${compilerOutput.outputText}',
+          'Test failed with compile error: ${output.outputText}',
           false,
         );
         return false;
       }
     } else {
       // No errors were reported.
-      controller.sendAccept();
+      accept(controller);
     }
     if (expectedError != null) {
       // A rejection error was expected but not seen.
@@ -1230,13 +1394,126 @@
     }
     _debugPrint(
       'Frontend Server successfully compiled outputs to: '
-      '$outputDillPath',
+      '${output.outputDillPath}',
       label: test.name,
     );
+
+    emitFiles(test, output, generation);
+    return true;
+  }
+}
+
+class ChromeStandaloneSuiteRunner extends DdcStandaloneSuiteRunner
+    with ChromeTestRunner {
+  ChromeStandaloneSuiteRunner(super.options);
+}
+
+class D8StandaloneSuiteRunner extends DdcStandaloneSuiteRunner
+    with D8TestRunner {
+  D8StandaloneSuiteRunner(super.options);
+}
+
+/// Hot reload test suite runner for backend agnostic behavior compiled by the
+/// FE server.
+abstract class HotReloadFeServerSuiteRunner
+    extends HotReloadSuiteRunner<HotReloadFrontendServerController> {
+  /// The output location for the incremental .dill file created by the front
+  /// end server.
+  late final Uri outputIncrementalDillUri = emittedFilesDir.uri.resolve(
+    'output_incremental.dill',
+  );
+
+  HotReloadFeServerSuiteRunner(super.options);
+
+  /// Custom command line arguments passed to the Front End Server on startup.
+  List<String> get platformFrontEndServerArgs;
+
+  /// Returns a controller for a freshly started front end server instance to
+  /// handle compile and recompile requests for a hot reload test.
+  @override
+  HotReloadFrontendServerController createController() {
+    _print('Initializing the Frontend Server.');
+    final fesArgs = [
+      '--incremental',
+      '--filesystem-root=${snapshotDir.uri.toFilePath()}',
+      '--filesystem-scheme=$filesystemScheme',
+      '--output-dill=${outputDillUri.toFilePath()}',
+      '--output-incremental-dill=${outputIncrementalDillUri.toFilePath()}',
+      '--packages=${packageConfigUri.toFilePath()}',
+      '--sdk-root=${sdkRoot.toFilePath()}',
+      '--verbosity=${options.verbose ? 'all' : 'info'}',
+      ...platformFrontEndServerArgs,
+    ];
+    return HotReloadFrontendServerController(fesArgs)..start();
+  }
+
+  @override
+  Future<void> stopController(
+    HotReloadFrontendServerController controller,
+  ) async {
+    await controller.stop();
+    _print('Frontend Server has shut down.');
+  }
+
+  @override
+  Future<CompiledOutput> sendCompile(
+    HotReloadFrontendServerController controller,
+    HotReloadTest test,
+  ) async {
     _debugPrint(
-      'Emitting JS code to ${outputDirectory.path}.',
+      'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
       label: test.name,
     );
+    final outputDillPath = outputDillUri.toFilePath();
+    final compilerOutput = await controller.sendCompile(
+      snapshotEntrypointWithScheme,
+    );
+    return FrontendServerOutput(outputDillPath, compilerOutput);
+  }
+
+  @override
+  Future<CompiledOutput> sendRecompile(
+    HotReloadFrontendServerController controller,
+    HotReloadTest test,
+    int generation,
+    List<String> updatedFiles,
+  ) async {
+    final outputDillPath = outputIncrementalDillUri.toFilePath();
+    final compilerOutput = await controller.sendRecompile(
+      snapshotEntrypointWithScheme,
+      invalidatedFiles: updatedFiles,
+      recompileRestart: test.isHotRestart[generation]!,
+    );
+    return FrontendServerOutput(outputDillPath, compilerOutput);
+  }
+}
+
+/// Hot reload test suite runner for DDC specific behavior compiled by the FE
+/// server that is agnostic to the environment (d8 vs. chrome) where the
+/// compiled code is eventually run.
+abstract class DdcFeServerSuiteRunner extends HotReloadFeServerSuiteRunner
+    with DdcResolver {
+  late HotReloadMemoryFilesystem filesystem;
+  FileResolver get fileResolver => filesystem;
+
+  DdcFeServerSuiteRunner(super.options);
+
+  @override
+  void registerOutputDirectory(HotReloadTest test, Uri outputDirectory) {
+    filesystem = HotReloadMemoryFilesystem(outputDirectory);
+  }
+
+  @override
+  List<String> get platformFrontEndServerArgs => [
+    '--dartdevc-module-format=ddc',
+    '--dartdevc-canary',
+    '--platform=$ddcPlatformDillFromSdkRoot',
+    '--target=dartdevc',
+  ];
+
+  @override
+  void emitFiles(HotReloadTest test, CompiledOutput output, int generation) {
+    final outputDillPath = output.outputDillPath;
     // Update the memory filesystem with the newly-created JS files.
     _print(
       'Loading generation $generation files into the memory filesystem.',
@@ -1245,7 +1522,7 @@
     final codeFile = File('$outputDillPath.sources');
     final manifestFile = File('$outputDillPath.json');
     final sourcemapFile = File('$outputDillPath.map');
-    filesystem!.update(
+    filesystem.update(
       codeFile,
       manifestFile,
       sourcemapFile,
@@ -1254,69 +1531,48 @@
     // Write JS files and sourcemaps to their respective generation.
     _print('Writing generation $generation assets.', label: test.name);
     _debugPrint(
-      'Writing JS assets to ${outputDirectory.path}',
+      'Writing JS assets to ${filesystem.jsRootUri.path}',
       label: test.name,
     );
-    filesystem!.writeToDisk(outputDirectory.uri, generation: '$generation');
-    return true;
+    filesystem.writeToDisk(filesystem.jsRootUri, generation: '$generation');
   }
-}
-
-/// Hot reload test suite runner for behavior specific to DDC compiled code
-/// running in D8.
-class D8SuiteRunner extends DdcSuiteRunner {
-  D8SuiteRunner(super.options);
 
   @override
-  Future<bool> runTest(
-    HotReloadTest test,
-    Directory tempDirectory,
-    IOSink outputSink,
-  ) async {
-    _print('Creating D8 hot reload test suite.', label: test.name);
-    final bootstrapJSUri = tempDirectory.uri.resolve(
-      'generation0/bootstrap.js',
-    );
-    _print('Preparing to run D8 test.', label: test.name);
-    final d8BootstrapJS = ddc_helpers.generateD8Bootstrapper(
-      ddcModuleLoaderJsPath: escapedString(ddcModuleLoaderJSUri.toFilePath()),
-      dartSdkJsPath: escapedString(dartSdkJSUri.toFilePath()),
-      entrypointModuleName: escapedString(entrypointModuleName),
-      entrypointLibraryExportName: escapedString(entrypointLibraryExportName),
-      scriptDescriptors: filesystem!.scriptDescriptorForBootstrap,
-      modifiedFilesPerGeneration: filesystem!.generationsToModifiedFilePaths,
-    );
-    _debugPrint('Writing D8 bootstrapper: $bootstrapJSUri', label: test.name);
-    final bootstrapJSFile = File.fromUri(bootstrapJSUri)
-      ..writeAsStringSync(d8BootstrapJS);
-    _debugPrint('Running test in D8.', label: test.name);
-    final reloadReceipts = <HotReloadReceipt>[];
-    final config = ddc_helpers.D8Configuration(sdkRoot);
-    final process = await startProcess('D8', config.binary.toFilePath(), [
-      config.sealNativeObjectScript.toFilePath(),
-      config.preamblesScript.toFilePath(),
-      bootstrapJSFile.path,
-    ]);
-    process.stdout
-        .transform(utf8.decoder)
-        .transform(LineSplitter())
-        .listen(
-          (line) => parseReloadReceipt(
-            test,
-            line,
-            reloadReceipts.add,
-            outputSink.writeln,
-          ),
-        );
-    return await process.exitCode == 0 &&
-        reloadReceiptCheck(test, reloadReceipts);
+  void accept(HotReloadFrontendServerController controller) {
+    controller.sendAccept();
   }
+
+  @override
+  Future<void> reject(HotReloadFrontendServerController controller) =>
+      controller.sendReject();
 }
 
-/// Hot reload test suite runner for behavior specific to DDC compiled code
-/// running in Chrome.
-class ChromeSuiteRunner extends DdcSuiteRunner {
-  ChromeSuiteRunner(super.options);
+mixin ChromeTestRunner<T> on HotReloadSuiteRunner<T> {
+  FileResolver get fileResolver;
+
+  @override
+  Future<void> runSuite() async {
+    // Only allow Chrome when debugging a single test.
+    // TODO(markzipan): Add support for full Chrome testing.
+    if (options.runtime == RuntimePlatforms.chrome) {
+      var matchingTests = Directory.fromUri(allTestsUri).listSync().where((
+        testDir,
+      ) {
+        if (testDir is! Directory) return false;
+        final testDirParts = testDir.uri.pathSegments;
+        final testName = testDirParts[testDirParts.length - 2];
+        return options.testNameFilter.hasMatch(testName);
+      });
+
+      if (matchingTests.length > 1) {
+        throw Exception(
+          'Chrome is only supported when debugging a single test.'
+          "Please filter on a single test with '-f'.",
+        );
+      }
+    }
+    await super.runSuite();
+  }
 
   @override
   Future<bool> runTest(
@@ -1367,8 +1623,8 @@
         mainEntrypointJSUri.toFilePath(),
       ),
       entrypointLibraryExportName: escapedString(entrypointLibraryExportName),
-      scriptDescriptors: filesystem!.scriptDescriptorForBootstrap,
-      modifiedFilesPerGeneration: filesystem!.generationsToModifiedFilePaths,
+      scriptDescriptors: fileResolver.scriptDescriptorForBootstrap,
+      modifiedFilesPerGeneration: fileResolver.generationsToModifiedFilePaths,
     );
     _debugPrint(
       'Writing Chrome bootstrap files: '
@@ -1470,39 +1726,77 @@
     return await process.exitCode == 0 &&
         reloadReceiptCheck(test, reloadReceipts);
   }
+}
+
+mixin D8TestRunner<T> on HotReloadSuiteRunner<T> {
+  FileResolver get fileResolver;
 
   @override
-  Future<void> runSuite(Options options) async {
-    // Only allow Chrome when debugging a single test.
-    // TODO(markzipan): Add support for full Chrome testing.
-    if (options.runtime == RuntimePlatforms.chrome) {
-      var matchingTests = Directory.fromUri(allTestsUri).listSync().where((
-        testDir,
-      ) {
-        if (testDir is! Directory) return false;
-        final testDirParts = testDir.uri.pathSegments;
-        final testName = testDirParts[testDirParts.length - 2];
-        return options.testNameFilter.hasMatch(testName);
-      });
-
-      if (matchingTests.length > 1) {
-        throw Exception(
-          'Chrome is only supported when debugging a single test.'
-          "Please filter on a single test with '-f'.",
+  Future<bool> runTest(
+    HotReloadTest test,
+    Directory tempDirectory,
+    IOSink outputSink,
+  ) async {
+    _print('Creating D8 hot reload test suite.', label: test.name);
+    final bootstrapJSUri = tempDirectory.uri.resolve(
+      'generation0/bootstrap.js',
+    );
+    _print('Preparing to run D8 test.', label: test.name);
+    final d8BootstrapJS = ddc_helpers.generateD8Bootstrapper(
+      ddcModuleLoaderJsPath: escapedString(ddcModuleLoaderJSUri.toFilePath()),
+      dartSdkJsPath: escapedString(dartSdkJSUri.toFilePath()),
+      entrypointModuleName: escapedString(entrypointModuleName),
+      entrypointLibraryExportName: escapedString(entrypointLibraryExportName),
+      scriptDescriptors: fileResolver.scriptDescriptorForBootstrap,
+      modifiedFilesPerGeneration: fileResolver.generationsToModifiedFilePaths,
+    );
+    _debugPrint('Writing D8 bootstrapper: $bootstrapJSUri', label: test.name);
+    final bootstrapJSFile = File.fromUri(bootstrapJSUri)
+      ..writeAsStringSync(d8BootstrapJS);
+    _debugPrint('Running test in D8.', label: test.name);
+    final reloadReceipts = <HotReloadReceipt>[];
+    final config = ddc_helpers.D8Configuration(sdkRoot);
+    final process = await startProcess('D8', config.binary.toFilePath(), [
+      config.sealNativeObjectScript.toFilePath(),
+      config.preamblesScript.toFilePath(),
+      bootstrapJSFile.path,
+    ]);
+    process.stdout
+        .transform(utf8.decoder)
+        .transform(LineSplitter())
+        .listen(
+          (line) => parseReloadReceipt(
+            test,
+            line,
+            reloadReceipts.add,
+            outputSink.writeln,
+          ),
         );
-      }
-    }
-    await super.runSuite(options);
+    return await process.exitCode == 0 &&
+        reloadReceiptCheck(test, reloadReceipts);
   }
 }
 
+/// Hot reload test suite runner for behavior specific to DDC compiled code
+/// running in Chrome.
+class ChromeSuiteRunner extends DdcFeServerSuiteRunner with ChromeTestRunner {
+  ChromeSuiteRunner(super.options);
+}
+
+/// Hot reload test suite runner for behavior specific to DDC compiled code
+/// running in D8.
+class D8SuiteRunner extends DdcFeServerSuiteRunner with D8TestRunner {
+  D8SuiteRunner(super.options);
+}
+
 /// Hot reload test suite runner for behavior specific to the VM.
-class VMSuiteRunner extends HotReloadSuiteRunner {
+class VMSuiteRunner extends HotReloadFeServerSuiteRunner {
   final String vmPlatformDillFromSdkRoot = fe_shared.relativizeUri(
     sdkRoot,
     buildRootUri.resolve('vm_platform_strong.dill'),
     fe_shared.isWindows,
   );
+  late Uri outputDirectoryUri;
 
   VMSuiteRunner(super.options);
 
@@ -1513,113 +1807,6 @@
   ];
 
   @override
-  Future<bool> compileGeneration(
-    HotReloadTest test,
-    int generation,
-    Directory outputDirectory,
-    List<String> updatedFiles,
-    HotReloadFrontendServerController controller,
-  ) async {
-    // The first generation calls `compile`, but subsequent ones call
-    // `recompile`.
-    // Likewise, use the incremental output directory for `recompile` calls.
-    // TODO(nshahan): Sending compile/recompile instructions is likely
-    // the same across backends and should be shared code.
-    String outputDillPath;
-    _print(
-      'Compiling generation $generation with the Frontend Server.',
-      label: test.name,
-    );
-    CompilerOutput compilerOutput;
-    if (generation == 0) {
-      _debugPrint(
-        'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
-        label: test.name,
-      );
-      outputDillPath = outputDillUri.toFilePath();
-      compilerOutput = await controller.sendCompile(
-        snapshotEntrypointWithScheme,
-      );
-    } else {
-      _debugPrint(
-        'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme',
-        label: test.name,
-      );
-      outputDillPath = outputIncrementalDillUri.toFilePath();
-      // TODO(markzipan): Add logic to reject bad compiles.
-      compilerOutput = await controller.sendRecompile(
-        snapshotEntrypointWithScheme,
-        invalidatedFiles: updatedFiles,
-        // The VM never uses the `recompile-restart` instruction as it does
-        // not recompile during a hot restart.
-        recompileRestart: false,
-      );
-    }
-    var hasExpectedCompileError = false;
-    final expectedError = test.expectedErrors[generation];
-    // Frontend Server reported compile errors. Fail if they weren't
-    // expected, and do not run tests.
-    if (compilerOutput.errorCount > 0) {
-      await controller.sendReject();
-      if (expectedError != null &&
-          compilerOutput.outputText.contains(expectedError)) {
-        hasExpectedCompileError = true;
-        _debugPrint(
-          'VM rejected generation $generation: '
-          '"${compilerOutput.outputText}"',
-          label: test.name,
-        );
-        // Remove the expected error from this test to avoid expecting it to
-        // appear as a rejection at runtime.
-        test.expectedErrors[generation] =
-            HotReloadReceipt.compileTimeErrorMessage;
-      } else {
-        await reportTestOutcome(
-          test.name,
-          'Test failed with compile error: ${compilerOutput.outputText}',
-          false,
-        );
-        return false;
-      }
-    } else if (test.expectedErrors.containsKey(generation)) {
-      // Automatically reject generations that are expected to be rejected so
-      // the front end server can update it's internal state correctly. This
-      // ensures the next delta will always be calculated from against the last
-      // accepted generation. The actual rejections will be validated when the
-      // test runs on the VM.
-      await controller.sendReject();
-      _debugPrint(
-        'VM compile automatically rejected generation: $generation.',
-        label: test.name,
-      );
-    } else {
-      controller.sendAccept();
-    }
-    _debugPrint(
-      'Frontend Server successfully compiled outputs to: '
-      '$outputDillPath',
-      label: test.name,
-    );
-    final dillOutputDir = Directory.fromUri(
-      outputDirectory.uri.resolve('generation$generation'),
-    );
-    dillOutputDir.createSync();
-    // Write an .error.dill file as a signal to the runtime utils that this
-    // generation contains compile time errors and should not be reloaded.
-    final dillOutputUri = hasExpectedCompileError
-        ? dillOutputDir.uri.resolve('${test.name}.error.dill')
-        : dillOutputDir.uri.resolve('${test.name}.dill');
-    // Write dills to their respective generation.
-    _print('Writing generation $generation assets.', label: test.name);
-    _debugPrint(
-      'Writing dill to ${dillOutputUri.toFilePath()}',
-      label: test.name,
-    );
-    File(outputDillPath).copySync(dillOutputUri.toFilePath());
-    return true;
-  }
-
-  @override
   Future<bool> runTest(
     HotReloadTest test,
     Directory tempDirectory,
@@ -1671,4 +1858,142 @@
     );
     return vmExitCode == 0 && reloadReceiptCheck(test, reloadReceipts);
   }
+
+  @override
+  Future<bool> resolveOutput(
+    HotReloadFrontendServerController controller,
+    HotReloadTest test,
+    CompiledOutput output,
+    int generation,
+  ) async {
+    final expectedError = test.expectedErrors[generation];
+    var hasExpectedCompileError = false;
+    // Frontend Server reported compile errors. Fail if they weren't
+    // expected, and do not run tests.
+    if (output.errorCount > 0) {
+      await controller.sendReject();
+      if (expectedError != null && output.outputText.contains(expectedError)) {
+        hasExpectedCompileError = true;
+        _debugPrint(
+          'VM rejected generation $generation: '
+          '"${output.outputText}"',
+          label: test.name,
+        );
+        // Remove the expected error from this test to avoid expecting it to
+        // appear as a rejection at runtime.
+        test.expectedErrors[generation] =
+            HotReloadReceipt.compileTimeErrorMessage;
+      } else {
+        await reportTestOutcome(
+          test.name,
+          'Test failed with compile error: ${output.outputText}',
+          false,
+        );
+        return false;
+      }
+    } else if (test.expectedErrors.containsKey(generation)) {
+      // Automatically reject generations that are expected to be rejected so
+      // the front end server can update it's internal state correctly. This
+      // ensures the next delta will always be calculated from against the last
+      // accepted generation. The actual rejections will be validated when the
+      // test runs on the VM.
+      await controller.sendReject();
+      _debugPrint(
+        'VM compile automatically rejected generation: $generation.',
+        label: test.name,
+      );
+    } else {
+      controller.sendAccept();
+    }
+
+    final outputDillPath = output.outputDillPath;
+    final dillOutputDir = Directory.fromUri(
+      outputDirectoryUri.resolve('generation$generation'),
+    );
+    dillOutputDir.createSync();
+    // Write an .error.dill file as a signal to the runtime utils that this
+    // generation contains compile time errors and should not be reloaded.
+    final dillOutputUri = hasExpectedCompileError
+        ? dillOutputDir.uri.resolve('${test.name}.error.dill')
+        : dillOutputDir.uri.resolve('${test.name}.dill');
+    // Write dills to their respective generation.
+    _print('Writing generation $generation assets.', label: test.name);
+    _debugPrint(
+      'Writing dill to ${dillOutputUri.toFilePath()}',
+      label: test.name,
+    );
+    File(outputDillPath).copySync(dillOutputUri.toFilePath());
+    return true;
+  }
+
+  @override
+  void registerOutputDirectory(HotReloadTest test, Uri outputDirectory) {
+    outputDirectoryUri = outputDirectory;
+  }
+}
+
+class DdcStandaloneFileResolver implements FileResolver {
+  final HotReloadTest test;
+  final Uri outputDirectory;
+
+  final Map<int, (String, List<String>)> _perGenerationMetadata = {};
+  final List<String> _firstGenerationLibraries = [];
+  late final String _firstGenerationFile;
+
+  DdcStandaloneFileResolver(this.test, this.outputDirectory);
+
+  void saveGenerationMetadata(
+    HotReloadTest test,
+    Uri metadataFileUri,
+    int generation,
+  ) {
+    final metadataFile = File.fromUri(metadataFileUri);
+    final metadataString = metadataFile.readAsStringSync();
+    final metadataJson = jsonDecode(metadataString);
+    final librariesJson = metadataJson['libraries'] as List<dynamic>;
+    final libraryUris = [...librariesJson.map((e) => e['importUri'] as String)];
+    final baseFilename = Uri.parse(metadataJson['moduleUri'] as String);
+    final generationDir = outputDirectory.resolve('generation$generation/');
+    final renamedFilename = generationDir.resolve('out.js');
+    _perGenerationMetadata[generation] = (renamedFilename.path, libraryUris);
+    if (generation == 0) {
+      _firstGenerationLibraries.addAll(libraryUris);
+      _firstGenerationFile = renamedFilename.toFilePath(
+        windows: Platform.isWindows,
+      );
+    }
+    Directory.fromUri(generationDir).createSync();
+    (File.fromUri(baseFilename)..copySync(renamedFilename.path));
+  }
+
+  // Test used to simulate DartPad style hot reload where only a single known
+  // library is edited. There are multiple libraries that get compiled into the
+  // JS bundle but only the main library needs to be updated. This ensures that
+  // the DDC runtime ignores the extra libraries.
+  static const String _mainOnlyTestName = 'main_only';
+
+  @override
+  List<Map<String, String?>> get scriptDescriptorForBootstrap {
+    // Only include a single library since the code for library is all included
+    // in the one file.
+    return <Map<String, String?>>[
+      {'id': _firstGenerationLibraries.first, 'src': _firstGenerationFile},
+    ];
+  }
+
+  @override
+  Map<String, List<List<String>>> get generationsToModifiedFilePaths {
+    if (test.name == _mainOnlyTestName) {
+      return {
+        for (var e in _perGenerationMetadata.entries)
+          '${e.key}': [
+            [e.value.$2.firstWhere((l) => l.contains('main.dart')), e.value.$1],
+          ],
+      };
+    }
+    return {
+      for (var e in _perGenerationMetadata.entries)
+        '${e.key}': e.value.$2.map((l) => [l, e.value.$1]).toList(),
+    };
+  }
 }
diff --git a/pkg/reload_test/lib/hot_reload_memory_filesystem.dart b/pkg/reload_test/lib/hot_reload_memory_filesystem.dart
index 0e813ae..820c411 100644
--- a/pkg/reload_test/lib/hot_reload_memory_filesystem.dart
+++ b/pkg/reload_test/lib/hot_reload_memory_filesystem.dart
@@ -11,6 +11,18 @@
 
 import 'package:reload_test/ddc_helpers.dart' show FileDataPerGeneration;
 
+abstract class FileResolver {
+  /// Returns all scripts in the filesystem in a form that can be ingested by
+  /// the DDC module system's bootstrapper.
+  /// Files must only be in the first generation.
+  List<Map<String, String?>> get scriptDescriptorForBootstrap;
+
+  /// Returns a map of generation number to modified files' paths.
+  ///
+  /// Used to determine which JS files should be loaded per generation.
+  FileDataPerGeneration get generationsToModifiedFilePaths;
+}
+
 /// A pseudo in-memory filesystem with helpers to aid the hot reload runner.
 ///
 /// The Frontend Server outputs web sources and sourcemaps as concatenated
@@ -18,7 +30,7 @@
 /// for resolving the individual files.
 /// Adapted from:
 /// https://github.com/flutter/flutter/blob/ac7879e2aa6de40afec1fe2af9730a8d55de3e06/packages/flutter_tools/lib/src/web/memory_fs.dart
-class HotReloadMemoryFilesystem {
+class HotReloadMemoryFilesystem implements FileResolver {
   /// The root directory's URI from which JS file are being served.
   final Uri jsRootUri;
 
@@ -54,9 +66,7 @@
     }
   }
 
-  /// Returns a map of generation number to modified files' paths.
-  ///
-  /// Used to determine which JS files should be loaded per generation.
+  @override
   FileDataPerGeneration get generationsToModifiedFilePaths => {
         for (var e in generationChanges.entries)
           e.key: e.value
@@ -64,9 +74,7 @@
               .toList()
       };
 
-  /// Returns all scripts in the filesystem in a form that can be ingested by
-  /// the DDC module system's bootstrapper.
-  /// Files must only be in the first generation.
+  @override
   List<Map<String, String?>> get scriptDescriptorForBootstrap {
     // TODO(markzipan): This currently isn't ordered, which may cause problems
     // with cycles.
diff --git a/tests/hot_reload/main_only/b.0.dart b/tests/hot_reload/main_only/b.0.dart
new file mode 100644
index 0000000..1baefc3
--- /dev/null
+++ b/tests/hot_reload/main_only/b.0.dart
@@ -0,0 +1 @@
+get line => "part1";
diff --git a/tests/hot_reload/main_only/main.0.dart b/tests/hot_reload/main_only/main.0.dart
new file mode 100644
index 0000000..d0a1a7d
--- /dev/null
+++ b/tests/hot_reload/main_only/main.0.dart
@@ -0,0 +1,26 @@
+// Copyright (c) 2025, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+import 'b.dart';
+
+f() => "$line part2";
+
+Future<void> main() async {
+  var last = f();
+  Expect.equals('part1 part2', last);
+  Expect.equals(0, hotReloadGeneration);
+  await hotReload();
+
+  last = f();
+  Expect.equals('part1 part3', last);
+  Expect.equals(1, hotReloadGeneration);
+  await hotReload();
+
+  last = f();
+  Expect.equals('part1 part4', last);
+  Expect.equals(2, hotReloadGeneration);
+}
diff --git a/tests/hot_reload/main_only/main.1.dart b/tests/hot_reload/main_only/main.1.dart
new file mode 100644
index 0000000..7b8739c
--- /dev/null
+++ b/tests/hot_reload/main_only/main.1.dart
@@ -0,0 +1,38 @@
+// Copyright (c) 2025, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+import 'b.dart';
+
+f() => "$line part3";
+
+Future<void> main() async {
+  var last = f();
+  Expect.equals('part1 part2', last);
+  Expect.equals(0, hotReloadGeneration);
+  await hotReload();
+
+  last = f();
+  Expect.equals('part1 part3', last);
+  Expect.equals(1, hotReloadGeneration);
+  await hotReload();
+
+  last = f();
+  Expect.equals('part1 part4', last);
+  Expect.equals(2, hotReloadGeneration);
+}
+
+/** DIFF **/
+/*
+ 
+ import 'b.dart';
+ 
+-f() => "$line part2";
++f() => "$line part3";
+ 
+ Future<void> main() async {
+   var last = f();
+*/
diff --git a/tests/hot_reload/main_only/main.2.dart b/tests/hot_reload/main_only/main.2.dart
new file mode 100644
index 0000000..5331857
--- /dev/null
+++ b/tests/hot_reload/main_only/main.2.dart
@@ -0,0 +1,38 @@
+// Copyright (c) 2025, 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:expect/expect.dart';
+import 'package:reload_test/reload_test_utils.dart';
+
+import 'b.dart';
+
+f() => "$line part4";
+
+Future<void> main() async {
+  var last = f();
+  Expect.equals('part1 part2', last);
+  Expect.equals(0, hotReloadGeneration);
+  await hotReload();
+
+  last = f();
+  Expect.equals('part1 part3', last);
+  Expect.equals(1, hotReloadGeneration);
+  await hotReload();
+
+  last = f();
+  Expect.equals('part1 part4', last);
+  Expect.equals(2, hotReloadGeneration);
+}
+
+/** DIFF **/
+/*
+ 
+ import 'b.dart';
+ 
+-f() => "$line part3";
++f() => "$line part4";
+ 
+ Future<void> main() async {
+   var last = f();
+*/
diff --git a/tools/bots/test_matrix.json b/tools/bots/test_matrix.json
index e6cb26a..b258204 100644
--- a/tools/bots/test_matrix.json
+++ b/tools/bots/test_matrix.json
@@ -2130,6 +2130,17 @@
           ]
         },
         {
+          "name": "ddc hot reload tests without fe server in d8",
+          "script": "out/ReleaseX64/dart-sdk/bin/dart",
+          "testRunner": true,
+          "arguments": [
+            "pkg/dev_compiler/test/hot_reload_suite.dart",
+            "-nddc-canary-linux-chrome",
+            "--verbose",
+            "--no-use-fe-server"
+          ]
+        },
+        {
           "name": "ddc sdk tests",
           "arguments": [
             "-nddc-canary-linux-chrome",