| // Copyright 2013 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' as io; | |
| import 'package:path/path.dart' as path; | |
| import 'package:watcher/watcher.dart'; | |
| import 'exceptions.dart'; | |
| import 'utils.dart'; | |
| /// Describes what [Pipeline] is currently doing. | |
| enum PipelineStatus { | |
| /// The pipeline has not started yet. | |
| /// | |
| /// This is the initial state of the pipeline. | |
| idle, | |
| /// The pipeline is running build steps. | |
| started, | |
| /// The pipeline is stopping. | |
| stopping, | |
| /// The pipeline is not running anything because it has been interrupted. | |
| interrupted, | |
| /// The pipeline is not running anything because it encountered an error. | |
| error, | |
| /// The pipeline is not running anything because it finished all build steps successfully. | |
| done, | |
| } | |
| /// A step in the build pipeline. | |
| abstract class PipelineStep { | |
| /// The name of this step. | |
| /// | |
| /// This value appears in logs, so it should be descriptive and human-readable. | |
| String get description; | |
| /// Whether it is safe to interrupt this step while it's running. | |
| bool get isSafeToInterrupt; | |
| /// Runs this step. | |
| /// | |
| /// The returned future is completed when the step is finished. The future | |
| /// completes with an error if the step failed. | |
| Future<void> run(); | |
| /// Interrupts this step, if it's already running. | |
| /// | |
| /// [Pipeline] only calls this if [isSafeToInterrupt] returns true. | |
| Future<void> interrupt(); | |
| } | |
| /// A helper class for implementing [PipelineStep] in terms of a process. | |
| abstract class ProcessStep implements PipelineStep { | |
| ProcessManager? _process; | |
| bool _isInterrupted = false; | |
| /// Starts and returns the process that implements the logic of this pipeline | |
| /// step. | |
| Future<ProcessManager> createProcess(); | |
| @override | |
| Future<void> interrupt() async { | |
| _isInterrupted = true; | |
| _process?.kill(); | |
| } | |
| @override | |
| Future<void> run() async { | |
| final ProcessManager process = await createProcess(); | |
| if (_isInterrupted) { | |
| // If the step was interrupted while creating the process, the | |
| // `interrupt` won't kill the process; it must be done here. | |
| process.kill(); | |
| return; | |
| } | |
| _process = process; | |
| await process.wait(); | |
| _process = null; | |
| } | |
| } | |
| class _PipelineStepFailure { | |
| _PipelineStepFailure(this.step, this.error); | |
| final PipelineStep step; | |
| final Object error; | |
| } | |
| /// Executes a sequence of asynchronous tasks, typically as part of a build/test | |
| /// process. | |
| /// | |
| /// The pipeline can be executed by calling [start] and stopped by calling | |
| /// [stop]. | |
| /// | |
| /// When a pipeline is stopped, it switches to the [PipelineStatus.stopping] | |
| /// state. If [PipelineStep.isSafeToInterrupt] is true, interrupts the currently | |
| /// running step and skips the rest. Otherwise, waits until the current task | |
| /// finishes and skips the rest. | |
| class Pipeline { | |
| Pipeline({required this.steps}); | |
| final Iterable<PipelineStep> steps; | |
| PipelineStep? _currentStep; | |
| Future<void>? _currentStepFuture; | |
| PipelineStatus get status => _status; | |
| PipelineStatus _status = PipelineStatus.idle; | |
| /// Runs the steps of the pipeline. | |
| /// | |
| /// Returns a future that resolves after all steps have been performed. | |
| /// | |
| /// If any steps fail, the pipeline attempts to continue to subsequent steps, | |
| /// but will fail at the end. | |
| /// | |
| /// The pipeline may be interrupted by calling [stop] before the future | |
| /// resolves. | |
| Future<void> run() async { | |
| _status = PipelineStatus.started; | |
| final List<_PipelineStepFailure> failures = <_PipelineStepFailure>[]; | |
| for (final PipelineStep step in steps) { | |
| _currentStep = step; | |
| _currentStepFuture = step.run(); | |
| try { | |
| await _currentStepFuture; | |
| } catch (e) { | |
| failures.add(_PipelineStepFailure(step, e)); | |
| } finally { | |
| _currentStep = null; | |
| } | |
| } | |
| if (failures.isEmpty) { | |
| _status = PipelineStatus.done; | |
| } else { | |
| _status = PipelineStatus.error; | |
| print('Pipeline experienced the following failures:'); | |
| for (final _PipelineStepFailure failure in failures) { | |
| print(' "${failure.step.description}": ${failure.error}'); | |
| } | |
| throw ToolExit('Test pipeline failed.'); | |
| } | |
| } | |
| /// Stops executing any more tasks in the pipeline. | |
| /// | |
| /// Tasks that are safe to interrupt (according to [PipelineStep.isSafeToInterrupt]), | |
| /// are interrupted. Otherwise, waits for the current step to finish before | |
| /// interrupting the pipeline. | |
| Future<void> stop() async { | |
| _status = PipelineStatus.stopping; | |
| final PipelineStep? step = _currentStep; | |
| if (step == null) { | |
| _status = PipelineStatus.interrupted; | |
| return; | |
| } | |
| if (step.isSafeToInterrupt) { | |
| print('Interrupting ${step.description}'); | |
| await step.interrupt(); | |
| _status = PipelineStatus.interrupted; | |
| return; | |
| } | |
| print('${step.description} cannot be interrupted. Waiting for it to complete.'); | |
| await _currentStepFuture; | |
| _status = PipelineStatus.interrupted; | |
| } | |
| } | |
| /// Signature of functions to be called when a [WatchEvent] is received. | |
| typedef WatchEventPredicate = bool Function(WatchEvent event); | |
| /// Responsible for watching a directory [dir] and executing the given | |
| /// [pipeline] whenever a change occurs in the directory. | |
| /// | |
| /// The [ignore] callback can be used to customize the watching behavior to | |
| /// ignore certain files. | |
| class PipelineWatcher { | |
| PipelineWatcher({ | |
| required this.dir, | |
| required this.pipeline, | |
| this.ignore, | |
| }) : watcher = DirectoryWatcher(dir); | |
| /// The path of the directory to watch for changes. | |
| final String dir; | |
| /// The pipeline to be executed when an event is fired by the watcher. | |
| final Pipeline pipeline; | |
| /// Used to watch a directory for any file system changes. | |
| final DirectoryWatcher watcher; | |
| /// A callback that determines whether to rerun the pipeline or not for a | |
| /// given [WatchEvent] instance. | |
| final WatchEventPredicate? ignore; | |
| /// Activates the watcher. | |
| Future<void> start() async { | |
| watcher.events.listen(_onEvent); | |
| // Listen to the `q` key stroke to stop the pipeline. | |
| print("Press 'q' to exit felt"); | |
| // Key strokes should be reported immediately and one at a time rather than | |
| // wait for the user to hit ENTER and report the whole line. To achieve | |
| // that, echo mode and line mode must be disabled. | |
| io.stdin.echoMode = false; | |
| io.stdin.lineMode = false; | |
| // Reset these settings when the felt command is done. | |
| cleanupCallbacks.add(() async { | |
| io.stdin.echoMode = true; | |
| io.stdin.lineMode = true; | |
| }); | |
| await io.stdin.firstWhere((List<int> event) { | |
| const int qKeyCode = 113; | |
| final bool qEntered = event.isNotEmpty && event.first == qKeyCode; | |
| return qEntered; | |
| }); | |
| print('Stopping felt'); | |
| await pipeline.stop(); | |
| } | |
| int _pipelineRunCount = 0; | |
| Timer? _scheduledPipeline; | |
| void _onEvent(WatchEvent event) { | |
| if (ignore?.call(event) ?? false) { | |
| return; | |
| } | |
| final String relativePath = path.relative(event.path, from: dir); | |
| print('- [${event.type}] $relativePath'); | |
| _pipelineRunCount++; | |
| _scheduledPipeline?.cancel(); | |
| _scheduledPipeline = Timer(const Duration(milliseconds: 100), () { | |
| _scheduledPipeline = null; | |
| _runPipeline(); | |
| }); | |
| } | |
| Future<void> _runPipeline() async { | |
| if (pipeline.status == PipelineStatus.stopping) { | |
| // We are already trying to stop the pipeline. No need to do anything. | |
| return; | |
| } | |
| if (pipeline.status == PipelineStatus.started) { | |
| // If the pipeline already running, stop it before starting it again. | |
| await pipeline.stop(); | |
| } | |
| final int runCount = _pipelineRunCount; | |
| try { | |
| await pipeline.run(); | |
| _pipelineSucceeded(runCount); | |
| } catch(error, stackTrace) { | |
| // The error is printed but not rethrown. This is because in watch mode | |
| // failures are expected. The idea is that the developer corrects the | |
| // error, saves the file, and the pipeline reruns. | |
| _pipelineFailed(error, stackTrace); | |
| } | |
| } | |
| void _pipelineSucceeded(int pipelineRunCount) { | |
| if (pipelineRunCount == _pipelineRunCount) { | |
| print('*** Done! ***'); | |
| print("Press 'q' to exit felt"); | |
| } | |
| } | |
| void _pipelineFailed(Object error, StackTrace stackTrace) { | |
| print('felt command failed: $error'); | |
| print(stackTrace); | |
| } | |
| } |