// Copyright 2014 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 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../convert.dart'; import 'common.dart'; import 'context.dart'; import 'io.dart'; import 'logger.dart'; import 'utils.dart'; typedef StringConverter = String Function(String string); /// A function that will be run before the VM exits. typedef ShutdownHook = FutureOr<dynamic> Function(); // TODO(ianh): We have way too many ways to run subprocesses in this project. // Convert most of these into one or more lightweight wrappers around the // [ProcessManager] API using named parameters for the various options. // See [here](https://github.com/flutter/flutter/pull/14535#discussion_r167041161) // for more details. /// The stage in which a [ShutdownHook] will be run. All shutdown hooks within /// a given stage will be started in parallel and will be guaranteed to run to /// completion before shutdown hooks in the next stage are started. class ShutdownStage implements Comparable<ShutdownStage> { const ShutdownStage._(this.priority); /// The stage priority. Smaller values will be run before larger values. final int priority; /// The stage before the invocation recording (if one exists) is serialized /// to disk. Tasks performed during this stage *will* be recorded. static const ShutdownStage STILL_RECORDING = ShutdownStage._(1); /// The stage during which the invocation recording (if one exists) will be /// serialized to disk. Invocations performed after this stage will not be /// recorded. static const ShutdownStage SERIALIZE_RECORDING = ShutdownStage._(2); /// The stage during which a serialized recording will be refined (e.g. /// cleansed for tests, zipped up for bug reporting purposes, etc.). static const ShutdownStage POST_PROCESS_RECORDING = ShutdownStage._(3); /// The stage during which temporary files and directories will be deleted. static const ShutdownStage CLEANUP = ShutdownStage._(4); @override int compareTo(ShutdownStage other) => priority.compareTo(other.priority); } ShutdownHooks get shutdownHooks => ShutdownHooks.instance; abstract class ShutdownHooks { factory ShutdownHooks({ @required Logger logger, }) => _DefaultShutdownHooks( logger: logger, ); static ShutdownHooks get instance => context.get<ShutdownHooks>(); /// Registers a [ShutdownHook] to be executed before the VM exits. /// /// If [stage] is specified, the shutdown hook will be run during the specified /// stage. By default, the shutdown hook will be run during the /// [ShutdownStage.CLEANUP] stage. void addShutdownHook( ShutdownHook shutdownHook, [ ShutdownStage stage = ShutdownStage.CLEANUP, ]); /// Runs all registered shutdown hooks and returns a future that completes when /// all such hooks have finished. /// /// Shutdown hooks will be run in groups by their [ShutdownStage]. All shutdown /// hooks within a given stage will be started in parallel and will be /// guaranteed to run to completion before shutdown hooks in the next stage are /// started. Future<void> runShutdownHooks(); } class _DefaultShutdownHooks implements ShutdownHooks { _DefaultShutdownHooks({ @required Logger logger, }) : _logger = logger; final Logger _logger; final Map<ShutdownStage, List<ShutdownHook>> _shutdownHooks = <ShutdownStage, List<ShutdownHook>>{}; bool _shutdownHooksRunning = false; @override void addShutdownHook( ShutdownHook shutdownHook, [ ShutdownStage stage = ShutdownStage.CLEANUP, ]) { assert(!_shutdownHooksRunning); _shutdownHooks.putIfAbsent(stage, () => <ShutdownHook>[]).add(shutdownHook); } @override Future<void> runShutdownHooks() async { _logger.printTrace('Running shutdown hooks'); _shutdownHooksRunning = true; try { for (final ShutdownStage stage in _shutdownHooks.keys.toList()..sort()) { _logger.printTrace('Shutdown hook priority ${stage.priority}'); final List<ShutdownHook> hooks = _shutdownHooks.remove(stage); final List<Future<dynamic>> futures = <Future<dynamic>>[]; for (final ShutdownHook shutdownHook in hooks) { final FutureOr<dynamic> result = shutdownHook(); if (result is Future<dynamic>) { futures.add(result); } } await Future.wait<dynamic>(futures); } } finally { _shutdownHooksRunning = false; } assert(_shutdownHooks.isEmpty); _logger.printTrace('Shutdown hooks complete'); } } class ProcessExit implements Exception { ProcessExit(this.exitCode, {this.immediate = false}); final bool immediate; final int exitCode; String get message => 'ProcessExit: $exitCode'; @override String toString() => message; } class RunResult { RunResult(this.processResult, this._command) : assert(_command != null), assert(_command.isNotEmpty); final ProcessResult processResult; final List<String> _command; int get exitCode => processResult.exitCode; String get stdout => processResult.stdout as String; String get stderr => processResult.stderr as String; @override String toString() { final StringBuffer out = StringBuffer(); if (stdout.isNotEmpty) { out.writeln(stdout); } if (stderr.isNotEmpty) { out.writeln(stderr); } return out.toString().trimRight(); } /// Throws a [ProcessException] with the given `message`. void throwException(String message) { throw ProcessException( _command.first, _command.skip(1).toList(), message, exitCode, ); } } typedef RunResultChecker = bool Function(int); ProcessUtils get processUtils => ProcessUtils.instance; abstract class ProcessUtils { factory ProcessUtils({ @required ProcessManager processManager, @required Logger logger, }) => _DefaultProcessUtils( processManager: processManager, logger: logger, ); static ProcessUtils get instance => context.get<ProcessUtils>(); /// Spawns a child process to run the command [cmd]. /// /// When [throwOnError] is `true`, if the child process finishes with a non-zero /// exit code, a [ProcessException] is thrown. /// /// If [throwOnError] is `true`, and [whiteListFailures] is supplied, /// a [ProcessException] is only thrown on a non-zero exit code if /// [whiteListFailures] returns false when passed the exit code. /// /// When [workingDirectory] is set, it is the working directory of the child /// process. /// /// When [allowReentrantFlutter] is set to `true`, the child process is /// permitted to call the Flutter tool. By default it is not. /// /// When [environment] is supplied, it is used as the environment for the child /// process. /// /// When [timeout] is supplied, [runAsync] will kill the child process and /// throw a [ProcessException] when it doesn't finish in time. /// /// If [timeout] is supplied, the command will be retried [timeoutRetries] times /// if it times out. Future<RunResult> run( List<String> cmd, { bool throwOnError = false, RunResultChecker whiteListFailures, String workingDirectory, bool allowReentrantFlutter = false, Map<String, String> environment, Duration timeout, int timeoutRetries = 0, }); /// Run the command and block waiting for its result. RunResult runSync( List<String> cmd, { bool throwOnError = false, RunResultChecker whiteListFailures, bool hideStdout = false, String workingDirectory, Map<String, String> environment, bool allowReentrantFlutter = false, }); /// This runs the command in the background from the specified working /// directory. Completes when the process has been started. Future<Process> start( List<String> cmd, { String workingDirectory, bool allowReentrantFlutter = false, Map<String, String> environment, }); /// This runs the command and streams stdout/stderr from the child process to /// this process' stdout/stderr. Completes with the process's exit code. /// /// If [filter] is null, no lines are removed. /// /// If [filter] is non-null, all lines that do not match it are removed. If /// [mapFunction] is present, all lines that match [filter] are also forwarded /// to [mapFunction] for further processing. Future<int> stream( List<String> cmd, { String workingDirectory, bool allowReentrantFlutter = false, String prefix = '', bool trace = false, RegExp filter, StringConverter mapFunction, Map<String, String> environment, }); bool exitsHappySync( List<String> cli, { Map<String, String> environment, }); Future<bool> exitsHappy( List<String> cli, { Map<String, String> environment, }); } class _DefaultProcessUtils implements ProcessUtils { _DefaultProcessUtils({ @required ProcessManager processManager, @required Logger logger, }) : _processManager = processManager, _logger = logger; final ProcessManager _processManager; final Logger _logger; @override Future<RunResult> run( List<String> cmd, { bool throwOnError = false, RunResultChecker whiteListFailures, String workingDirectory, bool allowReentrantFlutter = false, Map<String, String> environment, Duration timeout, int timeoutRetries = 0, }) async { if (cmd == null || cmd.isEmpty) { throw ArgumentError('cmd must be a non-empty list'); } if (timeoutRetries < 0) { throw ArgumentError('timeoutRetries must be non-negative'); } _traceCommand(cmd, workingDirectory: workingDirectory); // When there is no timeout, there's no need to kill a running process, so // we can just use _processManager.run(). if (timeout == null) { final ProcessResult results = await _processManager.run( cmd, workingDirectory: workingDirectory, environment: _environment(allowReentrantFlutter, environment), ); final RunResult runResult = RunResult(results, cmd); _logger.printTrace(runResult.toString()); if (throwOnError && runResult.exitCode != 0 && (whiteListFailures == null || !whiteListFailures(runResult.exitCode))) { runResult.throwException('Process exited abnormally:\n$runResult'); } return runResult; } // When there is a timeout, we have to kill the running process, so we have // to use _processManager.start() through _runCommand() above. while (true) { assert(timeoutRetries >= 0); timeoutRetries = timeoutRetries - 1; final Process process = await start( cmd, workingDirectory: workingDirectory, allowReentrantFlutter: allowReentrantFlutter, environment: environment, ); final StringBuffer stdoutBuffer = StringBuffer(); final StringBuffer stderrBuffer = StringBuffer(); final Future<void> stdoutFuture = process.stdout .transform<String>(const Utf8Decoder(reportErrors: false)) .listen(stdoutBuffer.write) .asFuture<void>(null); final Future<void> stderrFuture = process.stderr .transform<String>(const Utf8Decoder(reportErrors: false)) .listen(stderrBuffer.write) .asFuture<void>(null); int exitCode; exitCode = await process.exitCode.timeout(timeout, onTimeout: () { // The process timed out. Kill it. _processManager.killPid(process.pid); return null; }); String stdoutString; String stderrString; try { Future<void> stdioFuture = Future.wait<void>(<Future<void>>[stdoutFuture, stderrFuture]); if (exitCode == null) { // If we had to kill the process for a timeout, only wait a short time // for the stdio streams to drain in case killing the process didn't // work. stdioFuture = stdioFuture.timeout(const Duration(seconds: 1)); } await stdioFuture; } on Exception catch (_) { // Ignore errors on the process' stdout and stderr streams. Just capture // whatever we got, and use the exit code } stdoutString = stdoutBuffer.toString(); stderrString = stderrBuffer.toString(); final ProcessResult result = ProcessResult( process.pid, exitCode ?? -1, stdoutString, stderrString); final RunResult runResult = RunResult(result, cmd); // If the process did not timeout. We are done. if (exitCode != null) { _logger.printTrace(runResult.toString()); if (throwOnError && runResult.exitCode != 0 && (whiteListFailures == null || !whiteListFailures(exitCode))) { runResult.throwException('Process exited abnormally:\n$runResult'); } return runResult; } // If we are out of timeoutRetries, throw a ProcessException. if (timeoutRetries < 0) { runResult.throwException('Process timed out:\n$runResult'); } // Log the timeout with a trace message in verbose mode. _logger.printTrace( 'Process "${cmd[0]}" timed out. $timeoutRetries attempts left:\n' '$runResult', ); } // Unreachable. } @override RunResult runSync( List<String> cmd, { bool throwOnError = false, RunResultChecker whiteListFailures, bool hideStdout = false, String workingDirectory, Map<String, String> environment, bool allowReentrantFlutter = false, }) { _traceCommand(cmd, workingDirectory: workingDirectory); final ProcessResult results = _processManager.runSync( cmd, workingDirectory: workingDirectory, environment: _environment(allowReentrantFlutter, environment), ); final RunResult runResult = RunResult(results, cmd); _logger.printTrace('Exit code ${runResult.exitCode} from: ${cmd.join(' ')}'); bool failedExitCode = runResult.exitCode != 0; if (whiteListFailures != null && failedExitCode) { failedExitCode = !whiteListFailures(runResult.exitCode); } if (runResult.stdout.isNotEmpty && !hideStdout) { if (failedExitCode && throwOnError) { _logger.printStatus(runResult.stdout.trim()); } else { _logger.printTrace(runResult.stdout.trim()); } } if (runResult.stderr.isNotEmpty) { if (failedExitCode && throwOnError) { _logger.printError(runResult.stderr.trim()); } else { _logger.printTrace(runResult.stderr.trim()); } } if (failedExitCode && throwOnError) { runResult.throwException('The command failed'); } return runResult; } @override Future<Process> start( List<String> cmd, { String workingDirectory, bool allowReentrantFlutter = false, Map<String, String> environment, }) { _traceCommand(cmd, workingDirectory: workingDirectory); return _processManager.start( cmd, workingDirectory: workingDirectory, environment: _environment(allowReentrantFlutter, environment), ); } @override Future<int> stream( List<String> cmd, { String workingDirectory, bool allowReentrantFlutter = false, String prefix = '', bool trace = false, RegExp filter, StringConverter mapFunction, Map<String, String> environment, }) async { final Process process = await start( cmd, workingDirectory: workingDirectory, allowReentrantFlutter: allowReentrantFlutter, environment: environment, ); final StreamSubscription<String> stdoutSubscription = process.stdout .transform<String>(utf8.decoder) .transform<String>(const LineSplitter()) .where((String line) => filter == null || filter.hasMatch(line)) .listen((String line) { if (mapFunction != null) { line = mapFunction(line); } if (line != null) { final String message = '$prefix$line'; if (trace) { _logger.printTrace(message); } else { _logger.printStatus(message, wrap: false); } } }); final StreamSubscription<String> stderrSubscription = process.stderr .transform<String>(utf8.decoder) .transform<String>(const LineSplitter()) .where((String line) => filter == null || filter.hasMatch(line)) .listen((String line) { if (mapFunction != null) { line = mapFunction(line); } if (line != null) { _logger.printError('$prefix$line', wrap: false); } }); // Wait for stdout to be fully processed // because process.exitCode may complete first causing flaky tests. await waitGroup<void>(<Future<void>>[ stdoutSubscription.asFuture<void>(), stderrSubscription.asFuture<void>(), ]); // The streams as futures have already completed, so waiting for the // potentially async stream cancellation to complete likely has no benefit. // Further, some Stream implementations commonly used in tests don't // complete the Future returned here, which causes tests using // mocks/FakeAsync to fail when these Futures are awaited. unawaited(stdoutSubscription.cancel()); unawaited(stderrSubscription.cancel()); return await process.exitCode; } @override bool exitsHappySync( List<String> cli, { Map<String, String> environment, }) { _traceCommand(cli); if (!_processManager.canRun(cli.first)) { _logger.printTrace('$cli either does not exist or is not executable.'); return false; } try { return _processManager.runSync(cli, environment: environment).exitCode == 0; } on Exception catch (error) { _logger.printTrace('$cli failed with $error'); return false; } } @override Future<bool> exitsHappy( List<String> cli, { Map<String, String> environment, }) async { _traceCommand(cli); if (!_processManager.canRun(cli.first)) { _logger.printTrace('$cli either does not exist or is not executable.'); return false; } try { return (await _processManager.run(cli, environment: environment)).exitCode == 0; } on Exception catch (error) { _logger.printTrace('$cli failed with $error'); return false; } } Map<String, String> _environment(bool allowReentrantFlutter, [ Map<String, String> environment, ]) { if (allowReentrantFlutter) { if (environment == null) { environment = <String, String>{'FLUTTER_ALREADY_LOCKED': 'true'}; } else { environment['FLUTTER_ALREADY_LOCKED'] = 'true'; } } return environment; } void _traceCommand(List<String> args, { String workingDirectory }) { final String argsText = args.join(' '); if (workingDirectory == null) { _logger.printTrace('executing: $argsText'); } else { _logger.printTrace('executing: [$workingDirectory/] $argsText'); } } }