blob: 75445927f34407331812e0f2df193a3f5156c9c1 [file] [log] [blame]
// Copyright 2016 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:io';
import 'dart:isolate';
import 'package:args/args.dart';
import 'package:path/path.dart' as path;
import '../adb.dart';
import '../agent.dart';
import '../health.dart';
import '../runner.dart';
import '../utils.dart';
/// Agents periodically poll the server for more tasks. This sleep period is
/// used to prevent us from DDoS-ing the server.
const Duration _sleepBetweenBuilds = const Duration(seconds: 10);
/// Maximum amount of time we're allowing Flutter to install.
const Duration _kInstallationTimeout = const Duration(minutes: 20);
/// Runs the agent in continuous integration mode.
///
/// In this mode the agent runs in an infinite loop, continuously asking for
/// more tasks from Cocoon, performing them and reporting back results.
class ContinuousIntegrationCommand extends Command {
ContinuousIntegrationCommand(Agent agent) : super('ci', agent);
final List<StreamSubscription> _streamSubscriptions = <StreamSubscription>[];
bool _exiting = false;
@override
Future<Null> run(ArgResults args) async {
// Ensure keychain is unlocked before running health checks.
await _unlockKeyChain();
// Perform one pre-flight round of checks and quit immediately if something
// is wrong.
AgentHealth health = await performHealthChecks(agent);
section('Pre-flight checks:');
health
.toString()
.split('\n')
.map((String line) => line.trim())
.where((String line) => line.isNotEmpty)
.forEach(logger.info);
if (!health.ok) {
logger.error('Some pre-flight checks failed. Quitting.');
exit(1);
}
// Do not quit on uncaught exceptions because we're in CI mode.
Isolate.current.setErrorsFatal(false);
// Start CI mode
section('Started continuous integration:');
_listenToShutdownSignals();
while (!_exiting) {
await runZoned(() async {
agent.resetHttpClient();
// This try/catch captures errors that we cannot send to the server,
// because we have not yet reserved a task. It will simply log to the
// console.
try {
section('Preflight checks');
await devices.performPreflightTasks();
// Check health before requesting a new task.
health = await performHealthChecks(agent);
// Always upload health status whether succeeded or failed.
await agent.updateHealthStatus(health);
if (!health.ok) {
logger.warning('Some health checks failed:');
health
.toString()
.split('\n')
.map((String line) => line.trim())
.where((String line) => line.isNotEmpty)
.forEach(logger.warning);
await Future<void>.delayed(_sleepBetweenBuilds);
// Don't bother requesting new tasks if health is bad.
return;
}
section('Requesting a task');
CocoonTask task = await agent.reserveTask();
// Errors that happen inside this try/catch will be uploaded to the
// server because we have succeeded at reserving a task.
try {
if (task != null) {
section('Task info:');
logger.info(' name : ${task.name}');
logger.info(' key : ${task.key ?? ""}');
logger.info(' revision : ${task.revision}');
if (task.timeoutInMinutes != 0) {
logger.info(' custom timeout : ${task.timeoutInMinutes}');
}
// TODO(https://github.com/flutter/flutter/issues/29141) - remove
// once source of leaked processes is identified.
if (Platform.isWindows) {
// Kill all dart.exe that are potentially holding flutter bin/cache,
// preventing it from being deleted.
await killAllRunningProcessesOnWindows('dart');
}
// Sync flutter outside of the task so it does not contribute to
// the task timeout.
await getFlutterAt(task.revision).timeout(_kInstallationTimeout);
await _cleanBuildDirectories(agent, task);
// Ensure keychain is unlocked before running task.
await _unlockKeyChain();
// Ensure the phone's screen is on before running a task.
await _screensOn();
await _runTask(task);
} else {
logger.info('No tasks available for this agent.');
}
} catch (error, stackTrace) {
String errorMessage = 'ERROR: $error\n$stackTrace';
logger.error(errorMessage);
await agent.reportFailure(task.key, errorMessage);
}
} catch (error, stackTrace) {
// Unable to report failure to the backend.
stderr.writeln('ERROR: $error\n$stackTrace');
} finally {
await _screensOff();
await forceQuitRunningProcesses();
}
logger.info('Pausing before asking for more tasks.');
await Future<void>.delayed(_sleepBetweenBuilds);
}, onError: (dynamic error, StackTrace stackTrace) {
// Catches errors from dangling futures that cannot be reported to the
// server.
stderr.writeln('ERROR: $error\n$stackTrace');
});
}
}
/// Recursively finds all Dart packages in the cloned Flutter repository
/// (i.e. directories with `pubspec.yaml` files), and deletes the `build`
/// directories, if any.
///
/// This is to prevent cross-contamination of build artifacts across tests.
Future<Null> _cleanBuildDirectories(Agent agent, CocoonTask task) async {
Future<Null> recursivelyDeleteBuildDirectories(Directory directory) async {
final List<FileSystemEntity> contents = directory.listSync();
final bool isDartPackage = contents.any((FileSystemEntity entity) =>
entity is File && path.basename(entity.path) == 'pubspec.yaml');
if (isDartPackage) {
for (FileSystemEntity entity in contents) {
if (entity is Directory && path.basename(entity.path) == 'build') {
await agent.uploadLogChunk(task.key, 'Deleting ${entity.path}\n');
rrm(entity);
}
}
} else {
for (FileSystemEntity entity in contents) {
if (entity is Directory) {
await recursivelyDeleteBuildDirectories(entity);
}
}
}
}
await agent.uploadLogChunk(
task.key, 'Deleting build/ directories, if any.\n');
try {
await recursivelyDeleteBuildDirectories(config.flutterDirectory);
} catch (error, stack) {
await agent.uploadLogChunk(
task.key,
'Failed to delete build/ directories: $error\n\n$stack',
);
}
}
Future<Null> _runTask(CocoonTask task) async {
TaskResult result = await runTask(agent, task);
if (result.succeeded) {
await agent.reportSuccess(
task.key, result.data, result.benchmarkScoreKeys);
} else {
await agent.reportFailure(task.key, result.reason);
}
}
Future<Null> _screensOff() async {
try {
for (Device device in await devices.discoverDevices()) {
await device.disableAccessibility();
await device.sendToSleep();
}
} catch (error, stackTrace) {
// Best effort only.
logger.warning('Failed to turn off screen: $error\n$stackTrace');
}
}
Future<Null> _screensOn() async {
try {
for (Device device in await devices.discoverDevices()) {
await device.wakeUp();
}
} catch (error, stackTrace) {
// Best effort only.
logger.warning('Failed to turn on screen: $error\n$stackTrace');
}
}
void _listenToShutdownSignals() {
_streamSubscriptions.add(ProcessSignal.sigint.watch().listen((_) {
logger.info('\nReceived SIGINT. Shutting down.');
_stop(ProcessSignal.sigint);
}));
if (!Platform.isWindows) {
_streamSubscriptions.add(ProcessSignal.sigterm.watch().listen((_) {
logger.info('\nReceived SIGTERM. Shutting down.');
_stop(ProcessSignal.sigterm);
}));
}
}
Future<Null> _unlockKeyChain() async {
// Unlocking the keychain is required to:
// * Enable Xcode to access the certificate for code signing.
// * Mitigate "Your session has expired" issue. See flutter/flutter#17860.
if (Platform.isMacOS) {
await exec(
'security',
<String>[
'unlock-keychain',
'-p',
Platform.environment['FLUTTER_USER_SECRET'],
'login.keychain'
],
canFail: false,
silent: true);
}
}
Future<Null> _stop(ProcessSignal signal) async {
_exiting = true;
for (StreamSubscription sub in _streamSubscriptions) {
await sub.cancel();
}
_streamSubscriptions.clear();
// TODO(yjbanov): stop processes launched by tasks, if any
await Future<void>.delayed(const Duration(seconds: 1));
exit(0);
}
}