blob: 41fcd319af8e6ef77a95142ea110be70de65c3dc [file] [log] [blame]
// Copyright 2013 The Flutter Authors. 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:ffi' as ffi;
import 'dart:io' as io show Directory, Process;
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:process_runner/process_runner.dart';
import 'build_config.dart';
/// The base clase for events generated by a command.
sealed class RunnerEvent {
RunnerEvent(this.name, this.command, this.timestamp);
/// The name of the task or command.
final String name;
/// The command and its arguments.
final List<String> command;
/// When the event happened.
final DateTime timestamp;
}
/// A [RunnerEvent] representing the start of a command.
final class RunnerStart extends RunnerEvent {
RunnerStart(super.name, super.command, super.timestamp);
@override
String toString() {
return '[${_timestamp(timestamp)}][$name]: STARTING';
}
}
/// A [RunnerEvent] representing the progress of a started command.
final class RunnerProgress extends RunnerEvent {
RunnerProgress(
super.name,
super.command,
super.timestamp,
this.what,
this.completed,
this.total,
this.done,
) : percent = (completed * 100) / total;
/// What a command is currently working on, for example a build target or
/// the name of a test.
final String what;
/// The number of steps completed.
final int completed;
/// The total number of steps in the task.
final int total;
/// How close is the task to being completed, for example the proportion of
/// build targets that have finished building.
final double percent;
/// Whether the command is finished and this is the final progress event.
final bool done;
@override
String toString() {
final String ts = '[${_timestamp(timestamp)}]';
final String pct = '${percent.toStringAsFixed(1)}%';
return '$ts[$name]: $pct ($completed/$total) $what';
}
}
/// A [RunnerEvent] representing the result of a command.
final class RunnerResult extends RunnerEvent {
RunnerResult(super.name, super.command, super.timestamp, this.result);
/// The resuilt of running the command.
final ProcessRunnerResult result;
/// Whether the command was successful.
late final bool ok = result.exitCode == 0;
@override
String toString() {
if (ok) {
return '[${_timestamp(timestamp)}][$name]: OK';
}
final StringBuffer buffer = StringBuffer();
buffer.writeln('[$timestamp][$name]: FAILED');
buffer.writeln('COMMAND:\n${command.join(' ')}');
buffer.writeln('STDOUT:\n${result.stdout}');
buffer.writeln('STDERR:\n${result.stderr}');
return buffer.toString();
}
}
final class RunnerError extends RunnerEvent {
RunnerError(super.name, super.command, super.timestamp, this.error);
/// An error message.
final String error;
@override
String toString() {
return '[${_timestamp(timestamp)}][$name]: ERROR: $error';
}
}
/// The type of a callback that handles [RunnerEvent]s while a [Runner]
/// is executing its `run()` method.
typedef RunnerEventHandler = void Function(RunnerEvent);
/// An abstract base clase for running the various tasks that a build config
/// specifies. Derived classes implement the `run()` method.
sealed class Runner {
Runner(
this.platform,
this.processRunner,
this.abi,
this.engineSrcDir,
this.dryRun,
);
/// Information about the platform that hosts the runner.
final Platform platform;
/// Runs the subprocesses required to run the element of the build config.
final ProcessRunner processRunner;
/// The [Abi] of the host platform.
final ffi.Abi abi;
/// The src/ directory of the engine checkout.
final io.Directory engineSrcDir;
/// Whether only a dry run is required. Subprocesses will not be spawned.
final bool dryRun;
/// Uses the [processRunner] to run the commands specified by the build
/// config.
Future<bool> run(RunnerEventHandler eventHandler);
String _interpreter(String language) {
// Force python to be python3.
if (language.startsWith('python')) {
return 'python3';
}
// If the language is 'dart', return the Dart binary that is running this
// program.
if (language == 'dart') {
return platform.executable;
}
// Otherwise use the language verbatim as the interpreter.
return language;
}
}
final ProcessRunnerResult _dryRunResult = ProcessRunnerResult(
0, // exit code.
<int>[], // stdout.
<int>[], // stderr.
<int>[], // combined,
pid: 0, // pid,
);
/// The [Runner] for a [Build].
///
/// Runs the specified `gn` and `ninja` commands, followed by generator tasks,
/// and finally tests.
final class BuildRunner extends Runner {
BuildRunner({
Platform? platform,
ProcessRunner? processRunner,
ffi.Abi? abi,
required io.Directory engineSrcDir,
required this.build,
this.extraGnArgs = const <String>[],
this.extraNinjaArgs = const <String>[],
this.extraTestArgs = const <String>[],
this.runGn = true,
this.runNinja = true,
this.runGenerators = true,
this.runTests = true,
bool dryRun = false,
}) : super(
platform ?? const LocalPlatform(),
processRunner ?? ProcessRunner(),
abi ?? ffi.Abi.current(),
engineSrcDir,
dryRun,
);
/// The [Build] to run.
final Build build;
/// Extra arguments to append to the `gn` command.
final List<String> extraGnArgs;
/// Extra arguments to append to the `ninja` command.
final List<String> extraNinjaArgs;
/// Extra arguments to append to *all* test commands.
final List<String> extraTestArgs;
/// Whether to run the GN step. Defaults to true.
final bool runGn;
/// Whether to run the ninja step. Defaults to true.
final bool runNinja;
/// Whether to run the generators. Defaults to true.
final bool runGenerators;
/// Whether to run the test step. Defaults to true.
final bool runTests;
@override
Future<bool> run(RunnerEventHandler eventHandler) async {
if (!build.canRunOn(platform)) {
eventHandler(RunnerError(
build.name,
<String>[],
DateTime.now(),
'Build with drone_dimensions "{${build.droneDimensions.join(',')}}" '
'cannot run on platform ${platform.operatingSystem}',
));
return false;
}
if (runGn) {
if (!await _runGn(eventHandler)) {
return false;
}
}
if (runNinja) {
if (!await _runNinja(eventHandler)) {
return false;
}
}
if (runGenerators) {
if (!await _runGenerators(eventHandler)) {
return false;
}
}
if (runTests) {
if (!await _runTests(eventHandler)) {
return false;
}
}
return true;
}
// GN arguments from the build config that can be overridden by extraGnArgs.
static const List<(String, String)> _overridableArgs = <(String, String)>[
('--lto', '--no-lto'),
('--rbe', '--no-rbe'),
('--goma', '--no-goma'),
];
// extraGnArgs overrides the build config args.
late final Set<String> _mergedGnArgs = () {
// Put the union of the build config args and extraGnArgs in gnArgs.
final Set<String> gnArgs = Set<String>.of(build.gn);
gnArgs.addAll(extraGnArgs);
// If extraGnArgs contains an arg, remove its opposite from gnArgs.
for (final (String, String) arg in _overridableArgs) {
if (extraGnArgs.contains(arg.$1)) {
gnArgs.remove(arg.$2);
}
if (extraGnArgs.contains(arg.$2)) {
gnArgs.remove(arg.$1);
}
}
return gnArgs;
}();
Future<bool> _runGn(RunnerEventHandler eventHandler) async {
final String gnPath = p.join(engineSrcDir.path, 'flutter', 'tools', 'gn');
final Set<String> gnArgs = _mergedGnArgs;
final List<String> command = <String>[gnPath, ...gnArgs];
eventHandler(RunnerStart('${build.name}: GN', command, DateTime.now()));
final ProcessRunnerResult processResult;
if (dryRun) {
processResult = _dryRunResult;
} else {
processResult = await processRunner.runProcess(
command,
workingDirectory: engineSrcDir,
failOk: true,
);
}
final RunnerResult result = RunnerResult(
'${build.name}: GN',
command,
DateTime.now(),
processResult,
);
eventHandler(result);
return result.ok;
}
late final String _hostCpu = () {
return switch (abi) {
ffi.Abi.linuxArm64 ||
ffi.Abi.macosArm64 ||
ffi.Abi.windowsArm64 =>
'arm64',
ffi.Abi.linuxX64 || ffi.Abi.macosX64 || ffi.Abi.windowsX64 => 'x64',
_ => throw StateError('This host platform "$abi" is not supported.'),
};
}();
late final String _buildtoolsPath = () {
final String os = platform.operatingSystem;
final String platformDir = switch (os) {
Platform.linux => 'linux-$_hostCpu',
Platform.macOS => 'mac-$_hostCpu',
Platform.windows => 'windows-$_hostCpu',
_ => throw StateError('This host OS "$os" is not supported.'),
};
return p.join(engineSrcDir.path, 'buildtools', platformDir);
}();
Future<bool> _bootstrapRbe(
RunnerEventHandler eventHandler, {
bool shutdown = false,
}) async {
final String reclientPath = p.join(_buildtoolsPath, 'reclient');
final String exe = platform.isWindows ? '.exe' : '';
final String bootstrapPath = p.join(reclientPath, 'bootstrap$exe');
final String reproxyPath = p.join(reclientPath, 'reproxy$exe');
final String os = platform.operatingSystem;
final String reclientConfigFile = switch (os) {
Platform.linux => 'reclient-linux.cfg',
Platform.macOS => 'reclient-mac.cfg',
Platform.windows => 'reclient-win.cfg',
_ => throw StateError('This host OS "$os" is not supported.'),
};
final String reclientConfigPath = p.join(
engineSrcDir.path,
'flutter',
'build',
'rbe',
reclientConfigFile,
);
final List<String> bootstrapCommand = <String>[
bootstrapPath,
'--re_proxy=$reproxyPath',
'--automatic_auth=true',
if (shutdown) '--shutdown' else ...<String>['--cfg=$reclientConfigPath'],
];
if (!processRunner.processManager.canRun(bootstrapPath)) {
eventHandler(RunnerError(
build.name,
<String>[],
DateTime.now(),
'"$bootstrapPath" not found.',
));
return false;
}
eventHandler(RunnerStart(
'${build.name}: RBE ${shutdown ? 'shutdown' : 'startup'}',
bootstrapCommand,
DateTime.now(),
));
final ProcessRunnerResult bootstrapResult;
if (dryRun) {
bootstrapResult = _dryRunResult;
} else {
bootstrapResult = await processRunner.runProcess(
bootstrapCommand,
failOk: true,
);
}
eventHandler(RunnerResult(
'${build.name}: RBE ${shutdown ? 'shutdown' : 'startup'}',
bootstrapCommand,
DateTime.now(),
bootstrapResult,
));
return bootstrapResult.exitCode == 0;
}
Future<bool> _runNinja(RunnerEventHandler eventHandler) async {
if (_isRbe) {
if (!await _bootstrapRbe(eventHandler)) {
return false;
}
}
bool success = false;
try {
final String ninjaPath = p.join(
engineSrcDir.path,
'flutter',
'third_party',
'ninja',
'ninja',
);
final String outDir = p.join(
engineSrcDir.path,
'out',
build.ninja.config,
);
final List<String> command = <String>[
ninjaPath,
'-C',
outDir,
if (_isGomaOrRbe) ...<String>['-j', '200'],
...extraNinjaArgs,
...build.ninja.targets,
];
eventHandler(
RunnerStart('${build.name}: ninja', command, DateTime.now()),
);
final ProcessRunnerResult processResult;
if (dryRun) {
processResult = _dryRunResult;
} else {
final io.Process process = await processRunner.processManager.start(
command,
workingDirectory: engineSrcDir.path,
);
final List<int> stderrOutput = <int>[];
final List<int> stdoutOutput = <int>[];
final Completer<void> stdoutComplete = Completer<void>();
final Completer<void> stderrComplete = Completer<void>();
process.stdout
.transform<String>(const Utf8Decoder())
.transform(const LineSplitter())
.listen(
(String line) {
if (_ninjaProgress(eventHandler, command, line)) {
return;
}
final List<int> bytes = utf8.encode('$line\n');
stdoutOutput.addAll(bytes);
},
onDone: () async => stdoutComplete.complete(),
);
process.stderr.listen(
stderrOutput.addAll,
onDone: () async => stderrComplete.complete(),
);
await Future.wait<void>(<Future<void>>[
stdoutComplete.future,
stderrComplete.future,
]);
final int exitCode = await process.exitCode;
processResult = ProcessRunnerResult(
exitCode,
stdoutOutput, // stdout.
stderrOutput, // stderr.
<int>[], // combined,
pid: process.pid, // pid,
);
}
eventHandler(RunnerResult(
'${build.name}: ninja',
command,
DateTime.now(),
processResult,
));
success = processResult.exitCode == 0;
} finally {
if (_isRbe) {
// Ignore failures to shutdown.
await _bootstrapRbe(eventHandler, shutdown: true);
}
}
return success;
}
// Parse lines of the form '[6232/6269] LINK ./accessibility_unittests'.
// Returns false if the line is not a ninja progress line.
bool _ninjaProgress(
RunnerEventHandler eventHandler,
List<String> command,
String line,
) {
// Grab the '[6232/6269]' part.
final String maybeProgress = line.split(' ')[0];
if (maybeProgress.length < 3 ||
maybeProgress[0] != '[' ||
maybeProgress[maybeProgress.length - 1] != ']') {
return false;
}
// Extract the two numbers by stripping the '[' and ']' and splitting on
// the '/'.
final List<String> progress =
maybeProgress.substring(1, maybeProgress.length - 1).split('/');
if (progress.length < 2) {
return false;
}
final int? completed = int.tryParse(progress[0]);
final int? total = int.tryParse(progress[1]);
if (completed == null || total == null) {
return false;
}
eventHandler(RunnerProgress(
'${build.name}: ninja',
command,
DateTime.now(),
line.replaceFirst(maybeProgress, '').trim(),
completed,
total,
completed == total, // True when done.
));
return true;
}
late final bool _isGoma = _mergedGnArgs.contains('--goma');
late final bool _isRbe = _mergedGnArgs.contains('--rbe');
late final bool _isGomaOrRbe = _isGoma || _isRbe;
Future<bool> _runGenerators(RunnerEventHandler eventHandler) async {
for (final BuildTask task in build.generators) {
final BuildTaskRunner runner = BuildTaskRunner(
processRunner: processRunner,
platform: platform,
abi: abi,
engineSrcDir: engineSrcDir,
task: task,
dryRun: dryRun,
);
if (!await runner.run(eventHandler)) {
return false;
}
}
return true;
}
Future<bool> _runTests(RunnerEventHandler eventHandler) async {
for (final BuildTest test in build.tests) {
final BuildTestRunner runner = BuildTestRunner(
processRunner: processRunner,
platform: platform,
abi: abi,
engineSrcDir: engineSrcDir,
test: test,
extraTestArgs: extraTestArgs,
dryRun: dryRun,
);
if (!await runner.run(eventHandler)) {
return false;
}
}
return true;
}
}
/// The [Runner] for a [BuildTask] of a generator of a [Build].
final class BuildTaskRunner extends Runner {
BuildTaskRunner({
Platform? platform,
ProcessRunner? processRunner,
ffi.Abi? abi,
required io.Directory engineSrcDir,
required this.task,
bool dryRun = false,
}) : super(
platform ?? const LocalPlatform(),
processRunner ?? ProcessRunner(),
abi ?? ffi.Abi.current(),
engineSrcDir,
dryRun,
);
/// The task to run.
final BuildTask task;
@override
Future<bool> run(RunnerEventHandler eventHandler) async {
final String interpreter = _interpreter(task.language);
for (final String script in task.scripts) {
final List<String> command = <String>[
if (interpreter.isNotEmpty) interpreter,
script,
...task.parameters,
];
eventHandler(RunnerStart(task.name, command, DateTime.now()));
final ProcessRunnerResult processResult;
if (dryRun) {
processResult = _dryRunResult;
} else {
processResult = await processRunner.runProcess(
command,
workingDirectory: engineSrcDir,
failOk: true,
);
}
final RunnerResult result = RunnerResult(
task.name,
command,
DateTime.now(),
processResult,
);
eventHandler(result);
if (!result.ok) {
return false;
}
}
return true;
}
}
/// The [Runner] for a [BuildTest] of a [Build].
final class BuildTestRunner extends Runner {
BuildTestRunner({
Platform? platform,
ProcessRunner? processRunner,
ffi.Abi? abi,
required io.Directory engineSrcDir,
required this.test,
this.extraTestArgs = const <String>[],
bool dryRun = false,
}) : super(
platform ?? const LocalPlatform(),
processRunner ?? ProcessRunner(),
abi ?? ffi.Abi.current(),
engineSrcDir,
dryRun,
);
/// The test to run.
final BuildTest test;
/// Extra arguments to append to the test command.
final List<String> extraTestArgs;
@override
Future<bool> run(RunnerEventHandler eventHandler) async {
final String interpreter = _interpreter(test.language);
final List<String> command = <String>[
if (interpreter.isNotEmpty) interpreter,
test.script,
...test.parameters,
...extraTestArgs,
];
eventHandler(RunnerStart(test.name, command, DateTime.now()));
final ProcessRunnerResult processResult;
if (dryRun) {
processResult = _dryRunResult;
} else {
// TODO(zanderso): We could detect here that we're running e.g. C++ unit
// tests via run_tests.py, and parse the stdout to generate RunnerProgress
// events.
processResult = await processRunner.runProcess(
command,
workingDirectory: engineSrcDir,
failOk: true,
printOutput: true,
);
}
final RunnerResult result = RunnerResult(
test.name,
command,
DateTime.now(),
processResult,
);
eventHandler(result);
return result.ok;
}
}
String _timestamp(DateTime time) {
String threeDigits(int n) {
return switch (n) {
>= 100 => '$n',
>= 10 => '0$n',
_ => '00$n',
};
}
String twoDigits(int n) {
return switch (n) {
>= 10 => '$n',
_ => '0$n',
};
}
final String y = time.year.toString();
final String m = twoDigits(time.month);
final String d = twoDigits(time.day);
final String hh = twoDigits(time.hour);
final String mm = twoDigits(time.minute);
final String ss = twoDigits(time.second);
final String ms = threeDigits(time.millisecond);
return '$y-$m-${d}T$hh:$mm:$ss.$ms${time.isUtc ? 'Z' : ''}';
}