process.dart 20.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
import 'package:meta/meta.dart';
8
import 'package:process/process.dart';
9

10
import '../convert.dart';
11 12
import '../globals.dart' as globals;
import '../reporting/first_run.dart';
13
import 'io.dart';
14
import 'logger.dart';
15

16
typedef StringConverter = String? Function(String string);
17 18

/// A function that will be run before the VM exits.
19
typedef ShutdownHook = FutureOr<void> Function();
Devon Carew's avatar
Devon Carew committed
20

21
// TODO(ianh): We have way too many ways to run subprocesses in this project.
22 23 24 25
// 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.
26

27
abstract class ShutdownHooks {
28
  factory ShutdownHooks() => _DefaultShutdownHooks();
29 30 31

  /// Registers a [ShutdownHook] to be executed before the VM exits.
  void addShutdownHook(
32 33
    ShutdownHook shutdownHook
  );
34

35 36 37
  @visibleForTesting
  List<ShutdownHook> get registeredHooks;

38 39 40 41 42 43 44
  /// 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.
45 46 47 48
  ///
  /// This class is constructed before the [Logger], so it cannot be direct
  /// injected in the constructor.
  Future<void> runShutdownHooks(Logger logger);
49 50
}

51
class _DefaultShutdownHooks implements ShutdownHooks {
52
  _DefaultShutdownHooks();
53

54 55
  @override
  final List<ShutdownHook> registeredHooks = <ShutdownHook>[];
56 57 58 59 60

  bool _shutdownHooksRunning = false;

  @override
  void addShutdownHook(
61 62
    ShutdownHook shutdownHook
  ) {
63
    assert(!_shutdownHooksRunning);
64
    registeredHooks.add(shutdownHook);
65 66 67
  }

  @override
68 69 70 71
  Future<void> runShutdownHooks(Logger logger) async {
    logger.printTrace(
      'Running ${registeredHooks.length} shutdown hook${registeredHooks.length == 1 ? '' : 's'}',
    );
72 73
    _shutdownHooksRunning = true;
    try {
74
      final List<Future<dynamic>> futures = <Future<dynamic>>[];
75
      for (final ShutdownHook shutdownHook in registeredHooks) {
76 77 78
        final FutureOr<dynamic> result = shutdownHook();
        if (result is Future<dynamic>) {
          futures.add(result);
79 80
        }
      }
81
      await Future.wait<dynamic>(futures);
82 83
    } finally {
      _shutdownHooksRunning = false;
84
    }
85
    logger.printTrace('Shutdown hooks complete');
86
  }
87 88
}

89
class ProcessExit implements Exception {
90
  ProcessExit(this.exitCode, {this.immediate = false});
91

92
  final bool immediate;
93 94
  final int exitCode;

Hixie's avatar
Hixie committed
95
  String get message => 'ProcessExit: $exitCode';
96 97

  @override
98 99
  String toString() => message;
}
100 101

class RunResult {
102
  RunResult(this.processResult, this._command)
103
    : assert(_command.isNotEmpty);
104 105 106

  final ProcessResult processResult;

107 108
  final List<String> _command;

109
  int get exitCode => processResult.exitCode;
110 111
  String get stdout => processResult.stdout as String;
  String get stderr => processResult.stderr as String;
112 113 114

  @override
  String toString() {
115
    final StringBuffer out = StringBuffer();
116 117
    if (stdout.isNotEmpty) {
      out.writeln(stdout);
118
    }
119 120
    if (stderr.isNotEmpty) {
      out.writeln(stderr);
121
    }
122 123
    return out.toString().trimRight();
  }
124

125 126
  /// Throws a [ProcessException] with the given `message`.
  void throwException(String message) {
127
    throw ProcessException(
128 129 130 131 132 133
      _command.first,
      _command.skip(1).toList(),
      message,
      exitCode,
    );
  }
134
}
135 136 137 138

typedef RunResultChecker = bool Function(int);

abstract class ProcessUtils {
139
  factory ProcessUtils({
140 141
    required ProcessManager processManager,
    required Logger logger,
142 143 144 145
  }) => _DefaultProcessUtils(
    processManager: processManager,
    logger: logger,
  );
146 147 148 149 150 151

  /// 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.
  ///
152
  /// If [throwOnError] is `true`, and [allowedFailures] is supplied,
153
  /// a [ProcessException] is only thrown on a non-zero exit code if
154
  /// [allowedFailures] returns false when passed the exit code.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
  ///
  /// 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,
173 174
    RunResultChecker? allowedFailures,
    String? workingDirectory,
175
    bool allowReentrantFlutter = false,
176 177
    Map<String, String>? environment,
    Duration? timeout,
178 179 180 181 182 183 184
    int timeoutRetries = 0,
  });

  /// Run the command and block waiting for its result.
  RunResult runSync(
    List<String> cmd, {
    bool throwOnError = false,
185
    bool verboseExceptions = false,
186
    RunResultChecker? allowedFailures,
187
    bool hideStdout = false,
188 189
    String? workingDirectory,
    Map<String, String>? environment,
190
    bool allowReentrantFlutter = false,
191
    Encoding encoding = systemEncoding,
192 193 194 195 196 197
  });

  /// 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, {
198
    String? workingDirectory,
199
    bool allowReentrantFlutter = false,
200
    Map<String, String>? environment,
201
    ProcessStartMode mode = ProcessStartMode.normal,
202 203 204 205 206 207 208 209 210 211
  });

  /// 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.
212 213 214
  ///
  /// If [stdoutErrorMatcher] is non-null, matching lines from stdout will be
  /// treated as errors, just as if they had been logged to stderr instead.
215 216
  Future<int> stream(
    List<String> cmd, {
217
    String? workingDirectory,
218 219 220
    bool allowReentrantFlutter = false,
    String prefix = '',
    bool trace = false,
221 222 223 224
    RegExp? filter,
    RegExp? stdoutErrorMatcher,
    StringConverter? mapFunction,
    Map<String, String>? environment,
225 226 227 228
  });

  bool exitsHappySync(
    List<String> cli, {
229
    Map<String, String>? environment,
230 231 232 233
  });

  Future<bool> exitsHappy(
    List<String> cli, {
234
    Map<String, String>? environment,
235 236 237 238
  });
}

class _DefaultProcessUtils implements ProcessUtils {
239
  _DefaultProcessUtils({
240 241
    required ProcessManager processManager,
    required Logger logger,
242 243 244 245 246 247 248
  }) : _processManager = processManager,
      _logger = logger;

  final ProcessManager _processManager;

  final Logger _logger;

249 250 251 252
  @override
  Future<RunResult> run(
    List<String> cmd, {
    bool throwOnError = false,
253 254
    RunResultChecker? allowedFailures,
    String? workingDirectory,
255
    bool allowReentrantFlutter = false,
256 257
    Map<String, String>? environment,
    Duration? timeout,
258 259
    int timeoutRetries = 0,
  }) async {
260
    if (cmd.isEmpty) {
261 262 263 264 265 266 267 268
      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
269
    // we can just use _processManager.run().
270
    if (timeout == null) {
271
      final ProcessResult results = await _processManager.run(
272 273 274 275 276
        cmd,
        workingDirectory: workingDirectory,
        environment: _environment(allowReentrantFlutter, environment),
      );
      final RunResult runResult = RunResult(results, cmd);
277
      _logger.printTrace(runResult.toString());
278
      if (throwOnError && runResult.exitCode != 0 &&
279
          (allowedFailures == null || !allowedFailures(runResult.exitCode))) {
280 281 282 283 284 285
        runResult.throwException('Process exited abnormally:\n$runResult');
      }
      return runResult;
    }

    // When there is a timeout, we have to kill the running process, so we have
286
    // to use _processManager.start() through _runCommand() above.
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    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)
303
          .asFuture<void>();
304 305 306
      final Future<void> stderrFuture = process.stderr
          .transform<String>(const Utf8Decoder(reportErrors: false))
          .listen(stderrBuffer.write)
307
          .asFuture<void>();
308

309 310
      int? exitCode;
      exitCode = await process.exitCode.then<int?>((int x) => x).timeout(timeout, onTimeout: () {
311
        // The process timed out. Kill it.
312
        _processManager.killPid(process.pid);
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327
        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;
328
      } on Exception {
329 330 331 332 333 334 335 336 337 338 339 340
        // 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) {
341
        _logger.printTrace(runResult.toString());
342
        if (throwOnError && runResult.exitCode != 0 &&
343
            (allowedFailures == null || !allowedFailures(exitCode))) {
344 345 346 347 348 349 350 351 352 353 354
          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.
355 356 357 358
      _logger.printTrace(
        'Process "${cmd[0]}" timed out. $timeoutRetries attempts left:\n'
        '$runResult',
      );
359 360 361 362 363 364 365 366 367
    }

    // Unreachable.
  }

  @override
  RunResult runSync(
    List<String> cmd, {
    bool throwOnError = false,
368
    bool verboseExceptions = false,
369
    RunResultChecker? allowedFailures,
370
    bool hideStdout = false,
371 372
    String? workingDirectory,
    Map<String, String>? environment,
373
    bool allowReentrantFlutter = false,
374
    Encoding encoding = systemEncoding,
375 376
  }) {
    _traceCommand(cmd, workingDirectory: workingDirectory);
377
    final ProcessResult results = _processManager.runSync(
378 379 380
      cmd,
      workingDirectory: workingDirectory,
      environment: _environment(allowReentrantFlutter, environment),
381 382
      stderrEncoding: encoding,
      stdoutEncoding: encoding,
383 384 385
    );
    final RunResult runResult = RunResult(results, cmd);

386
    _logger.printTrace('Exit code ${runResult.exitCode} from: ${cmd.join(' ')}');
387 388

    bool failedExitCode = runResult.exitCode != 0;
389 390
    if (allowedFailures != null && failedExitCode) {
      failedExitCode = !allowedFailures(runResult.exitCode);
391 392 393 394
    }

    if (runResult.stdout.isNotEmpty && !hideStdout) {
      if (failedExitCode && throwOnError) {
395
        _logger.printStatus(runResult.stdout.trim());
396
      } else {
397
        _logger.printTrace(runResult.stdout.trim());
398 399 400 401 402
      }
    }

    if (runResult.stderr.isNotEmpty) {
      if (failedExitCode && throwOnError) {
403
        _logger.printError(runResult.stderr.trim());
404
      } else {
405
        _logger.printTrace(runResult.stderr.trim());
406 407 408 409
      }
    }

    if (failedExitCode && throwOnError) {
410 411 412 413 414 415
      String message = 'The command failed';
      if (verboseExceptions) {
        message = 'The command failed\nStdout:\n${runResult.stdout}\n'
            'Stderr:\n${runResult.stderr}';
      }
      runResult.throwException(message);
416 417 418 419 420 421 422 423
    }

    return runResult;
  }

  @override
  Future<Process> start(
    List<String> cmd, {
424
    String? workingDirectory,
425
    bool allowReentrantFlutter = false,
426
    Map<String, String>? environment,
427
    ProcessStartMode mode = ProcessStartMode.normal,
428 429
  }) {
    _traceCommand(cmd, workingDirectory: workingDirectory);
430
    return _processManager.start(
431 432 433
      cmd,
      workingDirectory: workingDirectory,
      environment: _environment(allowReentrantFlutter, environment),
434
      mode: mode,
435 436 437 438 439 440
    );
  }

  @override
  Future<int> stream(
    List<String> cmd, {
441
    String? workingDirectory,
442 443 444
    bool allowReentrantFlutter = false,
    String prefix = '',
    bool trace = false,
445 446 447 448
    RegExp? filter,
    RegExp? stdoutErrorMatcher,
    StringConverter? mapFunction,
    Map<String, String>? environment,
449 450 451 452 453 454 455 456 457 458 459 460
  }) 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) {
461
        String? mappedLine = line;
462
        if (mapFunction != null) {
463
          mappedLine = mapFunction(line);
464
        }
465 466
        if (mappedLine != null) {
          final String message = '$prefix$mappedLine';
467
          if (stdoutErrorMatcher?.hasMatch(mappedLine) ?? false) {
468 469
            _logger.printError(message, wrap: false);
          } else if (trace) {
470
            _logger.printTrace(message);
471
          } else {
472
            _logger.printStatus(message, wrap: false);
473
          }
474 475 476 477 478 479 480
        }
      });
    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) {
481
        String? mappedLine = line;
482
        if (mapFunction != null) {
483
          mappedLine = mapFunction(line);
484
        }
485 486
        if (mappedLine != null) {
          _logger.printError('$prefix$mappedLine', wrap: false);
487
        }
488 489 490 491
      });

    // Wait for stdout to be fully processed
    // because process.exitCode may complete first causing flaky tests.
492
    await Future.wait<void>(<Future<void>>[
493 494 495 496
      stdoutSubscription.asFuture<void>(),
      stderrSubscription.asFuture<void>(),
    ]);

497 498 499 500 501 502 503
    // 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());
504

505
    return process.exitCode;
506 507 508 509 510
  }

  @override
  bool exitsHappySync(
    List<String> cli, {
511
    Map<String, String>? environment,
512 513
  }) {
    _traceCommand(cli);
514 515 516 517 518
    if (!_processManager.canRun(cli.first)) {
      _logger.printTrace('$cli either does not exist or is not executable.');
      return false;
    }

519
    try {
520
      return _processManager.runSync(cli, environment: environment).exitCode == 0;
521
    } on Exception catch (error) {
522
      _logger.printTrace('$cli failed with $error');
523 524 525 526 527 528 529
      return false;
    }
  }

  @override
  Future<bool> exitsHappy(
    List<String> cli, {
530
    Map<String, String>? environment,
531 532
  }) async {
    _traceCommand(cli);
533 534 535 536 537
    if (!_processManager.canRun(cli.first)) {
      _logger.printTrace('$cli either does not exist or is not executable.');
      return false;
    }

538
    try {
539
      return (await _processManager.run(cli, environment: environment)).exitCode == 0;
540
    } on Exception catch (error) {
541
      _logger.printTrace('$cli failed with $error');
542 543 544 545
      return false;
    }
  }

546 547
  Map<String, String>? _environment(bool allowReentrantFlutter, [
    Map<String, String>? environment,
548 549
  ]) {
    if (allowReentrantFlutter) {
550
      if (environment == null) {
551
        environment = <String, String>{'FLUTTER_ALREADY_LOCKED': 'true'};
552
      } else {
553
        environment['FLUTTER_ALREADY_LOCKED'] = 'true';
554
      }
555 556 557 558 559
    }

    return environment;
  }

560
  void _traceCommand(List<String> args, { String? workingDirectory }) {
561 562
    final String argsText = args.join(' ');
    if (workingDirectory == null) {
563
      _logger.printTrace('executing: $argsText');
564
    } else {
565
      _logger.printTrace('executing: [$workingDirectory/] $argsText');
566 567 568
    }
  }
}
569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641

Future<int> exitWithHooks(int code, {required ShutdownHooks shutdownHooks}) async {
  // Need to get the boolean returned from `messenger.shouldDisplayLicenseTerms()`
  // before invoking the print welcome method because the print welcome method
  // will set `messenger.shouldDisplayLicenseTerms()` to false
  final FirstRunMessenger messenger =
      FirstRunMessenger(persistentToolState: globals.persistentToolState!);
  final bool legacyAnalyticsMessageShown =
      messenger.shouldDisplayLicenseTerms();

  // Prints the welcome message if needed for legacy analytics.
  globals.flutterUsage.printWelcome();

  // Ensure that the consent message has been displayed for unified analytics
  if (globals.analytics.shouldShowMessage) {
    globals.logger.printStatus(globals.analytics.getConsentMessage);
    if (!globals.flutterUsage.enabled) {
      globals.printStatus(
          'Please note that analytics reporting was already disabled, '
          'and will continue to be disabled.\n');
    }

    // Because the legacy analytics may have also sent a message,
    // the conditional below will print additional messaging informing
    // users that the two consent messages they are receiving is not a
    // bug
    if (legacyAnalyticsMessageShown) {
      globals.logger
          .printStatus('You have received two consent messages because '
              'the flutter tool is migrating to a new analytics system. '
              'Disabling analytics collection will disable both the legacy '
              'and new analytics collection systems. '
              'You can disable analytics reporting by running `flutter --disable-analytics`\n');
    }

    // Invoking this will onboard the flutter tool onto
    // the package on the developer's machine and will
    // allow for events to be sent to Google Analytics
    // on subsequent runs of the flutter tool (ie. no events
    // will be sent on the first run to allow developers to
    // opt out of collection)
    globals.analytics.clientShowedMessage();
  }

  // Send any last analytics calls that are in progress without overly delaying
  // the tool's exit (we wait a maximum of 250ms).
  if (globals.flutterUsage.enabled) {
    final Stopwatch stopwatch = Stopwatch()..start();
    await globals.flutterUsage.ensureAnalyticsSent();
    globals.printTrace('ensureAnalyticsSent: ${stopwatch.elapsedMilliseconds}ms');
  }

  // Run shutdown hooks before flushing logs
  await shutdownHooks.runShutdownHooks(globals.logger);

  final Completer<void> completer = Completer<void>();

  // Give the task / timer queue one cycle through before we hard exit.
  Timer.run(() {
    try {
      globals.printTrace('exiting with code $code');
      exit(code);
      completer.complete();
    // This catches all exceptions because the error is propagated on the
    // completer.
    } catch (error, stackTrace) { // ignore: avoid_catches_without_on_clauses
      completer.completeError(error, stackTrace);
    }
  });

  await completer.future;
  return code;
}