7e2f4855d4
Remove extra close parenthesis and single quote from error message. Change-Id: I67f6dabb872916b49e385fa43fdef0a22ef65b4f Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/500580 Commit-Queue: Nicholas Shahan <nshahan@google.com> Reviewed-by: Mark Zhou <markzipan@google.com>
316 lines
10 KiB
Dart
316 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);
|
|
}
|
|
}
|
|
}
|