Refactor devicelab bin/run.dart and other cleanup (#96320)
diff --git a/dev/devicelab/bin/run.dart b/dev/devicelab/bin/run.dart
index 515b3a1..d4589bd 100644
--- a/dev/devicelab/bin/run.dart
+++ b/dev/devicelab/bin/run.dart
@@ -13,102 +13,108 @@
import 'package:flutter_devicelab/framework/utils.dart';
import 'package:path/path.dart' as path;
-late ArgResults args;
-
-List<String> _taskNames = <String>[];
-
-/// The device-id to run test on.
-String? deviceId;
-
-/// The git branch being tested on.
-String? gitBranch;
-
-/// The build of the local engine to use.
-///
-/// Required for A/B test mode.
-String? localEngine;
-
-/// The path to the engine "src/" directory.
-String? localEngineSrcPath;
-
-/// Name of the LUCI builder this test is currently running on.
-///
-/// This is only passed on CI runs for Cocoon to be able to uniquely identify
-/// this test run.
-String? luciBuilder;
-
-/// Whether to exit on first test failure.
-bool exitOnFirstTestFailure = false;
-
-/// Path to write test results to.
-String? resultsPath;
-
-/// File containing a service account token.
-///
-/// If passed, the test run results will be uploaded to Flutter infrastructure.
-String? serviceAccountTokenFile;
-
-/// Suppresses standard output, prints only standard error output.
-bool silent = false;
-
/// Runs tasks.
///
-/// The tasks are chosen depending on the command-line options
-/// (see [_argParser]).
+/// The tasks are chosen depending on the command-line options.
Future<void> main(List<String> rawArgs) async {
+ // This is populated by a callback in the ArgParser.
+ final List<String> taskNames = <String>[];
+ final ArgParser argParser = createArgParser(taskNames);
+
+ ArgResults args;
try {
- args = _argParser.parse(rawArgs);
+ args = argParser.parse(rawArgs); // populates taskNames as a side-effect
} on FormatException catch (error) {
stderr.writeln('${error.message}\n');
stderr.writeln('Usage:\n');
- stderr.writeln(_argParser.usage);
- exitCode = 1;
- return;
+ stderr.writeln(argParser.usage);
+ exit(1);
}
- deviceId = args['device-id'] as String?;
- exitOnFirstTestFailure = (args['exit'] as bool?) ?? false;
- gitBranch = args['git-branch'] as String?;
- localEngine = args['local-engine'] as String?;
- localEngineSrcPath = args['local-engine-src-path'] as String?;
- luciBuilder = args['luci-builder'] as String?;
- resultsPath = args['results-file'] as String?;
- serviceAccountTokenFile = args['service-account-token-file'] as String?;
- silent = (args['silent'] as bool?) ?? false;
+ /// Suppresses standard output, prints only standard error output.
+ final bool silent = (args['silent'] as bool?) ?? false;
+
+ /// The build of the local engine to use.
+ ///
+ /// Required for A/B test mode.
+ final String? localEngine = args['local-engine'] as String?;
+
+ /// The path to the engine "src/" directory.
+ final String? localEngineSrcPath = args['local-engine-src-path'] as String?;
+
+ /// The device-id to run test on.
+ final String? deviceId = args['device-id'] as String?;
+
+ /// Whether to exit on first test failure.
+ final bool exitOnFirstTestFailure = (args['exit'] as bool?) ?? false;
+
+ /// Whether to tell tasks to clean up after themselves.
+ final bool terminateStrayDartProcesses = (args['terminate-stray-dart-processes'] as bool?) ?? false;
+
+ /// The git branch being tested on.
+ final String? gitBranch = args['git-branch'] as String?;
+
+ /// Name of the LUCI builder this test is currently running on.
+ ///
+ /// This is only passed on CI runs for Cocoon to be able to uniquely identify
+ /// this test run.
+ final String? luciBuilder = args['luci-builder'] as String?;
+
+ /// Path to write test results to.
+ final String? resultsPath = args['results-file'] as String?;
if (!args.wasParsed('task')) {
if (args.wasParsed('stage') || args.wasParsed('all')) {
addTasks(
tasks: loadTaskManifest().tasks,
args: args,
- taskNames: _taskNames,
+ taskNames: taskNames,
);
}
}
if (args.wasParsed('list')) {
- for (int i = 0; i < _taskNames.length; i++) {
- print('${(i + 1).toString().padLeft(3)} - ${_taskNames[i]}');
+ for (int i = 0; i < taskNames.length; i++) {
+ print('${(i + 1).toString().padLeft(3)} - ${taskNames[i]}');
}
- exitCode = 0;
- return;
+ exit(0);
}
- if (_taskNames.isEmpty) {
+ if (taskNames.isEmpty) {
stderr.writeln('Failed to find tasks to run based on supplied options.');
- exitCode = 1;
- return;
+ exit(1);
}
if (args.wasParsed('ab')) {
- await _runABTest();
+ final int runsPerTest = int.parse(args['ab'] as String);
+ final String resultsFile = args['ab-result-file'] as String? ?? 'ABresults#.json';
+ if (taskNames.length > 1) {
+ stderr.writeln('When running in A/B test mode exactly one task must be passed but got ${taskNames.join(', ')}.\n');
+ stderr.writeln(argParser.usage);
+ exit(1);
+ }
+ if (localEngine == null) {
+ stderr.writeln('When running in A/B test mode --local-engine is required.\n');
+ stderr.writeln(argParser.usage);
+ exit(1);
+ }
+ await _runABTest(
+ runsPerTest: runsPerTest,
+ silent: silent,
+ localEngine: localEngine,
+ localEngineSrcPath: localEngineSrcPath,
+ deviceId: deviceId,
+ resultsFile: resultsFile,
+ taskName: taskNames.single,
+ );
} else {
- await runTasks(_taskNames,
+ await runTasks(taskNames,
silent: silent,
localEngine: localEngine,
localEngineSrcPath: localEngineSrcPath,
deviceId: deviceId,
exitOnFirstTestFailure: exitOnFirstTestFailure,
+ terminateStrayDartProcesses: terminateStrayDartProcesses,
gitBranch: gitBranch,
luciBuilder: luciBuilder,
resultsPath: resultsPath,
@@ -116,26 +122,18 @@
}
}
-Future<void> _runABTest() async {
- final int runsPerTest = int.parse(args['ab'] as String);
-
- if (_taskNames.length > 1) {
- stderr.writeln('When running in A/B test mode exactly one task must be passed but got ${_taskNames.join(', ')}.\n');
- stderr.writeln(_argParser.usage);
- exit(1);
- }
-
- if (!args.wasParsed('local-engine')) {
- stderr.writeln('When running in A/B test mode --local-engine is required.\n');
- stderr.writeln(_argParser.usage);
- exit(1);
- }
-
- final String taskName = _taskNames.single;
-
+Future<void> _runABTest({
+ required int runsPerTest,
+ required bool silent,
+ required String localEngine,
+ required String? localEngineSrcPath,
+ required String? deviceId,
+ required String resultsFile,
+ required String taskName,
+}) async {
print('$taskName A/B test. Will run $runsPerTest times.');
- final ABTest abTest = ABTest(localEngine!, taskName);
+ final ABTest abTest = ABTest(localEngine, taskName);
for (int i = 1; i <= runsPerTest; i++) {
section('Run #$i');
@@ -182,7 +180,7 @@
}
abTest.finalize();
- final File jsonFile = _uniqueFile(args['ab-result-file'] as String? ?? 'ABresults#.json');
+ final File jsonFile = _uniqueFile(resultsFile);
jsonFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(abTest.jsonMap));
if (silent != true) {
@@ -234,142 +232,152 @@
}
}
-/// Command-line options for the `run.dart` command.
-final ArgParser _argParser = ArgParser()
- ..addMultiOption(
- 'task',
- abbr: 't',
- help: 'Either:\n'
- ' - the name of a task defined in manifest.yaml.\n'
- ' Example: complex_layout__start_up.\n'
- ' - the path to a Dart file corresponding to a task,\n'
- ' which resides in bin/tasks.\n'
- ' Example: bin/tasks/complex_layout__start_up.dart.\n'
- '\n'
- 'This option may be repeated to specify multiple tasks.',
- callback: (List<String> value) {
- for (final String nameOrPath in value) {
- final List<String> fragments = path.split(nameOrPath);
- final bool isDartFile = fragments.last.endsWith('.dart');
+ArgParser createArgParser(List<String> taskNames) {
+ return ArgParser()
+ ..addMultiOption(
+ 'task',
+ abbr: 't',
+ help: 'Either:\n'
+ ' - the name of a task defined in manifest.yaml.\n'
+ ' Example: complex_layout__start_up.\n'
+ ' - the path to a Dart file corresponding to a task,\n'
+ ' which resides in bin/tasks.\n'
+ ' Example: bin/tasks/complex_layout__start_up.dart.\n'
+ '\n'
+ 'This option may be repeated to specify multiple tasks.',
+ callback: (List<String> value) {
+ for (final String nameOrPath in value) {
+ final List<String> fragments = path.split(nameOrPath);
+ final bool isDartFile = fragments.last.endsWith('.dart');
- if (fragments.length == 1 && !isDartFile) {
- // Not a path
- _taskNames.add(nameOrPath);
- } else if (!isDartFile || !path.equals(path.dirname(nameOrPath), path.join('bin', 'tasks'))) {
- // Unsupported executable location
- throw FormatException('Invalid value for option -t (--task): $nameOrPath');
- } else {
- _taskNames.add(path.withoutExtension(fragments.last));
+ if (fragments.length == 1 && !isDartFile) {
+ // Not a path
+ taskNames.add(nameOrPath);
+ } else if (!isDartFile || !path.equals(path.dirname(nameOrPath), path.join('bin', 'tasks'))) {
+ // Unsupported executable location
+ throw FormatException('Invalid value for option -t (--task): $nameOrPath');
+ } else {
+ taskNames.add(path.withoutExtension(fragments.last));
+ }
}
- }
- },
- )
- ..addOption(
- 'device-id',
- abbr: 'd',
- help: 'Target device id (prefixes are allowed, names are not supported).\n'
- 'The option will be ignored if the test target does not run on a\n'
- 'mobile device. This still respects the device operating system\n'
- 'settings in the test case, and will results in error if no device\n'
- 'with given ID/ID prefix is found.',
- )
- ..addOption(
- 'ab',
- help: 'Runs an A/B test comparing the default engine with the local\n'
- 'engine build for one task. This option does not support running\n'
- 'multiple tasks. The value is the number of times to run the task.\n'
- 'The task is expected to be a benchmark that reports score keys.\n'
- 'The A/B test collects the metrics collected by the test and\n'
- 'produces a report containing averages, noise, and the speed-up\n'
- 'between the two engines. --local-engine is required when running\n'
- 'an A/B test.',
- callback: (String? value) {
- if (value != null && int.tryParse(value) == null) {
- throw ArgParserException('Option --ab must be a number, but was "$value".');
- }
- },
- )
- ..addOption(
- 'ab-result-file',
- help: 'The filename in which to place the json encoded results of an A/B test.\n'
- 'The filename may contain a single # character to be replaced by a sequence\n'
- 'number if the name already exists.',
- )
- ..addFlag(
- 'all',
- abbr: 'a',
- help: 'Runs all tasks defined in manifest.yaml in alphabetical order.',
- )
- ..addOption(
- 'continue-from',
- abbr: 'c',
- help: 'With --all or --stage, continue from the given test.',
- )
- ..addFlag(
- 'exit',
- defaultsTo: true,
- help: 'Exit on the first test failure.',
- )
- ..addOption(
- 'git-branch',
- help: '[Flutter infrastructure] Git branch of the current commit. LUCI\n'
- 'checkouts run in detached HEAD state, so the branch must be passed.',
- )
- ..addOption(
- 'local-engine',
- help: 'Name of a build output within the engine out directory, if you\n'
- 'are building Flutter locally. Use this to select a specific\n'
- 'version of the engine if you have built multiple engine targets.\n'
- 'This path is relative to --local-engine-src-path/out. This option\n'
- 'is required when running an A/B test (see the --ab option).',
- )
- ..addFlag(
- 'list',
- abbr: 'l',
- help: "Don't actually run the tasks, but list out the tasks that would\n"
- 'have been run, in the order they would have run.',
- )
- ..addOption(
- 'local-engine-src-path',
- help: 'Path to your engine src directory, if you are building Flutter\n'
- 'locally. Defaults to \$FLUTTER_ENGINE if set, or tries to guess at\n'
- 'the location based on the value of the --flutter-root option.',
- )
- ..addOption('luci-builder', help: '[Flutter infrastructure] Name of the LUCI builder being run on.')
- ..addFlag(
- 'match-host-platform',
- defaultsTo: true,
- help: 'Only run tests that match the host platform (e.g. do not run a\n'
- 'test with a `required_agent_capabilities` value of "mac/android"\n'
- 'on a windows host). Each test publishes its '
- '`required_agent_capabilities`\nin the `manifest.yaml` file.',
- )
- ..addOption(
- 'results-file',
- help: '[Flutter infrastructure] File path for test results. If passed with\n'
- 'task, will write test results to the file.'
- )
- ..addOption(
- 'service-account-token-file',
- help: '[Flutter infrastructure] Authentication for uploading results.',
- )
- ..addOption(
- 'stage',
- abbr: 's',
- help: 'Name of the stage. Runs all tasks for that stage. The tasks and\n'
- 'their stages are read from manifest.yaml.',
- )
- ..addFlag(
- 'silent',
- )
- ..addMultiOption(
- 'test',
- hide: true,
- callback: (List<String> value) {
- if (value.isNotEmpty) {
- throw const FormatException(
- 'Invalid option --test. Did you mean --task (-t)?',
- );
- }
- },
- );
+ },
+ )
+ ..addOption(
+ 'device-id',
+ abbr: 'd',
+ help: 'Target device id (prefixes are allowed, names are not supported).\n'
+ 'The option will be ignored if the test target does not run on a\n'
+ 'mobile device. This still respects the device operating system\n'
+ 'settings in the test case, and will results in error if no device\n'
+ 'with given ID/ID prefix is found.',
+ )
+ ..addOption(
+ 'ab',
+ help: 'Runs an A/B test comparing the default engine with the local\n'
+ 'engine build for one task. This option does not support running\n'
+ 'multiple tasks. The value is the number of times to run the task.\n'
+ 'The task is expected to be a benchmark that reports score keys.\n'
+ 'The A/B test collects the metrics collected by the test and\n'
+ 'produces a report containing averages, noise, and the speed-up\n'
+ 'between the two engines. --local-engine is required when running\n'
+ 'an A/B test.',
+ callback: (String? value) {
+ if (value != null && int.tryParse(value) == null) {
+ throw ArgParserException('Option --ab must be a number, but was "$value".');
+ }
+ },
+ )
+ ..addOption(
+ 'ab-result-file',
+ help: 'The filename in which to place the json encoded results of an A/B test.\n'
+ 'The filename may contain a single # character to be replaced by a sequence\n'
+ 'number if the name already exists.',
+ )
+ ..addFlag(
+ 'all',
+ abbr: 'a',
+ help: 'Runs all tasks defined in manifest.yaml in alphabetical order.',
+ )
+ ..addOption(
+ 'continue-from',
+ abbr: 'c',
+ help: 'With --all or --stage, continue from the given test.',
+ )
+ ..addFlag(
+ 'exit',
+ defaultsTo: true,
+ help: 'Exit on the first test failure. Currently flakes are intentionally (though '
+ 'incorrectly) not considered to be failures.',
+ )
+ ..addOption(
+ 'git-branch',
+ help: '[Flutter infrastructure] Git branch of the current commit. LUCI\n'
+ 'checkouts run in detached HEAD state, so the branch must be passed.',
+ )
+ ..addOption(
+ 'local-engine',
+ help: 'Name of a build output within the engine out directory, if you\n'
+ 'are building Flutter locally. Use this to select a specific\n'
+ 'version of the engine if you have built multiple engine targets.\n'
+ 'This path is relative to --local-engine-src-path/out. This option\n'
+ 'is required when running an A/B test (see the --ab option).',
+ )
+ ..addFlag(
+ 'list',
+ abbr: 'l',
+ help: "Don't actually run the tasks, but list out the tasks that would\n"
+ 'have been run, in the order they would have run.',
+ )
+ ..addOption(
+ 'local-engine-src-path',
+ help: 'Path to your engine src directory, if you are building Flutter\n'
+ 'locally. Defaults to \$FLUTTER_ENGINE if set, or tries to guess at\n'
+ 'the location based on the value of the --flutter-root option.',
+ )
+ ..addOption('luci-builder', help: '[Flutter infrastructure] Name of the LUCI builder being run on.')
+ ..addFlag(
+ 'match-host-platform',
+ defaultsTo: true,
+ help: 'Only run tests that match the host platform (e.g. do not run a\n'
+ 'test with a `required_agent_capabilities` value of "mac/android"\n'
+ 'on a windows host). Each test publishes its '
+ '`required_agent_capabilities`\nin the `manifest.yaml` file.',
+ )
+ ..addOption(
+ 'results-file',
+ help: '[Flutter infrastructure] File path for test results. If passed with\n'
+ 'task, will write test results to the file.'
+ )
+ ..addOption(
+ 'service-account-token-file',
+ help: '[Flutter infrastructure] Authentication for uploading results.',
+ )
+ ..addOption(
+ 'stage',
+ abbr: 's',
+ help: 'Name of the stage. Runs all tasks for that stage. The tasks and\n'
+ 'their stages are read from manifest.yaml.',
+ )
+ ..addFlag(
+ 'silent',
+ help: 'Reduce verbosity slightly.',
+ )
+ ..addFlag(
+ 'terminate-stray-dart-processes',
+ defaultsTo: true,
+ help: 'Whether to send a SIGKILL signal to any Dart processes that are still '
+ 'running when a task is completed. If any Dart processes are terminated '
+ 'in this way, the test is considered to have failed.',
+ )
+ ..addMultiOption(
+ 'test',
+ hide: true,
+ callback: (List<String> value) {
+ if (value.isNotEmpty) {
+ throw const FormatException(
+ 'Invalid option --test. Did you mean --task (-t)?',
+ );
+ }
+ },
+ );
+}
diff --git a/dev/devicelab/lib/framework/framework.dart b/dev/devicelab/lib/framework/framework.dart
index 1c9f689..a81f035 100644
--- a/dev/devicelab/lib/framework/framework.dart
+++ b/dev/devicelab/lib/framework/framework.dart
@@ -10,6 +10,7 @@
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
+import 'package:process/process.dart';
import 'package:stack_trace/stack_trace.dart';
import 'devices.dart';
@@ -41,35 +42,43 @@
///
/// It is OK for a [task] to perform many things. However, only one task can be
/// registered per Dart VM.
-Future<TaskResult> task(TaskFunction task) async {
+///
+/// If no `processManager` is provided, a default [LocalProcessManager] is created
+/// for the task.
+Future<TaskResult> task(TaskFunction task, { ProcessManager? processManager }) async {
if (_isTaskRegistered)
throw StateError('A task is already registered');
-
_isTaskRegistered = true;
+ processManager ??= const LocalProcessManager();
+
// TODO(ianh): allow overriding logging.
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((LogRecord rec) {
print('${rec.level.name}: ${rec.time}: ${rec.message}');
});
- final _TaskRunner runner = _TaskRunner(task);
+ final _TaskRunner runner = _TaskRunner(task, processManager);
runner.keepVmAliveUntilTaskRunRequested();
return runner.whenDone;
}
class _TaskRunner {
- _TaskRunner(this.task) {
+ _TaskRunner(this.task, this.processManager) {
registerExtension('ext.cocoonRunTask',
(String method, Map<String, String> parameters) async {
final Duration? taskTimeout = parameters.containsKey('timeoutInMinutes')
? Duration(minutes: int.parse(parameters['timeoutInMinutes']!))
: null;
- // This is only expected to be passed in unit test runs so they do not
- // kill the Dart process that is running them and waste time running config.
- final bool runFlutterConfig = parameters['runFlutterConfig'] != 'false';
+ final bool runFlutterConfig = parameters['runFlutterConfig'] != 'false'; // used by tests to avoid changing the configuration
final bool runProcessCleanup = parameters['runProcessCleanup'] != 'false';
- final TaskResult result = await run(taskTimeout, runProcessCleanup: runProcessCleanup, runFlutterConfig: runFlutterConfig);
+ final String? localEngine = parameters['localEngine'];
+ final TaskResult result = await run(
+ taskTimeout,
+ runProcessCleanup: runProcessCleanup,
+ runFlutterConfig: runFlutterConfig,
+ localEngine: localEngine,
+ );
return ServiceExtensionResponse.result(json.encode(result.toJson()));
});
registerExtension('ext.cocoonRunnerReady',
@@ -79,6 +88,7 @@
}
final TaskFunction task;
+ final ProcessManager processManager;
Future<Device?> _getWorkingDeviceIfAvailable() async {
try {
@@ -103,6 +113,7 @@
Future<TaskResult> run(Duration? taskTimeout, {
bool runFlutterConfig = true,
bool runProcessCleanup = true,
+ required String? localEngine,
}) async {
try {
_taskStarted = true;
@@ -113,20 +124,19 @@
section('Checking running Dart$exe processes');
beforeRunningDartInstances = await getRunningProcesses(
processName: 'dart$exe',
- ).toSet();
- final Set<RunningProcessInfo> allProcesses = await getRunningProcesses().toSet();
+ processManager: processManager,
+ );
+ final Set<RunningProcessInfo> allProcesses = await getRunningProcesses(processManager: processManager);
beforeRunningDartInstances.forEach(print);
for (final RunningProcessInfo info in allProcesses) {
if (info.commandLine.contains('iproxy')) {
print('[LEAK]: ${info.commandLine} ${info.creationDate} ${info.pid} ');
}
}
- } else {
- section('Skipping check running Dart$exe processes');
}
if (runFlutterConfig) {
- print('enabling configs for macOS, Linux, Windows, and Web...');
+ print('Enabling configs for macOS, Linux, Windows, and Web...');
final int configResult = await exec(path.join(flutterDirectory.path, 'bin', 'flutter'), <String>[
'config',
'-v',
@@ -134,13 +144,11 @@
'--enable-windows-desktop',
'--enable-linux-desktop',
'--enable-web',
- if (localEngine != null) ...<String>['--local-engine', localEngine!],
+ if (localEngine != null) ...<String>['--local-engine', localEngine],
], canFail: true);
if (configResult != 0) {
print('Failed to enable configuration, tasks may not run.');
}
- } else {
- print('Skipping enabling configs for macOS, Linux, Windows, and Web');
}
final Device? device = await _getWorkingDeviceIfAvailable();
@@ -169,26 +177,24 @@
}
if (runProcessCleanup) {
- section('Checking running Dart$exe processes after task...');
- final List<RunningProcessInfo> afterRunningDartInstances = await getRunningProcesses(
+ section('Terminating lingering Dart$exe processes after task...');
+ final Set<RunningProcessInfo> afterRunningDartInstances = await getRunningProcesses(
processName: 'dart$exe',
- ).toList();
+ processManager: processManager,
+ );
for (final RunningProcessInfo info in afterRunningDartInstances) {
if (!beforeRunningDartInstances.contains(info)) {
print('$info was leaked by this test.');
if (result is TaskResultCheckProcesses) {
result = TaskResult.failure('This test leaked dart processes');
}
- final bool killed = await killProcess(info.pid);
- if (!killed) {
- print('Failed to kill process ${info.pid}.');
- } else {
+ if (await info.terminate(processManager: processManager)) {
print('Killed process id ${info.pid}.');
+ } else {
+ print('Failed to kill process ${info.pid}.');
}
}
}
- } else {
- print('Skipping check running Dart$exe processes after task');
}
_completer.complete(result);
return result;
diff --git a/dev/devicelab/lib/framework/runner.dart b/dev/devicelab/lib/framework/runner.dart
index de7cf5d..56aa781 100644
--- a/dev/devicelab/lib/framework/runner.dart
+++ b/dev/devicelab/lib/framework/runner.dart
@@ -4,7 +4,6 @@
import 'dart:async';
import 'dart:convert';
-// import 'dart:core' as core;
import 'dart:io';
import 'package:meta/meta.dart';
@@ -29,6 +28,10 @@
Future<void> runTasks(
List<String> taskNames, {
bool exitOnFirstTestFailure = false,
+ // terminateStrayDartProcesses defaults to false so that tests don't have to specify it.
+ // It is set based on the --terminate-stray-dart-processes command line argument in
+ // normal execution, and that flag defaults to true.
+ bool terminateStrayDartProcesses = false,
bool silent = false,
String? deviceId,
String? gitBranch,
@@ -50,6 +53,7 @@
deviceId: deviceId,
localEngine: localEngine,
localEngineSrcPath: localEngineSrcPath,
+ terminateStrayDartProcesses: terminateStrayDartProcesses,
silent: silent,
taskArgs: taskArgs,
resultsPath: resultsPath,
@@ -58,15 +62,17 @@
isolateParams: isolateParams,
);
- section('Flaky status for "$taskName"');
if (!result.succeeded) {
- retry++;
+ retry += 1;
} else {
+ section('Flaky status for "$taskName"');
if (retry > 0) {
- print('Total ${retry+1} executions: $retry failures and 1 success');
+ print('Total ${retry+1} executions: $retry failures and 1 false positive.');
print('flaky: true');
+ // TODO(ianh): stop ignoring this failure. We should set exitCode=1, and quit
+ // if exitOnFirstTestFailure is true.
} else {
- print('Total ${retry+1} executions: 1 success');
+ print('Test passed on first attempt.');
print('flaky: false');
}
break;
@@ -74,7 +80,8 @@
}
if (!result.succeeded) {
- print('Total $retry executions: 0 success');
+ section('Flaky status for "$taskName"');
+ print('Consistently failed across all $retry executions.');
print('flaky: false');
exitCode = 1;
if (exitOnFirstTestFailure) {
@@ -92,6 +99,7 @@
String? deviceId,
String? localEngine,
String? localEngineSrcPath,
+ bool terminateStrayDartProcesses = false,
bool silent = false,
List<String>? taskArgs,
String? resultsPath,
@@ -105,6 +113,7 @@
deviceId: deviceId,
localEngine: localEngine,
localEngineSrcPath: localEngineSrcPath,
+ terminateStrayDartProcesses: terminateStrayDartProcesses,
silent: silent,
taskArgs: taskArgs,
isolateParams: isolateParams,
@@ -138,11 +147,12 @@
/// [taskArgs] are passed to the task executable for additional configuration.
Future<TaskResult> runTask(
String taskName, {
+ bool terminateStrayDartProcesses = false,
bool silent = false,
String? localEngine,
String? localEngineSrcPath,
String? deviceId,
- List<String> ?taskArgs,
+ List<String>? taskArgs,
@visibleForTesting Map<String, String>? isolateParams,
}) async {
final String taskExecutable = 'bin/tasks/$taskName.dart';
@@ -198,17 +208,26 @@
try {
final ConnectionResult result = await _connectToRunnerIsolate(await uri.future);
+ print('[$taskName] Connected to VM server.');
+ isolateParams = isolateParams == null ? <String, String>{} : Map<String, String>.of(isolateParams);
+ isolateParams['runProcessCleanup'] = terminateStrayDartProcesses.toString();
final Map<String, dynamic> taskResultJson = (await result.vmService.callServiceExtension(
'ext.cocoonRunTask',
args: isolateParams,
isolateId: result.isolate.id,
)).json!;
final TaskResult taskResult = TaskResult.fromJson(taskResultJson);
- await runner.exitCode;
+ final int exitCode = await runner.exitCode;
+ print('[$taskName] Process terminated with exit code $exitCode.');
return taskResult;
+ } catch (error, stack) {
+ print('[$taskName] Task runner system failed with exception!\n$error\n$stack');
+ rethrow;
} finally {
- if (!runnerFinished)
+ if (!runnerFinished) {
+ print('[$taskName] Terminating process...');
runner.kill(ProcessSignal.sigkill);
+ }
await stdoutSub.cancel();
await stderrSub.cancel();
}
diff --git a/dev/devicelab/lib/framework/running_processes.dart b/dev/devicelab/lib/framework/running_processes.dart
index 07a5e52..96b6e59 100644
--- a/dev/devicelab/lib/framework/running_processes.dart
+++ b/dev/devicelab/lib/framework/running_processes.dart
@@ -9,12 +9,12 @@
@immutable
class RunningProcessInfo {
- const RunningProcessInfo(this.pid, this.creationDate, this.commandLine)
+ const RunningProcessInfo(this.pid, this.commandLine, this.creationDate)
: assert(pid != null),
assert(commandLine != null);
+ final int pid;
final String commandLine;
- final String pid;
final DateTime creationDate;
@override
@@ -25,57 +25,54 @@
&& other.creationDate == creationDate;
}
+ Future<bool> terminate({required ProcessManager processManager}) async {
+ // This returns true when the signal is sent, not when the process goes away.
+ // See also https://github.com/dart-lang/sdk/issues/40759 (killPid should wait for process to be terminated).
+ if (Platform.isWindows) {
+ // TODO(ianh): Move Windows to killPid once we can.
+ // - killPid on Windows has not-useful return code: https://github.com/dart-lang/sdk/issues/47675
+ final ProcessResult result = await processManager.run(<String>[
+ 'taskkill.exe',
+ '/pid',
+ '$pid',
+ '/f',
+ ]);
+ return result.exitCode == 0;
+ }
+ return processManager.killPid(pid, ProcessSignal.sigkill);
+ }
+
@override
int get hashCode => Object.hash(pid, commandLine, creationDate);
@override
String toString() {
- return 'RunningProcesses{pid: $pid, commandLine: $commandLine, creationDate: $creationDate}';
+ return 'RunningProcesses(pid: $pid, commandLine: $commandLine, creationDate: $creationDate)';
}
}
-Future<bool> killProcess(String pid, {ProcessManager? processManager}) async {
- assert(pid != null, 'Must specify a pid to kill');
- processManager ??= const LocalProcessManager();
- ProcessResult result;
- if (Platform.isWindows) {
- result = await processManager.run(<String>[
- 'taskkill.exe',
- '/pid',
- pid,
- '/f',
- ]);
- } else {
- result = await processManager.run(<String>[
- 'kill',
- '-9',
- pid,
- ]);
- }
- return result.exitCode == 0;
-}
-
-Stream<RunningProcessInfo> getRunningProcesses({
+Future<Set<RunningProcessInfo>> getRunningProcesses({
String? processName,
- ProcessManager? processManager,
+ required ProcessManager processManager,
}) {
- processManager ??= const LocalProcessManager();
if (Platform.isWindows) {
- return windowsRunningProcesses(processName);
+ return windowsRunningProcesses(processName, processManager);
}
return posixRunningProcesses(processName, processManager);
}
@visibleForTesting
-Stream<RunningProcessInfo> windowsRunningProcesses(String? processName) async* {
- // PowerShell script to get the command line arguments and create time of
- // a process.
+Future<Set<RunningProcessInfo>> windowsRunningProcesses(
+ String? processName,
+ ProcessManager processManager,
+) async {
+ // PowerShell script to get the command line arguments and create time of a process.
// See: https://docs.microsoft.com/en-us/windows/desktop/cimwin32prov/win32-process
final String script = processName != null
? '"Get-CimInstance Win32_Process -Filter \\"name=\'$processName\'\\" | Select-Object ProcessId,CreationDate,CommandLine | Format-Table -AutoSize | Out-String -Width 4096"'
: '"Get-CimInstance Win32_Process | Select-Object ProcessId,CreationDate,CommandLine | Format-Table -AutoSize | Out-String -Width 4096"';
- // Unfortunately, there doesn't seem to be a good way to get ProcessManager to
- // run this.
+ // TODO(ianh): Unfortunately, there doesn't seem to be a good way to get
+ // ProcessManager to run this.
final ProcessResult result = await Process.run(
'powershell -command $script',
<String>[],
@@ -84,11 +81,9 @@
print('Could not list processes!');
print(result.stderr);
print(result.stdout);
- return;
+ return <RunningProcessInfo>{};
}
- for (final RunningProcessInfo info in processPowershellOutput(result.stdout as String)) {
- yield info;
- }
+ return processPowershellOutput(result.stdout as String).toSet();
}
/// Parses the output of the PowerShell script from [windowsRunningProcesses].
@@ -149,22 +144,22 @@
time = '${hours + 12}${time.substring(2)}';
}
- final String pid = line.substring(0, processIdHeaderSize).trim();
+ final int pid = int.parse(line.substring(0, processIdHeaderSize).trim());
final DateTime creationDate = DateTime.parse('$year-$month-${day}T$time');
final String commandLine = line.substring(commandLineHeaderStart).trim();
- yield RunningProcessInfo(pid, creationDate, commandLine);
+ yield RunningProcessInfo(pid, commandLine, creationDate);
}
}
@visibleForTesting
-Stream<RunningProcessInfo> posixRunningProcesses(
+Future<Set<RunningProcessInfo>> posixRunningProcesses(
String? processName,
ProcessManager processManager,
-) async* {
+) async {
// Cirrus is missing this in Linux for some reason.
if (!processManager.canRun('ps')) {
- print('Cannot list processes on this system: `ps` not available.');
- return;
+ print('Cannot list processes on this system: "ps" not available.');
+ return <RunningProcessInfo>{};
}
final ProcessResult result = await processManager.run(<String>[
'ps',
@@ -175,11 +170,9 @@
print('Could not list processes!');
print(result.stderr);
print(result.stdout);
- return;
+ return <RunningProcessInfo>{};
}
- for (final RunningProcessInfo info in processPsOutput(result.stdout as String, processName)) {
- yield info;
- }
+ return processPsOutput(result.stdout as String, processName).toSet();
}
/// Parses the output of the command in [posixRunningProcesses].
@@ -240,8 +233,8 @@
final DateTime creationDate = DateTime.parse('$year-$month-${day}T$time');
line = line.substring(24).trim();
final int nextSpace = line.indexOf(' ');
- final String pid = line.substring(0, nextSpace);
+ final int pid = int.parse(line.substring(0, nextSpace));
final String commandLine = line.substring(nextSpace + 1);
- yield RunningProcessInfo(pid, creationDate, commandLine);
+ yield RunningProcessInfo(pid, commandLine, creationDate);
}
}
diff --git a/dev/devicelab/lib/framework/utils.dart b/dev/devicelab/lib/framework/utils.dart
index f5d960b..b1ef715 100644
--- a/dev/devicelab/lib/framework/utils.dart
+++ b/dev/devicelab/lib/framework/utils.dart
@@ -20,14 +20,18 @@
String cwd = Directory.current.path;
/// The local engine to use for [flutter] and [evalFlutter], if any.
-String? get localEngine {
+///
+/// This is set as an environment variable when running the task, see runTask in runner.dart.
+String? get localEngineFromEnv {
const bool isDefined = bool.hasEnvironment('localEngine');
return isDefined ? const String.fromEnvironment('localEngine') : null;
}
/// The local engine source path to use if a local engine is used for [flutter]
/// and [evalFlutter].
-String? get localEngineSrcPath {
+///
+/// This is set as an environment variable when running the task, see runTask in runner.dart.
+String? get localEngineSrcPathFromEnv {
const bool isDefined = bool.hasEnvironment('localEngineSrcPath');
return isDefined ? const String.fromEnvironment('localEngineSrcPath') : null;
}
@@ -45,9 +49,9 @@
@override
String toString() {
return '''
- command : $command
- started : $startTime
- pid : ${process.pid}
+ command: $command
+ started: $startTime
+ pid : ${process.pid}
'''
.trim();
}
@@ -275,7 +279,7 @@
final Map<String, String> newEnvironment = Map<String, String>.from(environment ?? <String, String>{});
newEnvironment['BOT'] = isBot ? 'true' : 'false';
newEnvironment['LANG'] = 'en_US.UTF-8';
- print('\nExecuting: $command in $finalWorkingDirectory with environment $newEnvironment');
+ print('Executing "$command" in "$finalWorkingDirectory" with environment $newEnvironment');
final Process process = await _processManager.start(
<String>[executable, ...?arguments],
environment: newEnvironment,
@@ -285,7 +289,6 @@
_runningProcesses.add(processInfo);
unawaited(process.exitCode.then<void>((int exitCode) {
- print('"$executable" exit code: $exitCode');
_runningProcesses.remove(processInfo);
}));
@@ -303,7 +306,7 @@
for (final ProcessInfo p in _runningProcesses) {
print('Force-quitting process:\n$p');
if (!p.process.kill()) {
- print('Failed to force quit process');
+ print('Failed to force quit process.');
}
}
_runningProcesses.clear();
@@ -349,7 +352,7 @@
stderr: stderr,
printStdout: printStdout,
printStderr: printStderr,
- );
+ );
final int exitCode = await process.exitCode;
if (exitCode != 0 && !canFail)
@@ -373,23 +376,23 @@
final Completer<void> stdoutDone = Completer<void>();
final Completer<void> stderrDone = Completer<void>();
process.stdout
- .transform<String>(utf8.decoder)
- .transform<String>(const LineSplitter())
- .listen((String line) {
- if (printStdout) {
- print('stdout: $line');
- }
- output?.writeln(line);
- }, onDone: () { stdoutDone.complete(); });
+ .transform<String>(utf8.decoder)
+ .transform<String>(const LineSplitter())
+ .listen((String line) {
+ if (printStdout) {
+ print('stdout: $line');
+ }
+ output?.writeln(line);
+ }, onDone: () { stdoutDone.complete(); });
process.stderr
- .transform<String>(utf8.decoder)
- .transform<String>(const LineSplitter())
- .listen((String line) {
- if (printStderr) {
- print('stderr: $line');
- }
- stderr?.writeln(line);
- }, onDone: () { stderrDone.complete(); });
+ .transform<String>(utf8.decoder)
+ .transform<String>(const LineSplitter())
+ .listen((String line) {
+ if (printStderr) {
+ print('stderr: $line');
+ }
+ stderr?.writeln(line);
+ }, onDone: () { stderrDone.complete(); });
return Future.wait<void>(<Future<void>>[
stdoutDone.future,
@@ -436,6 +439,8 @@
'run',
'screenshot',
};
+ final String? localEngine = localEngineFromEnv;
+ final String? localEngineSrcPath = localEngineSrcPathFromEnv;
return <String>[
command,
if (deviceOperatingSystem == DeviceOperatingSystem.ios && supportedDeviceTimeoutCommands.contains(command))
@@ -448,8 +453,8 @@
'--screenshot',
hostAgent.dumpDirectory!.path,
],
- if (localEngine != null) ...<String>['--local-engine', localEngine!],
- if (localEngineSrcPath != null) ...<String>['--local-engine-src-path', localEngineSrcPath!],
+ if (localEngine != null) ...<String>['--local-engine', localEngine],
+ if (localEngineSrcPath != null) ...<String>['--local-engine-src-path', localEngineSrcPath],
...options,
];
}
diff --git a/dev/devicelab/lib/tasks/perf_tests.dart b/dev/devicelab/lib/tasks/perf_tests.dart
index 79ce804..306464e 100644
--- a/dev/devicelab/lib/tasks/perf_tests.dart
+++ b/dev/devicelab/lib/tasks/perf_tests.dart
@@ -847,12 +847,14 @@
final Device device = await devices.workingDevice;
await device.unlock();
final String deviceId = device.deviceId;
+ final String? localEngine = localEngineFromEnv;
+ final String? localEngineSrcPath = localEngineSrcPathFromEnv;
await flutter('drive', options: <String>[
if (localEngine != null)
- ...<String>['--local-engine', localEngine!],
+ ...<String>['--local-engine', localEngine],
if (localEngineSrcPath != null)
- ...<String>['--local-engine-src-path', localEngineSrcPath!],
+ ...<String>['--local-engine-src-path', localEngineSrcPath],
'--no-dds',
'--no-android-gradle-daemon',
'-v',
@@ -1028,15 +1030,16 @@
if (File(_vmserviceFileName).existsSync()) {
File(_vmserviceFileName).deleteSync();
}
-
+ final String? localEngine = localEngineFromEnv;
+ final String? localEngineSrcPath = localEngineSrcPathFromEnv;
_runProcess = await startProcess(
_flutterPath,
<String>[
'run',
if (localEngine != null)
- ...<String>['--local-engine', localEngine!],
+ ...<String>['--local-engine', localEngine],
if (localEngineSrcPath != null)
- ...<String>['--local-engine-src-path', localEngineSrcPath!],
+ ...<String>['--local-engine-src-path', localEngineSrcPath],
'--no-dds',
if (deviceOperatingSystem == DeviceOperatingSystem.ios)
...<String>[
diff --git a/dev/devicelab/test/run_test.dart b/dev/devicelab/test/run_test.dart
index 61ff3ea..a5332dd 100644
--- a/dev/devicelab/test/run_test.dart
+++ b/dev/devicelab/test/run_test.dart
@@ -4,6 +4,7 @@
import 'dart:io';
+import 'package:flutter_devicelab/framework/utils.dart' show rm;
import 'package:path/path.dart' as path;
import 'package:process/process.dart';
@@ -20,6 +21,7 @@
final ProcessResult scriptProcess = processManager.runSync(<String>[
dart,
'bin/run.dart',
+ '--no-terminate-stray-dart-processes',
...otherArgs,
for (final String testName in testNames) ...<String>['-t', testName],
]);
@@ -87,9 +89,14 @@
test('runs A/B test', () async {
+ final Directory tempDirectory = Directory.systemTemp.createTempSync('flutter_devicelab_ab_test.');
+ final File abResultsFile = File(path.join(tempDirectory.path, 'test_results.json'));
+
+ expect(abResultsFile.existsSync(), isFalse);
+
final ProcessResult result = await runScript(
<String>['smoke_test_success'],
- <String>['--ab=2', '--local-engine=host_debug_unopt'],
+ <String>['--ab=2', '--local-engine=host_debug_unopt', '--ab-result-file', abResultsFile.path],
);
expect(result.exitCode, 0);
@@ -137,6 +144,9 @@
'metric2\t123.00 (0.00%)\t123.00 (0.00%)\t1.00x\t\n',
),
);
+
+ expect(abResultsFile.existsSync(), isTrue);
+ rm(tempDirectory, recursive: true);
});
test('fails to upload results to Cocoon if flags given', () async {
diff --git a/dev/devicelab/test/runner_test.dart b/dev/devicelab/test/runner_test.dart
index 661a961..baee698 100644
--- a/dev/devicelab/test/runner_test.dart
+++ b/dev/devicelab/test/runner_test.dart
@@ -11,7 +11,6 @@
void main() {
final Map<String, String> isolateParams = <String, String>{
'runFlutterConfig': 'false',
- 'runProcessCleanup': 'false',
'timeoutInMinutes': '1',
};
List<String> printLog;
@@ -27,7 +26,7 @@
logs: printLog,
);
expect(printLog.length, 2);
- expect(printLog[0], 'Total 1 executions: 1 success');
+ expect(printLog[0], 'Test passed on first attempt.');
expect(printLog[1], 'flaky: false');
});
@@ -40,7 +39,7 @@
logs: printLog,
);
expect(printLog.length, 2);
- expect(printLog[0], 'Total 3 executions: 0 success');
+ expect(printLog[0], 'Consistently failed across all 3 executions.');
expect(printLog[1], 'flaky: false');
});
});
diff --git a/dev/devicelab/test/running_processes_test.dart b/dev/devicelab/test/running_processes_test.dart
index 76f3df0..100b3dd 100644
--- a/dev/devicelab/test/running_processes_test.dart
+++ b/dev/devicelab/test/running_processes_test.dart
@@ -2,7 +2,13 @@
// 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:flutter_devicelab/framework/running_processes.dart';
+import 'package:process/process.dart';
+
import 'common.dart';
void main() {
@@ -24,19 +30,19 @@
results,
equals(<RunningProcessInfo>[
RunningProcessInfo(
- '6552',
- DateTime(2019, 7, 3, 17, 0, 27),
+ 6552,
r'"C:\tools\dart-sdk\bin\dart.exe" .\bin\agent.dart ci',
+ DateTime(2019, 7, 3, 17, 0, 27),
),
RunningProcessInfo(
- '6553',
- DateTime(2019, 7, 3, 22, 0, 27),
+ 6553,
r'"C:\tools\dart-sdk1\bin\dart.exe" .\bin\agent.dart ci',
+ DateTime(2019, 7, 3, 22, 0, 27),
),
RunningProcessInfo(
- '6554',
- DateTime(2019, 7, 3, 11, 0, 27),
+ 6554,
r'"C:\tools\dart-sdk2\bin\dart.exe" .\bin\agent.dart ci',
+ DateTime(2019, 7, 3, 11, 0, 27),
),
]));
});
@@ -55,15 +61,81 @@
results,
equals(<RunningProcessInfo>[
RunningProcessInfo(
- '1',
- DateTime(2019, 3, 9, 20, 12, 47),
+ 1,
'/sbin/launchd',
+ DateTime(2019, 3, 9, 20, 12, 47),
),
RunningProcessInfo(
- '49',
- DateTime(2019, 3, 9, 20, 13),
+ 49,
'/usr/sbin/syslogd',
+ DateTime(2019, 3, 9, 20, 13),
),
]));
});
+
+ test('RunningProcessInfo.terminate', () {
+ final RunningProcessInfo process = RunningProcessInfo(123, 'test', DateTime(456));
+ final FakeProcessManager fakeProcessManager = FakeProcessManager();
+ process.terminate(processManager: fakeProcessManager);
+ if (Platform.isWindows) {
+ expect(fakeProcessManager.log, <String>['run([taskkill.exe, /pid, 123, /f], null, null, null, null, null, null)']);
+ } else {
+ expect(fakeProcessManager.log, <String>['killPid(123, SIGKILL)']);
+ }
+ });
+}
+
+class FakeProcessManager implements ProcessManager {
+ final List<String> log = <String>[];
+
+ @override
+ bool canRun(Object? a, { String? workingDirectory }) {
+ log.add('canRun($a, $workingDirectory)');
+ return true;
+ }
+
+ @override
+ bool killPid(int a, [ProcessSignal? b]) {
+ log.add('killPid($a, $b)');
+ return true;
+ }
+
+ @override
+ Future<ProcessResult> run(List<Object> a, {
+ Map<String, String>? environment,
+ bool? includeParentEnvironment,
+ bool? runInShell,
+ Encoding? stderrEncoding,
+ Encoding? stdoutEncoding,
+ String? workingDirectory,
+ }) async {
+ log.add('run($a, $environment, $includeParentEnvironment, $runInShell, $stderrEncoding, $stdoutEncoding, $workingDirectory)');
+ return ProcessResult(1, 0, 'stdout', 'stderr');
+ }
+
+ @override
+ ProcessResult runSync(List<Object> a, {
+ Map<String, String>? environment,
+ bool? includeParentEnvironment,
+ bool? runInShell,
+ Encoding? stderrEncoding,
+ Encoding? stdoutEncoding,
+ String? workingDirectory,
+ }) {
+ log.add('runSync($a, $environment, $includeParentEnvironment, $runInShell, $stderrEncoding, $stdoutEncoding, $workingDirectory)');
+ return ProcessResult(1, 0, 'stdout', 'stderr');
+ }
+
+ @override
+ Future<Process> start(
+ List<Object> a, {
+ Map<String, String>? environment,
+ bool? includeParentEnvironment,
+ ProcessStartMode? mode,
+ bool? runInShell,
+ String? workingDirectory,
+ }) {
+ log.add('start($a, $environment, $includeParentEnvironment, $mode, $runInShell, $workingDirectory)');
+ return Completer<Process>().future;
+ }
}
diff --git a/dev/devicelab/test/tasks/build_test_task_test.dart b/dev/devicelab/test/tasks/build_test_task_test.dart
index 5cc8b98..70bb86b 100644
--- a/dev/devicelab/test/tasks/build_test_task_test.dart
+++ b/dev/devicelab/test/tasks/build_test_task_test.dart
@@ -12,7 +12,6 @@
void main() {
final Map<String, String> isolateParams = <String, String>{
'runFlutterConfig': 'false',
- 'runProcessCleanup': 'false',
'timeoutInMinutes': '1',
};
@@ -66,7 +65,7 @@
final String capturedPrint = capturedPrintLines.toString();
expect(capturedPrint,
contains('with environment {FLUTTER_DEVICELAB_DEVICEID: FAKE_SUCCESS, BOT: true, LANG: en_US.UTF-8}'));
- expect(capturedPrint, contains('exit code: 0'));
+ expect(capturedPrint, contains('Process terminated with exit code 0.'));
});
test('throws exception when build and test arg are given', () async {
diff --git a/dev/devicelab/test/utils_test.dart b/dev/devicelab/test/utils_test.dart
index 06db6a1..ca3e12b 100644
--- a/dev/devicelab/test/utils_test.dart
+++ b/dev/devicelab/test/utils_test.dart
@@ -36,8 +36,8 @@
group('engine environment declarations', () {
test('localEngine', () {
- expect(localEngine, null);
- expect(localEngineSrcPath, null);
+ expect(localEngineFromEnv, null);
+ expect(localEngineSrcPathFromEnv, null);
});
});
}