blob: 2483b2897b777768e2f4f5420b196ab787568f1c [file] [log] [blame]
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.9
import 'dart:async';
import 'dart:convert';
import 'dart:io'
show
exit,
File,
InternetAddress,
Link,
Platform,
ProcessSignal,
ServerSocket,
Socket;
import 'dart:typed_data' show Uint8List;
import 'package:args/args.dart';
// front_end/src imports below that require lint `ignore_for_file`
// are a temporary state of things until frontend team builds better api
// that would replace api used below. This api was made private in
// an effort to discourage further use.
// ignore_for_file: implementation_imports
import 'package:front_end/src/api_unstable/vm.dart';
import '../frontend_server.dart';
/// Floor the system time by this amount in order to correctly detect modified
/// source files on all platforms. This has no effect on correctness,
/// but may result in more files being marked as modified than strictly
/// required.
const _STAT_GRANULARITY = const Duration(seconds: 1);
const RESIDENT_SERVER_LINK_POSTFIX = '_link';
/// Ensures the symbolic link is removed if ctrl-C is sent to the server.
/// Mostly used when debugging.
StreamSubscription<ProcessSignal> _cleanupHandler;
extension on DateTime {
/// Truncates by [amount].
///
/// This is needed because the 1 second granularity on DateTime objects
/// returned by file stat on Windows is different than the system time's
/// granularity. We must floor the system time
/// by 1 second so that if a file is modified within the same second of
/// the last compile time, it will be correctly detected as being modified.
DateTime floorTime({Duration amount = _STAT_GRANULARITY}) {
return DateTime.fromMillisecondsSinceEpoch(this.millisecondsSinceEpoch -
this.millisecondsSinceEpoch % amount.inMilliseconds);
}
}
enum _ResidentState {
WAITING_FOR_FIRST_COMPILE,
COMPILING,
WAITING_FOR_RECOMPILE,
}
/// A wrapper around the FrontendCompiler, along with all the state needed
/// to perform incremental compilations
///
/// TODO: Fix the race condition that occurs when the ResidentCompiler returns
/// a kernel file to the CLI and another compilation request is given before
/// the VM is able to launch from the kernel that was returned in the first
/// compile request. The ResidentCompiler will be in the state of waiting for
/// a recompile request and will subsequently process the request and modify
/// the kernel file. However, it should be waiting for the VM to finish
/// launching itself from this kernel until it modifies the kernel.
/// As far as I can tell this race also exists in the current CLI run
/// command when using pub's precompile pipeline.
///
/// TODO Fix the race condition that occurs when the same entry point is
/// compiled concurrently.
class ResidentCompiler {
File _entryPoint;
File _outputDill;
File _currentPackage;
ArgResults _compileOptions;
FrontendCompiler _compiler;
DateTime _lastCompileStartTime = DateTime.now().floorTime();
_ResidentState _state = _ResidentState.WAITING_FOR_FIRST_COMPILE;
final StringBuffer _compilerOutput = StringBuffer();
final Set<Uri> trackedSources = <Uri>{};
final List<String> _formattedOutput = <String>[];
var incrementalMode = false;
ResidentCompiler(this._entryPoint, this._outputDill, this._compileOptions) {
_compiler = FrontendCompiler(_compilerOutput);
updateState(_compileOptions);
}
/// The [ResidentCompiler] will use the [newOptions] for future compilation
/// requests.
void updateState(ArgResults newOptions) {
final packages = newOptions['packages'];
incrementalMode = newOptions['incremental'] == true;
_compileOptions = newOptions;
_currentPackage = packages == null ? null : File(packages);
// Refresh the compiler's output for the next compile
_compilerOutput.clear();
_formattedOutput.clear();
_state = _ResidentState.WAITING_FOR_FIRST_COMPILE;
}
/// The current compiler options are outdated when any option has changed
/// since the last compile, or when the packages file has been modified
bool areOptionsOutdated(ArgResults newOptions) {
if (newOptions.arguments.length != _compileOptions.arguments.length) {
return true;
}
if (!newOptions.arguments
.toSet()
.containsAll(_compileOptions.arguments.toSet())) {
return true;
}
return _currentPackage != null &&
!_lastCompileStartTime
.isAfter(_currentPackage.statSync().modified.floorTime());
}
/// Compiles the entry point that this ResidentCompiler is hooked to.
/// Will perform incremental compilations when possible.
/// If the options are outdated, must use updateState to get a correct
/// compile.
Future<String> compile() async {
var incremental = false;
// If this entrypoint was previously compiled on this compiler instance,
// check which source files need to be recompiled in the incremental
// compilation request. If no files have been modified, we can return
// the cached kernel. Otherwise, perform an incremental compilation.
if (_state == _ResidentState.WAITING_FOR_RECOMPILE) {
var invalidatedUris =
await _getSourceFilesToRecompile(_lastCompileStartTime);
// No changes to source files detected and cached kernel file exists
// If a kernel file is removed in between compilation requests,
// fall through to procude the kernel in recompileDelta.
if (invalidatedUris.isEmpty && _outputDill.existsSync()) {
return _encodeCompilerOutput(
_outputDill.path, _formattedOutput, _compiler.errors.length,
usingCachedKernel: true);
}
_state = _ResidentState.COMPILING;
incremental = true;
invalidatedUris
.forEach((invalidatedUri) => _compiler.invalidate(invalidatedUri));
_compiler.errors.clear();
_lastCompileStartTime = DateTime.now().floorTime();
await _compiler.recompileDelta(entryPoint: _entryPoint.path);
} else {
_state = _ResidentState.COMPILING;
_lastCompileStartTime = DateTime.now().floorTime();
_compiler.errors.clear();
await _compiler.compile(_entryPoint.path, _compileOptions);
}
_interpretCompilerOutput(LineSplitter()
.convert(_compilerOutput.toString())
.where((line) => line.isNotEmpty)
.toList());
_compilerOutput.clear();
if (incrementalMode) {
// forces the compiler to produce complete kernel files on each
// request, even when incrementally compiled.
_compiler
..acceptLastDelta()
..resetIncrementalCompiler();
_state = _ResidentState.WAITING_FOR_RECOMPILE;
} else {
_state = _ResidentState.WAITING_FOR_FIRST_COMPILE;
}
return _encodeCompilerOutput(
_outputDill.path, _formattedOutput, _compiler.errors.length,
incrementalCompile: incremental);
}
/// Reads the compiler's [outputLines] to keep track of which files
/// need to be tracked. Adds correctly ANSI formatted output to
/// the [_formattedOutput] list.
void _interpretCompilerOutput(List<String> outputLines) {
_formattedOutput.clear();
var outputLineIndex = 0;
var acceptingErrorsOrVerboseOutput = true;
final boundaryKey = outputLines[outputLineIndex]
.substring(outputLines[outputLineIndex++].indexOf(' ') + 1);
var line = outputLines[outputLineIndex++];
while (acceptingErrorsOrVerboseOutput || !line.startsWith(boundaryKey)) {
if (acceptingErrorsOrVerboseOutput) {
if (line == boundaryKey) {
acceptingErrorsOrVerboseOutput = false;
} else {
_formattedOutput.add(line);
}
} else {
final diffUri = line.substring(1);
if (line.startsWith('+')) {
trackedSources.add(Uri.parse(diffUri));
} else if (line.startsWith('-')) {
trackedSources.remove(Uri.parse(diffUri));
}
}
line = outputLines[outputLineIndex++];
}
}
/// Returns a list of uris that need to be recompiled, based on the
/// [lastkernelCompileTime] timestamp.
/// Due to Windows timestamp granularity, all timestamps are truncated by
/// the second. This has no effect on correctness but may result in more
/// files being marked as invalid than are strictly required.
Future<List<Uri>> _getSourceFilesToRecompile(
DateTime lastKernelCompileTime) async {
final sourcesToRecompile = <Uri>[];
for (Uri uri in trackedSources) {
final sourceModifiedTime =
File(uri.toFilePath()).statSync().modified.floorTime();
if (!lastKernelCompileTime.isAfter(sourceModifiedTime)) {
sourcesToRecompile.add(uri);
}
}
return sourcesToRecompile;
}
/// Encodes [outputDillPath] and any [formattedErrors] in JSON to
/// be sent over the socket.
static String _encodeCompilerOutput(
String outputDillPath,
List<String> formattedErrors,
int errorCount, {
bool usingCachedKernel = false,
bool incrementalCompile = false,
}) {
return jsonEncode(<String, Object>{
"success": errorCount == 0,
"errorCount": errorCount,
"compilerOutputLines": formattedErrors,
"output-dill": outputDillPath,
if (usingCachedKernel) "returnedStoredKernel": true, // used for testing
if (incrementalCompile) "incremental": true, // used for testing
});
}
}
/// Maintains [FrontendCompiler] instances for kernel compilations, meant to be
/// used by the Dart CLI via sockets.
///
/// The [ResidentFrontendServer] manages compilation requests for VM targets
/// between any number of dart entrypoints, and utilizes incremental
/// compilation and existing kernel files for faster compile times.
///
/// Communication is handled on the socket set up by the
/// residentListenAndCompile method.
class ResidentFrontendServer {
static const _commandString = 'command';
static const _executableString = 'executable';
static const _packageString = 'packages';
static const _outputString = 'output-dill';
static const _shutdownString = 'shutdown';
static const _compilerLimit = 3;
static final shutdownCommand =
jsonEncode(<String, Object>{_commandString: _shutdownString});
static final _shutdownJsonResponse =
jsonEncode(<String, Object>{_shutdownString: true});
static final _sdkBinariesUri = computePlatformBinariesLocation();
static final _sdkUri = _sdkBinariesUri.resolve('../../');
static final _platformKernelUri =
_sdkBinariesUri.resolve('vm_platform_strong.dill');
static final Map<String, ResidentCompiler> compilers = {};
/// Takes in JSON [input] from the socket and compiles the request,
/// using incremental compilation if possible. Returns a JSON string to be
/// sent back to the client socket containing either an error message or the
/// kernel file to be used.
///
/// If the command is compile, paths the source file, package_config.json,
/// and the output-dill file must be provided via "executable", "packages",
/// and "output-dill".
static Future<String> handleRequest(String input) async {
Map<String, dynamic> request;
try {
request = jsonDecode(input);
} on FormatException {
return _encodeErrorMessage('$input is not valid JSON.');
}
switch (request[_commandString]) {
case 'compile':
if (request[_executableString] == null ||
request[_outputString] == null) {
return _encodeErrorMessage(
'compilation requests must include an $_executableString '
'and an $_outputString path.');
}
final executablePath = request[_executableString];
final cachedDillPath = request[_outputString];
final options = _generateCompilerOptions(request);
var residentCompiler = compilers[executablePath];
if (residentCompiler == null) {
// Avoids using too much memory
if (compilers.length >= ResidentFrontendServer._compilerLimit) {
compilers.remove(compilers.keys.first);
}
residentCompiler = ResidentCompiler(
File(executablePath), File(cachedDillPath), options);
compilers[executablePath] = residentCompiler;
} else if (residentCompiler.areOptionsOutdated(options)) {
residentCompiler.updateState(options);
}
return await residentCompiler.compile();
case 'shutdown':
return _shutdownJsonResponse;
default:
return _encodeErrorMessage(
'Unsupported command: ${request[_commandString]}.');
}
}
/// Generates the compiler options needed to satisfy the [request]
static ArgResults _generateCompilerOptions(Map<String, dynamic> request) {
return argParser.parse(<String>[
'--sdk-root=${_sdkUri.toFilePath()}',
if (!request.containsKey('aot')) '--incremental',
'--platform=${_platformKernelUri.path}',
'--output-dill=${request[_outputString]}',
'--target=vm',
'--filesystem-scheme',
'org-dartlang-root',
if (request.containsKey(_packageString))
'--packages=${request[_packageString]}',
if (request['support-mirrors'] ?? false) '--support-mirrors',
if (request['enable-asserts'] ?? false) '--enable-asserts',
if (request['sound-null-safety'] ?? false) '--sound-null-safety',
if (request['verbosity'] != null) '--verbosity=${request["verbosity"]}',
if (request['verbose'] ?? false) '--verbose',
if (request['aot'] ?? false) '--aot',
if (request['tfa'] ?? false) '--tfa',
if (request['rta'] ?? false) '--rta',
if (request['tree-shake-write-only-fields'] ?? false)
'--tree-shake-write-only-fields',
if (request['protobuf-tree-shaker-v2'] ?? false)
'--protobuf-tree-shaker-v2',
if (request['define'] != null)
for (var define in request['define']) define,
if (request['enable-experiement'] != null)
for (var experiment in request['enable-experiment']) experiment,
]);
}
/// Encodes the [message] in JSON to be sent over the socket.
static String _encodeErrorMessage(String message) =>
jsonEncode(<String, Object>{"success": false, "errorMessage": message});
/// Used to create compile requests for the ResidentFrontendServer.
/// Returns a JSON string that the resident compiler will be able to
/// interpret.
static String createCompileJSON(
{String executable,
String packages,
String outputDill,
bool supportMirrors,
bool enableAsserts,
bool soundNullSafety,
String verbosity,
bool aot,
bool tfa,
bool rta,
bool treeShakeWriteOnlyFields,
bool protobufTreeShakerV2,
List<String> define,
List<String> enableExperiement,
bool verbose = false}) {
return jsonEncode(<String, Object>{
"command": "compile",
"executable": executable,
"output-dill": outputDill,
if (aot != null) "aot": true,
if (define != null) "define": define,
if (enableAsserts != null) "enable-asserts": true,
if (enableExperiement != null) "enable-experiment": enableExperiement,
if (packages != null) "packages": packages,
if (protobufTreeShakerV2 != null) "protobuf-tree-shaker-v2": true,
if (rta != null) "rta": true,
if (soundNullSafety != null) "sound-null-safety": soundNullSafety,
if (supportMirrors != null) "support-mirrors": true,
if (tfa != null) "tfa": true,
if (treeShakeWriteOnlyFields != null)
"tree-shaker-write-only-fields": true,
if (verbosity != null) "verbosity": verbosity,
"verbose": verbose,
});
}
}
/// Sends the JSON string [request] to the resident frontend server
/// and returns server's response in JSON
///
/// Clients must use this function when wanting to interact with a
/// ResidentFrontendServer instance.
Future<Map<String, dynamic>> sendAndReceiveResponse(
InternetAddress address, int port, String request) async {
Socket client;
Map<String, dynamic> jsonResponse;
try {
client = await Socket.connect(address, port);
client.write(request);
final data = String.fromCharCodes(await client.first);
jsonResponse = jsonDecode(data);
} catch (e) {
jsonResponse = <String, Object>{
'success': false,
'errorMessage': e.toString(),
};
}
if (client != null) {
client.destroy();
}
return jsonResponse;
}
/// Closes the ServerSocket and removes the [serverInfoFile] that is used
/// to access this instance of the Resident Frontend Server as well as the
/// lock to prevent the concurrent start race.
Future<void> residentServerCleanup(
ServerSocket server, File serverInfoFile) async {
final serverFilesystemLock =
Link('${serverInfoFile.path}$RESIDENT_SERVER_LINK_POSTFIX');
try {
if (_cleanupHandler != null) {
_cleanupHandler.cancel();
}
if (serverInfoFile.existsSync()) {
serverInfoFile.deleteSync();
}
} catch (_) {
} finally {
try {
if (serverFilesystemLock.existsSync()) {
serverFilesystemLock.deleteSync();
}
} catch (_) {}
}
await server.close();
}
/// Starts a timer that will shut down the resident frontend server in
/// the amount of time specified by [timerDuration], if it is not cancelled.
Timer startShutdownTimer(
Duration timerDuration, ServerSocket server, File serverInfoFile) {
return Timer(timerDuration, () async {
await residentServerCleanup(server, serverInfoFile);
});
}
/// Listens for compilation commands from socket connections on the
/// provided [address] and [port].
/// If the last request exceeds the amount of time specified by
/// [inactivityTimeout], the server will bring itself down
Future<StreamSubscription<Socket>> residentListenAndCompile(
InternetAddress address, int port, File serverInfoFile,
{Duration inactivityTimeout = const Duration(minutes: 30)}) async {
ServerSocket server;
// Create a link to the serverInfoFile to ensure that concurrent requests
// to start the server result in only 1 server being started. This
// also ensures that the serverInfoFile is only
// visible once the server is started and ready to receive connections.
// TODO https://github.com/dart-lang/sdk/issues/49647 use exclusive mode
// on File objects
final serverInfoLink =
Link('${serverInfoFile.path}$RESIDENT_SERVER_LINK_POSTFIX');
try {
try {
serverInfoLink.createSync(serverInfoFile.path);
} catch (e) {
// TODO: https://github.com/dart-lang/sdk/issues/49647 Using a File
// in exclusive mode removes the need for this check.
if (Platform.isWindows && e.toString().contains('errno = 1314')) {
throw StateError('Dart must be running in Administrator mode '
'or Developer mode must be enabled when '
'using the Resident Frontend Compiler.');
}
throw StateError('A server is already running.');
}
server = await ServerSocket.bind(address, port);
serverInfoFile
..writeAsStringSync(
'address:${server.address.address} port:${server.port}');
} on StateError catch (e) {
print('Error: $e\n');
return null;
} catch (e) {
// lock was acquired but bind or writing failed
try {
serverInfoLink.deleteSync();
} catch (_) {}
print('Error: $e\n');
return null;
}
_cleanupHandler = ProcessSignal.sigint.watch().listen((signal) async {
await residentServerCleanup(server, serverInfoFile);
exit(1);
});
var shutdownTimer =
startShutdownTimer(inactivityTimeout, server, serverInfoFile);
print('The Resident Frontend Compiler is listening at '
'${server.address.address}:${server.port}');
return server.listen((client) {
client.listen((Uint8List data) async {
String result = await ResidentFrontendServer.handleRequest(
String.fromCharCodes(data));
client.write(result);
shutdownTimer.cancel();
if (result == ResidentFrontendServer._shutdownJsonResponse) {
await residentServerCleanup(server, serverInfoFile);
} else {
shutdownTimer =
startShutdownTimer(inactivityTimeout, server, serverInfoFile);
}
}, onError: (error) {
client.close();
}, onDone: () {
client.close();
});
}, onError: (_) async {
shutdownTimer.cancel();
await residentServerCleanup(server, serverInfoFile);
});
}