| // 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<Uri> sources; |
| final String outputText; |
| } |
| |
| enum FrontendServerState { |
| awaitingResult, |
| awaitingKey, |
| collectingResultSources, |
| awaitingReject, |
| awaitingRejectKey, |
| finished, |
| } |
| |
| /// Controls and synchronizes the Frontend Server during hot reloaad tests. |
| /// |
| /// The Frontend Server accepts the following instructions: |
| /// > compile <input.dart> |
| /// |
| /// > recompile [<input.dart>] <boundary-key> |
| /// <dart file> |
| /// <dart file> |
| /// ... |
| /// <boundary-key> |
| /// |
| /// > accept |
| /// |
| /// > quit |
| /// |
| /// 'compile' and 'recompile' instructions output the following on completion: |
| /// result <boundary-key> |
| /// <boundary-key> |
| /// [<error text or modified files prefixed by '-' or '+'>] |
| /// <boundary-key> [<output.dill>] <error-count> |
| class HotReloadFrontendServerController { |
| final List<String> frontendServerArgs; |
| |
| /// Used to send commands to the Frontend Server. |
| final StreamController<List<int>> input; |
| |
| /// Contains output messages from the Frontend Server. |
| final StreamController<List<int>> output; |
| |
| /// Contains one event per completed Frontend Server 'compile' or 'recompile' |
| /// command. |
| final StreamController<CompilerOutput> compileCommandOutputChannel; |
| |
| /// An iterator over `compileCommandOutputChannel`. |
| /// Should be awaited after every 'compile' or 'recompile' command. |
| final StreamIterator<CompilerOutput> 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<int> frontendServerExitCode; |
| |
| /// Source file URIs reported by the Frontend Server. |
| List<Uri> sources = []; |
| |
| List<String> accumulatedOutput = []; |
| |
| int totalErrors = 0; |
| |
| FrontendServerState _state = FrontendServerState.awaitingResult; |
| |
| HotReloadFrontendServerController._(this.frontendServerArgs, this.input, |
| this.output, this.compileCommandOutputChannel, this.synchronizer); |
| |
| factory HotReloadFrontendServerController(List<String> frontendServerArgs) { |
| var input = StreamController<List<int>>(); |
| var output = StreamController<List<int>>(); |
| var compileCommandOutputChannel = StreamController<CompilerOutput>(); |
| 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. |
| void _clearState() { |
| sources.clear(); |
| accumulatedOutput.clear(); |
| _boundaryKey = 'INVALID'; |
| } |
| |
| Future<CompilerOutput> 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<void> sendCompileAndAccept(String dartSourcePath) async { |
| await sendCompile(dartSourcePath); |
| sendAccept(); |
| } |
| |
| Future<CompilerOutput> sendRecompile(String entrypointPath, |
| {List<String> invalidatedFiles = const [], |
| String boundaryKey = fakeBoundaryKey}) async { |
| if (!started) throw Exception('Frontend Server has not been started yet.'); |
| _state = FrontendServerState.awaitingResult; |
| final command = 'recompile $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<void> sendRecompileAndAccept(String entrypointPath, |
| {List<String> invalidatedFiles = const [], |
| String boundaryKey = fakeBoundaryKey}) async { |
| await sendRecompile(entrypointPath, |
| invalidatedFiles: invalidatedFiles, boundaryKey: boundaryKey); |
| 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<void> 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<void> stop() async { |
| _sendQuit(); |
| var exitCode = await frontendServerExitCode; |
| started = false; |
| if (exitCode != 0) { |
| print('Frontend Server exited with non-zero code: $exitCode'); |
| exit(exitCode); |
| } |
| } |
| } |