8aa6b413eb
A new optional suffix to the file names is added: '.restart'. This allows us to differentiate between hot reload generations and hot restart generations. All files with the same generation should either have the suffix or don't. If present, uses the recompile-request instruction in the frontend server. If not, uses the recompile instruction. Tests are renamed and a WIP README is added to document this change. Change-Id: I2691c5ec0b7336115a76dc0e0369213e74b6ea21 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/406183 Commit-Queue: Srujan Gaddam <srujzs@google.com> Reviewed-by: Nicholas Shahan <nshahan@google.com>
295 lines
10 KiB
Dart
295 lines
10 KiB
Dart
// 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 reload 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 and for that reason
|
|
/// the local [totalErrors] count is not reset here.
|
|
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,
|
|
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<void> sendRecompileAndAccept(String entrypointPath,
|
|
{List<String> 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<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);
|
|
}
|
|
}
|
|
}
|