// Copyright (c) 2024, 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 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:frontend_server/starter.dart'; const frontEndResponsePrefix = 'result '; const fakeBoundaryKey = '42'; final debug = false; /// Represents the output of a FrontendServer's 'compile' or 'recompile'. class CompilerOutput { CompilerOutput({ required this.outputDillPath, required this.errorCount, this.sources = const [], this.outputText = '', }); /// Output for a 'reject' response. factory CompilerOutput.rejectOutput() { return CompilerOutput( outputDillPath: '', errorCount: 0, ); } final String outputDillPath; final int errorCount; final List sources; final String outputText; } enum FrontendServerState { awaitingResult, awaitingKey, collectingResultSources, awaitingReject, awaitingRejectKey, finished, } /// Controls and synchronizes the Frontend Server during hot reload tests. /// /// The Frontend Server accepts the following instructions: /// /// ``` /// > compile /// /// > recompile [] /// /// /// ... /// /// /// > accept /// /// > quit /// /// 'compile' and 'recompile' instructions output the following on completion: /// result /// /// [] /// [] /// ``` class HotReloadFrontendServerController { final List frontendServerArgs; /// Used to send commands to the Frontend Server. final StreamController> input; /// Contains output messages from the Frontend Server. final StreamController> output; /// Contains one event per completed Frontend Server 'compile' or 'recompile' /// command. final StreamController compileCommandOutputChannel; /// An iterator over `compileCommandOutputChannel`. /// Should be awaited after every 'compile' or 'recompile' command. final StreamIterator synchronizer; /// Whether or not this controller has already been started. bool started = false; /// Initialize to an invalid string prior to the first result. String _boundaryKey = 'INVALID'; late Future frontendServerExitCode; /// Source file URIs reported by the Frontend Server. List sources = []; List accumulatedOutput = []; int totalErrors = 0; FrontendServerState _state = FrontendServerState.awaitingResult; HotReloadFrontendServerController._(this.frontendServerArgs, this.input, this.output, this.compileCommandOutputChannel, this.synchronizer); factory HotReloadFrontendServerController(List frontendServerArgs) { var input = StreamController>(); var output = StreamController>(); var compileCommandOutputChannel = StreamController(); var synchronizer = StreamIterator(compileCommandOutputChannel.stream); return HotReloadFrontendServerController._(frontendServerArgs, input, output, compileCommandOutputChannel, synchronizer); } /// Runs the Frontend Server in-memory in incremental mode. /// Must be called once before interacting with the Frontend Server. void start() { if (started) { print('Frontend Server has already been started.'); return; } output.stream .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String s) { if (debug) print('Frontend Server Response: $s'); switch (_state) { case FrontendServerState.awaitingReject: if (!s.startsWith(frontEndResponsePrefix)) { throw Exception('Unexpected Frontend Server response: $s'); } _boundaryKey = s.substring(frontEndResponsePrefix.length); _state = FrontendServerState.awaitingRejectKey; break; case FrontendServerState.awaitingRejectKey: if (s != _boundaryKey) { throw Exception('Unexpected Frontend Server response for reject ' '(expected just a key): $s'); } _state = FrontendServerState.finished; compileCommandOutputChannel.add(CompilerOutput.rejectOutput()); _clearState(); break; case FrontendServerState.awaitingResult: if (!s.startsWith(frontEndResponsePrefix)) { throw Exception('Unexpected Frontend Server response: $s'); } _boundaryKey = s.substring(frontEndResponsePrefix.length); _state = FrontendServerState.awaitingKey; break; case FrontendServerState.awaitingKey: // Advance to the next state when we encounter a lone boundary key. if (s == _boundaryKey) { _state = FrontendServerState.collectingResultSources; } else { accumulatedOutput.add(s); } case FrontendServerState.collectingResultSources: // Stop and record the result when we encounter a boundary key. if (s.startsWith(_boundaryKey)) { final compilationReportOutput = s.split(' '); final outputDillPath = compilationReportOutput[1]; final errorCount = int.parse(compilationReportOutput[2]); // The FrontendServer accumulates all errors seen so far, so we // need to correct for errors from previous compilations. final actualErrorCount = errorCount - totalErrors; final compilerOutput = CompilerOutput( outputDillPath: outputDillPath, errorCount: actualErrorCount, sources: sources, outputText: accumulatedOutput.join('\n'), ); totalErrors = errorCount; _state = FrontendServerState.finished; compileCommandOutputChannel.add(compilerOutput); _clearState(); } else if (s.startsWith('+')) { sources.add(Uri.parse(s.substring(1))); } else if (s.startsWith('-')) { sources.remove(Uri.parse(s.substring(1))); } else { throw Exception("Unexpected Frontend Server response " "(expected '+' or '-')'): $s"); } break; case FrontendServerState.finished: throw StateError('Frontend Server reached an unexpected state: $s'); } }); frontendServerExitCode = starter( frontendServerArgs, input: input.stream, output: IOSink(output.sink), ); started = true; } /// Clears the controller's state between commands. /// /// Note: this does not reset the Frontend Server's state and for that reason /// the local [totalErrors] count is not reset here. void _clearState() { sources.clear(); accumulatedOutput.clear(); _boundaryKey = 'INVALID'; } Future sendCompile(String dartSourcePath) async { if (!started) throw Exception('Frontend Server has not been started yet.'); _state = FrontendServerState.awaitingResult; final command = 'compile $dartSourcePath\n'; if (debug) print('Sending instruction to Frontend Server:\n$command'); input.add(command.codeUnits); await synchronizer.moveNext(); return synchronizer.current; } Future sendCompileAndAccept(String dartSourcePath) async { await sendCompile(dartSourcePath); sendAccept(); } Future sendRecompile(String entrypointPath, {List invalidatedFiles = const [], String boundaryKey = fakeBoundaryKey, required bool recompileRestart}) async { // Currently the `FrontendCompiler` used in this test suite clears errors // before performing a recompile but not an initial compile. Since we reuse // the same instance and issue an initial compile request for each test we // must track the number of errors on our side carefully so we can tell when // a new error has appeared. Resetting the [totalErrors] count here should // match the state on the FES side. totalErrors = 0; if (!started) throw Exception('Frontend Server has not been started yet.'); _state = FrontendServerState.awaitingResult; final instruction = recompileRestart ? 'recompile-restart' : 'recompile'; final command = '$instruction $entrypointPath $boundaryKey\n' '${invalidatedFiles.join('\n')}\n$boundaryKey\n'; if (debug) print('Sending instruction to Frontend Server:\n$command'); input.add(command.codeUnits); await synchronizer.moveNext(); return synchronizer.current; } Future sendRecompileAndAccept(String entrypointPath, {List invalidatedFiles = const [], String boundaryKey = fakeBoundaryKey, required bool recompileRestart}) async { await sendRecompile(entrypointPath, invalidatedFiles: invalidatedFiles, boundaryKey: boundaryKey, recompileRestart: recompileRestart); sendAccept(); } void sendAccept() { if (!started) throw Exception('Frontend Server has not been started yet.'); final command = 'accept\n'; if (debug) print('Sending instruction to Frontend Server:\n$command'); input.add(command.codeUnits); } Future sendReject() async { if (!started) throw Exception('Frontend Server has not been started yet.'); _state = FrontendServerState.awaitingReject; final command = 'reject\n'; if (debug) print('Sending instruction to Frontend Server:\n$command'); input.add(command.codeUnits); await synchronizer.moveNext(); } void _sendQuit() { if (!started) throw Exception('Frontend Server has not been started yet.'); final command = 'quit\n'; if (debug) print('Sending instruction to Frontend Server:\n$command'); input.add(command.codeUnits); } /// Cleanly shuts down the Frontend Server. Future stop() async { _sendQuit(); var exitCode = await frontendServerExitCode; started = false; if (exitCode != 0) { print('Frontend Server exited with non-zero code: $exitCode'); exit(exitCode); } } }