1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6
7import 'package:meta/meta.dart';
8import 'package:process/process.dart';
9
10import '../convert.dart';
11import '../globals.dart' as globals;
12import 'io.dart';
13import 'logger.dart';
14
15typedef StringConverter = String? Function(String string);
16
17/// A function that will be run before the VM exits.
18typedef ShutdownHook = FutureOr<void> Function();
19
20// TODO(ianh): We have way too many ways to run subprocesses in this project.
21// Convert most of these into one or more lightweight wrappers around the
22// [ProcessManager] API using named parameters for the various options.
23// See [here](https://github.com/flutter/flutter/pull/14535#discussion_r167041161)
24// for more details.
25
26abstract class ShutdownHooks {
27 factory ShutdownHooks() = _DefaultShutdownHooks;
28
29 /// Registers a [ShutdownHook] to be executed before the VM exits.
30 void addShutdownHook(ShutdownHook shutdownHook);
31
32 @visibleForTesting
33 List<ShutdownHook> get registeredHooks;
34
35 /// Runs all registered shutdown hooks and returns a future that completes when
36 /// all such hooks have finished.
37 ///
38 /// Shutdown hooks will be run in groups by their shutdown stage. All shutdown
39 /// hooks within a given stage will be started in parallel and will be
40 /// guaranteed to run to completion before shutdown hooks in the next stage are
41 /// started.
42 ///
43 /// This class is constructed before the [Logger], so it cannot be direct
44 /// injected in the constructor.
45 Future<void> runShutdownHooks(Logger logger);
46}
47
48class _DefaultShutdownHooks implements ShutdownHooks {
49 _DefaultShutdownHooks();
50
51 @override
52 final registeredHooks = <ShutdownHook>[];
53
54 var _shutdownHooksRunning = false;
55
56 @override
57 void addShutdownHook(ShutdownHook shutdownHook) {
58 assert(!_shutdownHooksRunning);
59 registeredHooks.add(shutdownHook);
60 }
61
62 @override
63 Future<void> runShutdownHooks(Logger logger) async {
64 logger.printTrace(
65 'Running ${registeredHooks.length} shutdown hook${registeredHooks.length == 1 ? '' : 's'}',
66 );
67 _shutdownHooksRunning = true;
68 try {
69 final futures = <Future<dynamic>>[
70 for (final ShutdownHook shutdownHook in registeredHooks)
71 if (shutdownHook() case final Future<dynamic> result) result,
72 ];
73 await Future.wait<dynamic>(futures);
74 } finally {
75 _shutdownHooksRunning = false;
76 }
77 logger.printTrace('Shutdown hooks complete');
78 }
79}
80
81class ProcessExit implements Exception {
82 ProcessExit(this.exitCode, {this.immediate = false});
83
84 final bool immediate;
85 final int exitCode;
86
87 String get message => 'ProcessExit: $exitCode';
88
89 @override
90 String toString() => message;
91}
92
93class RunResult {
94 RunResult(this.processResult, this._command) : assert(_command.isNotEmpty);
95
96 final ProcessResult processResult;
97
98 final List<String> _command;
99
100 int get exitCode => processResult.exitCode;
101 String get stdout => processResult.stdout as String;
102 String get stderr => processResult.stderr as String;
103
104 @override
105 String toString() {
106 final out = StringBuffer();
107 if (stdout.isNotEmpty) {
108 out.writeln(stdout);
109 }
110 if (stderr.isNotEmpty) {
111 out.writeln(stderr);
112 }
113 return out.toString().trimRight();
114 }
115
116 /// Throws a [ProcessException] with the given `message`.
117 void throwException(String message) {
118 throw ProcessException(_command.first, _command.skip(1).toList(), message, exitCode);
119 }
120}
121
122typedef RunResultChecker = bool Function(int);
123
124abstract class ProcessUtils {
125 factory ProcessUtils({required ProcessManager processManager, required Logger logger}) =
126 _DefaultProcessUtils;
127
128 /// Spawns a child process to run the command [cmd].
129 ///
130 /// When [throwOnError] is `true`, if the child process finishes with a non-zero
131 /// exit code, a [ProcessException] is thrown.
132 ///
133 /// If [throwOnError] is `true`, and [allowedFailures] is supplied,
134 /// a [ProcessException] is only thrown on a non-zero exit code if
135 /// [allowedFailures] returns false when passed the exit code.
136 ///
137 /// When [workingDirectory] is set, it is the working directory of the child
138 /// process.
139 ///
140 /// When [allowReentrantFlutter] is set to `true`, the child process is
141 /// permitted to call the Flutter tool. By default it is not.
142 ///
143 /// When [environment] is supplied, it is used as the environment for the child
144 /// process.
145 ///
146 /// When [timeout] is supplied, kills the child process and
147 /// throws a [ProcessException] when it doesn't finish in time.
148 ///
149 /// If [timeout] is supplied, the command will be retried [timeoutRetries] times
150 /// if it times out.
151 Future<RunResult> run(
152 List<String> cmd, {
153 bool throwOnError = false,
154 RunResultChecker? allowedFailures,
155 String? workingDirectory,
156 bool allowReentrantFlutter = false,
157 Map<String, String>? environment,
158 Duration? timeout,
159 int timeoutRetries = 0,
160 });
161
162 /// Run the command and block waiting for its result.
163 RunResult runSync(
164 List<String> cmd, {
165 bool throwOnError = false,
166 bool verboseExceptions = false,
167 RunResultChecker? allowedFailures,
168 bool hideStdout = false,
169 String? workingDirectory,
170 Map<String, String>? environment,
171 bool allowReentrantFlutter = false,
172 Encoding encoding = systemEncoding,
173 });
174
175 /// This runs the command in the background from the specified working
176 /// directory. Completes when the process has been started.
177 Future<Process> start(
178 List<String> cmd, {
179 String? workingDirectory,
180 bool allowReentrantFlutter = false,
181 Map<String, String>? environment,
182 ProcessStartMode mode = ProcessStartMode.normal,
183 });
184
185 /// This runs the command and streams stdout/stderr from the child process to
186 /// this process' stdout/stderr. Completes with the process's exit code.
187 ///
188 /// If [filter] is null, no lines are removed.
189 ///
190 /// If [filter] is non-null, all lines that do not match it are removed. If
191 /// [mapFunction] is present, all lines that match [filter] are also forwarded
192 /// to [mapFunction] for further processing.
193 ///
194 /// If [stdoutErrorMatcher] is non-null, matching lines from stdout will be
195 /// treated as errors, just as if they had been logged to stderr instead.
196 Future<int> stream(
197 List<String> cmd, {
198 String? workingDirectory,
199 bool allowReentrantFlutter = false,
200 String prefix = '',
201 bool trace = false,
202 RegExp? filter,
203 RegExp? stdoutErrorMatcher,
204 StringConverter? mapFunction,
205 Map<String, String>? environment,
206 });
207
208 bool exitsHappySync(List<String> cli, {Map<String, String>? environment});
209
210 Future<bool> exitsHappy(List<String> cli, {Map<String, String>? environment});
211
212 /// Write [line] to [stdin] and catch any errors with [onError].
213 ///
214 /// Specifically with [Process] file descriptors, an exception that is
215 /// thrown as part of a write can be most reliably caught with a
216 /// [ZoneSpecification] error handler.
217 ///
218 /// On some platforms, the following code appears to work:
219 ///
220 /// ```dart
221 /// stdin.writeln(line);
222 /// try {
223 /// await stdin.flush(line);
224 /// } catch (err) {
225 /// // handle error
226 /// }
227 /// ```
228 ///
229 /// However it did not catch a [SocketException] on Linux.
230 ///
231 /// As part of making sure errors are caught, this function will call [IOSink.flush]
232 /// on [stdin] to ensure that [line] is written to the pipe before this
233 /// function returns. This means completion will be blocked if the kernel
234 /// buffer of the pipe is full.
235 static Future<void> writelnToStdinGuarded({
236 required IOSink stdin,
237 required String line,
238 required void Function(Object, StackTrace) onError,
239 }) async {
240 await _writeToStdinGuarded(stdin: stdin, content: line, onError: onError, isLine: true);
241 }
242
243 /// Please see [writelnToStdinGuarded].
244 ///
245 /// This calls `stdin.write` instead of `stdin.writeln`.
246 static Future<void> writeToStdinGuarded({
247 required IOSink stdin,
248 required String content,
249 required void Function(Object, StackTrace) onError,
250 }) async {
251 await _writeToStdinGuarded(stdin: stdin, content: content, onError: onError, isLine: false);
252 }
253
254 static Future<void> writelnToStdinUnsafe({required IOSink stdin, required String line}) async {
255 await _writeToStdinUnsafe(stdin: stdin, content: line, isLine: true);
256 }
257
258 static Future<void> writeToStdinUnsafe({required IOSink stdin, required String content}) async {
259 await _writeToStdinUnsafe(stdin: stdin, content: content, isLine: false);
260 }
261
262 static Future<void> _writeToStdinGuarded({
263 required IOSink stdin,
264 required String content,
265 required void Function(Object, StackTrace) onError,
266 required bool isLine,
267 }) async {
268 try {
269 await _writeToStdinUnsafe(stdin: stdin, content: content, isLine: isLine);
270 } on Exception catch (error, stackTrace) {
271 onError(error, stackTrace);
272 }
273 }
274
275 static Future<void> _writeToStdinUnsafe({
276 required IOSink stdin,
277 required String content,
278 required bool isLine,
279 }) {
280 final completer = Completer<void>();
281
282 void handleError(Object error, StackTrace stackTrace) {
283 completer.completeError(error, stackTrace);
284 }
285
286 void writeFlushAndComplete() {
287 if (isLine) {
288 stdin.writeln(content);
289 } else {
290 stdin.write(content);
291 }
292 stdin.flush().then((_) {
293 completer.complete();
294 }, onError: handleError);
295 }
296
297 runZonedGuarded(writeFlushAndComplete, handleError);
298
299 return completer.future;
300 }
301}
302
303class _DefaultProcessUtils implements ProcessUtils {
304 _DefaultProcessUtils({required ProcessManager processManager, required Logger logger})
305 : _processManager = processManager,
306 _logger = logger;
307
308 final ProcessManager _processManager;
309
310 final Logger _logger;
311
312 @override
313 Future<RunResult> run(
314 List<String> cmd, {
315 bool throwOnError = false,
316 RunResultChecker? allowedFailures,
317 String? workingDirectory,
318 bool allowReentrantFlutter = false,
319 Map<String, String>? environment,
320 Duration? timeout,
321 int timeoutRetries = 0,
322 }) async {
323 if (cmd.isEmpty) {
324 throw ArgumentError('cmd must be a non-empty list');
325 }
326 if (timeoutRetries < 0) {
327 throw ArgumentError('timeoutRetries must be non-negative');
328 }
329 _traceCommand(cmd, workingDirectory: workingDirectory);
330
331 // When there is no timeout, there's no need to kill a running process, so
332 // we can just use _processManager.run().
333 if (timeout == null) {
334 final ProcessResult results = await _processManager.run(
335 cmd,
336 workingDirectory: workingDirectory,
337 environment: _environment(allowReentrantFlutter, environment),
338 );
339 final runResult = RunResult(results, cmd);
340 _logger.printTrace(runResult.toString());
341 if (throwOnError &&
342 runResult.exitCode != 0 &&
343 (allowedFailures == null || !allowedFailures(runResult.exitCode))) {
344 runResult.throwException(
345 'Process exited abnormally with exit code ${runResult.exitCode}:\n$runResult',
346 );
347 }
348 return runResult;
349 }
350
351 // When there is a timeout, we have to kill the running process, so we have
352 // to use _processManager.start() through _runCommand() above.
353 while (true) {
354 assert(timeoutRetries >= 0);
355 timeoutRetries = timeoutRetries - 1;
356
357 final Process process = await start(
358 cmd,
359 workingDirectory: workingDirectory,
360 allowReentrantFlutter: allowReentrantFlutter,
361 environment: environment,
362 );
363
364 final stdoutBuffer = StringBuffer();
365 final stderrBuffer = StringBuffer();
366 final Future<void> stdoutFuture = process.stdout
367 .transform<String>(const Utf8Decoder(reportErrors: false))
368 .listen(stdoutBuffer.write)
369 .asFuture<void>();
370 final Future<void> stderrFuture = process.stderr
371 .transform<String>(const Utf8Decoder(reportErrors: false))
372 .listen(stderrBuffer.write)
373 .asFuture<void>();
374
375 int? exitCode;
376 exitCode = await process.exitCode
377 .then<int?>((int x) => x)
378 .timeout(
379 timeout,
380 onTimeout: () {
381 // The process timed out. Kill it.
382 _processManager.killPid(process.pid);
383 return null;
384 },
385 );
386
387 String stdoutString;
388 String stderrString;
389 try {
390 Future<void> stdioFuture = Future.wait<void>(<Future<void>>[stdoutFuture, stderrFuture]);
391 if (exitCode == null) {
392 // If we had to kill the process for a timeout, only wait a short time
393 // for the stdio streams to drain in case killing the process didn't
394 // work.
395 stdioFuture = stdioFuture.timeout(const Duration(seconds: 1));
396 }
397 await stdioFuture;
398 } on Exception {
399 // Ignore errors on the process' stdout and stderr streams. Just capture
400 // whatever we got, and use the exit code
401 }
402 stdoutString = stdoutBuffer.toString();
403 stderrString = stderrBuffer.toString();
404
405 final result = ProcessResult(process.pid, exitCode ?? -1, stdoutString, stderrString);
406 final runResult = RunResult(result, cmd);
407
408 // If the process did not timeout. We are done.
409 if (exitCode != null) {
410 _logger.printTrace(runResult.toString());
411 if (throwOnError &&
412 runResult.exitCode != 0 &&
413 (allowedFailures == null || !allowedFailures(exitCode))) {
414 runResult.throwException(
415 'Process exited abnormally with exit code $exitCode:\n$runResult',
416 );
417 }
418 return runResult;
419 }
420
421 // If we are out of timeoutRetries, throw a ProcessException.
422 if (timeoutRetries < 0) {
423 runResult.throwException('Process timed out:\n$runResult');
424 }
425
426 // Log the timeout with a trace message in verbose mode.
427 _logger.printTrace(
428 'Process "${cmd[0]}" timed out. $timeoutRetries attempts left:\n'
429 '$runResult',
430 );
431 }
432
433 // Unreachable.
434 }
435
436 @override
437 RunResult runSync(
438 List<String> cmd, {
439 bool throwOnError = false,
440 bool verboseExceptions = false,
441 RunResultChecker? allowedFailures,
442 bool hideStdout = false,
443 String? workingDirectory,
444 Map<String, String>? environment,
445 bool allowReentrantFlutter = false,
446 Encoding encoding = systemEncoding,
447 }) {
448 _traceCommand(cmd, workingDirectory: workingDirectory);
449 final ProcessResult results = _processManager.runSync(
450 cmd,
451 workingDirectory: workingDirectory,
452 environment: _environment(allowReentrantFlutter, environment),
453 stderrEncoding: encoding,
454 stdoutEncoding: encoding,
455 );
456 final runResult = RunResult(results, cmd);
457
458 _logger.printTrace('Exit code ${runResult.exitCode} from: ${cmd.join(' ')}');
459
460 var failedExitCode = runResult.exitCode != 0;
461 if (allowedFailures != null && failedExitCode) {
462 failedExitCode = !allowedFailures(runResult.exitCode);
463 }
464
465 if (runResult.stdout.isNotEmpty && !hideStdout) {
466 if (failedExitCode && throwOnError) {
467 _logger.printStatus(runResult.stdout.trim());
468 } else {
469 _logger.printTrace(runResult.stdout.trim());
470 }
471 }
472
473 if (runResult.stderr.isNotEmpty) {
474 if (failedExitCode && throwOnError) {
475 _logger.printError(runResult.stderr.trim());
476 } else {
477 _logger.printTrace(runResult.stderr.trim());
478 }
479 }
480
481 if (failedExitCode && throwOnError) {
482 var message = 'The command failed with exit code ${runResult.exitCode}';
483 if (verboseExceptions) {
484 message =
485 'The command failed\nStdout:\n${runResult.stdout}\n'
486 'Stderr:\n${runResult.stderr}';
487 }
488 runResult.throwException(message);
489 }
490
491 return runResult;
492 }
493
494 @override
495 Future<Process> start(
496 List<String> cmd, {
497 String? workingDirectory,
498 bool allowReentrantFlutter = false,
499 Map<String, String>? environment,
500 ProcessStartMode mode = ProcessStartMode.normal,
501 }) {
502 _traceCommand(cmd, workingDirectory: workingDirectory);
503 return _processManager.start(
504 cmd,
505 workingDirectory: workingDirectory,
506 environment: _environment(allowReentrantFlutter, environment),
507 mode: mode,
508 );
509 }
510
511 @override
512 Future<int> stream(
513 List<String> cmd, {
514 String? workingDirectory,
515 bool allowReentrantFlutter = false,
516 String prefix = '',
517 bool trace = false,
518 RegExp? filter,
519 RegExp? stdoutErrorMatcher,
520 StringConverter? mapFunction,
521 Map<String, String>? environment,
522 }) async {
523 final Process process = await start(
524 cmd,
525 workingDirectory: workingDirectory,
526 allowReentrantFlutter: allowReentrantFlutter,
527 environment: environment,
528 );
529 final StreamSubscription<String> stdoutSubscription = process.stdout
530 .transform<String>(utf8.decoder)
531 .transform<String>(const LineSplitter())
532 .where((String line) => filter == null || filter.hasMatch(line))
533 .listen((String line) {
534 String? mappedLine = line;
535 if (mapFunction != null) {
536 mappedLine = mapFunction(line);
537 }
538 if (mappedLine != null) {
539 final message = '$prefix$mappedLine';
540 if (stdoutErrorMatcher?.hasMatch(mappedLine) ?? false) {
541 _logger.printError(message, wrap: false);
542 } else if (trace) {
543 _logger.printTrace(message);
544 } else {
545 _logger.printStatus(message, wrap: false);
546 }
547 }
548 });
549 final StreamSubscription<String> stderrSubscription = process.stderr
550 .transform<String>(utf8.decoder)
551 .transform<String>(const LineSplitter())
552 .where((String line) => filter == null || filter.hasMatch(line))
553 .listen((String line) {
554 String? mappedLine = line;
555 if (mapFunction != null) {
556 mappedLine = mapFunction(line);
557 }
558 if (mappedLine != null) {
559 _logger.printError('$prefix$mappedLine', wrap: false);
560 }
561 });
562
563 // Wait for stdout to be fully processed
564 // because process.exitCode may complete first causing flaky tests.
565 await Future.wait<void>(<Future<void>>[
566 stdoutSubscription.asFuture<void>(),
567 stderrSubscription.asFuture<void>(),
568 ]);
569
570 // The streams as futures have already completed, so waiting for the
571 // potentially async stream cancellation to complete likely has no benefit.
572 // Further, some Stream implementations commonly used in tests don't
573 // complete the Future returned here, which causes tests using
574 // mocks/FakeAsync to fail when these Futures are awaited.
575 unawaited(stdoutSubscription.cancel());
576 unawaited(stderrSubscription.cancel());
577
578 return process.exitCode;
579 }
580
581 @override
582 bool exitsHappySync(List<String> cli, {Map<String, String>? environment}) {
583 _traceCommand(cli);
584 if (!_processManager.canRun(cli.first)) {
585 _logger.printTrace('$cli either does not exist or is not executable.');
586 return false;
587 }
588
589 try {
590 return _processManager.runSync(cli, environment: environment).exitCode == 0;
591 } on Exception catch (error) {
592 _logger.printTrace('$cli failed with $error');
593 return false;
594 }
595 }
596
597 @override
598 Future<bool> exitsHappy(List<String> cli, {Map<String, String>? environment}) async {
599 _traceCommand(cli);
600 if (!_processManager.canRun(cli.first)) {
601 _logger.printTrace('$cli either does not exist or is not executable.');
602 return false;
603 }
604
605 try {
606 return (await _processManager.run(cli, environment: environment)).exitCode == 0;
607 } on Exception catch (error) {
608 _logger.printTrace('$cli failed with $error');
609 return false;
610 }
611 }
612
613 Map<String, String>? _environment(
614 bool allowReentrantFlutter, [
615 Map<String, String>? environment,
616 ]) {
617 if (allowReentrantFlutter) {
618 if (environment == null) {
619 environment = <String, String>{'FLUTTER_ALREADY_LOCKED': 'true'};
620 } else {
621 environment['FLUTTER_ALREADY_LOCKED'] = 'true';
622 }
623 }
624
625 return environment;
626 }
627
628 void _traceCommand(List<String> args, {String? workingDirectory}) {
629 final String argsText = args.join(' ');
630 if (workingDirectory == null) {
631 _logger.printTrace('executing: $argsText');
632 } else {
633 _logger.printTrace('executing: [$workingDirectory/] $argsText');
634 }
635 }
636}
637
638Future<int> exitWithHooks(int code, {required ShutdownHooks shutdownHooks}) async {
639 if (globals.analytics.shouldShowMessage) {
640 globals.logger.printStatus(globals.analytics.getConsentMessage);
641 globals.analytics.clientShowedMessage();
642
643 // This trace is searched for in tests.
644 globals.logger.printTrace('Showed analytics consent message.');
645 }
646
647 // Run shutdown hooks before flushing logs
648 await shutdownHooks.runShutdownHooks(globals.logger);
649
650 final completer = Completer<void>();
651
652 await globals.analytics.close();
653
654 // Give the task / timer queue one cycle through before we hard exit.
655 Timer.run(() {
656 try {
657 globals.printTrace('exiting with code $code');
658 exit(code);
659 completer.complete();
660 // This catches all exceptions because the error is propagated on the
661 // completer.
662 } catch (error, stackTrace) {
663 completer.completeError(error, stackTrace);
664 }
665 });
666
667 await completer.future;
668 return code;
669}
670