framework.dart 7.87 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
// Copyright 2016 The Chromium 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:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:isolate';

import 'package:logging/logging.dart';
12
import 'package:stack_trace/stack_trace.dart';
13 14 15 16 17 18

import 'utils.dart';

/// Maximum amount of time a single task is allowed to take to run.
///
/// If exceeded the task is considered to have failed.
19
const Duration _kDefaultTaskTimeout = Duration(minutes: 15);
20 21 22

/// Represents a unit of work performed in the CI environment that can
/// succeed, fail and be retried independently of others.
23
typedef TaskFunction = Future<TaskResult> Function();
24 25 26 27 28

bool _isTaskRegistered = false;

/// Registers a [task] to run, returns the result when it is complete.
///
29
/// The task does not run immediately but waits for the request via the
30 31 32 33 34 35
/// VM service protocol to run it.
///
/// It is ok for a [task] to perform many things. However, only one task can be
/// registered per Dart VM.
Future<TaskResult> task(TaskFunction task) {
  if (_isTaskRegistered)
36
    throw StateError('A task is already registered');
37 38 39

  _isTaskRegistered = true;

40
  // TODO(ianh): allow overriding logging.
41 42 43 44 45
  Logger.root.level = Level.ALL;
  Logger.root.onRecord.listen((LogRecord rec) {
    print('${rec.level.name}: ${rec.time}: ${rec.message}');
  });

46
  final _TaskRunner runner = _TaskRunner(task);
47 48 49 50 51 52 53 54
  runner.keepVmAliveUntilTaskRunRequested();
  return runner.whenDone;
}

class _TaskRunner {
  _TaskRunner(this.task) {
    registerExtension('ext.cocoonRunTask',
        (String method, Map<String, String> parameters) async {
55
      final Duration taskTimeout = parameters.containsKey('timeoutInMinutes')
56
        ? Duration(minutes: int.parse(parameters['timeoutInMinutes']))
57 58
        : _kDefaultTaskTimeout;
      final TaskResult result = await run(taskTimeout);
59
      return ServiceExtensionResponse.result(json.encode(result.toJson()));
60 61 62
    });
    registerExtension('ext.cocoonRunnerReady',
        (String method, Map<String, String> parameters) async {
63
      return ServiceExtensionResponse.result('"ready"');
64 65 66
    });
  }

67 68 69 70 71 72 73 74 75 76 77
  final TaskFunction task;

  // TODO(ianh): workaround for https://github.com/dart-lang/sdk/issues/23797
  RawReceivePort _keepAlivePort;
  Timer _startTaskTimeout;
  bool _taskStarted = false;

  final Completer<TaskResult> _completer = Completer<TaskResult>();

  static final Logger logger = Logger('TaskRunner');

78 79 80
  /// Signals that this task runner finished running the task.
  Future<TaskResult> get whenDone => _completer.future;

81
  Future<TaskResult> run(Duration taskTimeout) async {
82 83
    try {
      _taskStarted = true;
84
      print('Running task.');
85
      final TaskResult result = await _performTask().timeout(taskTimeout);
86 87 88
      _completer.complete(result);
      return result;
    } on TimeoutException catch (_) {
89
      print('Task timed out in framework.dart after $taskTimeout.');
90
      return TaskResult.failure('Task timed out after $taskTimeout');
91
    } finally {
92
      print('Cleaning up after task...');
93
      await forceQuitRunningProcesses();
94 95 96 97 98 99 100 101
      _closeKeepAlivePort();
    }
  }

  /// Causes the Dart VM to stay alive until a request to run the task is
  /// received via the VM service protocol.
  void keepVmAliveUntilTaskRunRequested() {
    if (_taskStarted)
102
      throw StateError('Task already started.');
103 104 105

    // Merely creating this port object will cause the VM to stay alive and keep
    // the VM service server running until the port is disposed of.
106
    _keepAlivePort = RawReceivePort();
107 108

    // Timeout if nothing bothers to connect and ask us to run the task.
109
    const Duration taskStartTimeout = Duration(seconds: 60);
110
    _startTaskTimeout = Timer(taskStartTimeout, () {
111 112 113 114 115 116 117 118 119 120 121 122 123 124
      if (!_taskStarted) {
        logger.severe('Task did not start in $taskStartTimeout.');
        _closeKeepAlivePort();
        exitCode = 1;
      }
    });
  }

  /// Disables the keep-alive port, allowing the VM to exit.
  void _closeKeepAlivePort() {
    _startTaskTimeout?.cancel();
    _keepAlivePort?.close();
  }

125
  Future<TaskResult> _performTask() {
126
    final Completer<TaskResult> completer = Completer<TaskResult>();
127 128 129
    Chain.capture(() async {
      completer.complete(await task());
    }, onError: (dynamic taskError, Chain taskErrorStack) {
130
      final String message = 'Task failed: $taskError';
131 132 133 134 135 136 137 138 139
      stderr
        ..writeln(message)
        ..writeln('\nStack trace:')
        ..writeln(taskErrorStack.terse);
      // IMPORTANT: We're completing the future _successfully_ but with a value
      // that indicates a task failure. This is intentional. At this point we
      // are catching errors coming from arbitrary (and untrustworthy) task
      // code. Our goal is to convert the failure into a readable message.
      // Propagating it further is not useful.
140
      if (!completer.isCompleted)
141
        completer.complete(TaskResult.failure(message));
142 143
    });
    return completer.future;
144 145 146 147 148 149
  }
}

/// A result of running a single task.
class TaskResult {
  /// Constructs a successful result.
150
  TaskResult.success(this.data, {this.benchmarkScoreKeys = const <String>[]})
151 152
      : succeeded = true,
        message = 'success' {
153
    const JsonEncoder prettyJson = JsonEncoder.withIndent('  ');
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
    if (benchmarkScoreKeys != null) {
      for (String key in benchmarkScoreKeys) {
        if (!data.containsKey(key)) {
          throw 'Invalid Golem score key "$key". It does not exist in task '
              'result data ${prettyJson.convert(data)}';
        } else if (data[key] is! num) {
          throw 'Invalid Golem score for key "$key". It is expected to be a num '
              'but was ${data[key].runtimeType}: ${prettyJson.convert(data[key])}';
        }
      }
    }
  }

  /// Constructs a successful result using JSON data stored in a file.
  factory TaskResult.successFromFile(File file,
      {List<String> benchmarkScoreKeys}) {
170
    return TaskResult.success(json.decode(file.readAsStringSync()),
171 172 173 174 175
        benchmarkScoreKeys: benchmarkScoreKeys);
  }

  /// Constructs an unsuccessful result.
  TaskResult.failure(this.message)
176 177 178
      : succeeded = false,
        data = null,
        benchmarkScoreKeys = const <String>[];
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219

  /// Whether the task succeeded.
  final bool succeeded;

  /// Task-specific JSON data
  final Map<String, dynamic> data;

  /// Keys in [data] that store scores that will be submitted to Golem.
  ///
  /// Each key is also part of a benchmark's name tracked by Golem.
  /// A benchmark name is computed by combining [Task.name] with a key
  /// separated by a dot. For example, if a task's name is
  /// `"complex_layout__start_up"` and score key is
  /// `"engineEnterTimestampMicros"`, the score will be submitted to Golem under
  /// `"complex_layout__start_up.engineEnterTimestampMicros"`.
  ///
  /// This convention reduces the amount of configuration that needs to be done
  /// to submit benchmark scores to Golem.
  final List<String> benchmarkScoreKeys;

  /// Whether the task failed.
  bool get failed => !succeeded;

  /// Explains the result in a human-readable format.
  final String message;

  /// Serializes this task result to JSON format.
  ///
  /// The JSON format is as follows:
  ///
  ///     {
  ///       "success": true|false,
  ///       "data": arbitrary JSON data valid only for successful results,
  ///       "benchmarkScoreKeys": [
  ///         contains keys into "data" that represent benchmarks scores, which
  ///         can be uploaded, for example. to golem, valid only for successful
  ///         results
  ///       ],
  ///       "reason": failure reason string valid only for unsuccessful results
  ///     }
  Map<String, dynamic> toJson() {
220
    final Map<String, dynamic> json = <String, dynamic>{
221 222 223 224 225 226 227 228 229 230 231 232 233
      'success': succeeded,
    };

    if (succeeded) {
      json['data'] = data;
      json['benchmarkScoreKeys'] = benchmarkScoreKeys;
    } else {
      json['reason'] = message;
    }

    return json;
  }
}