diff --git a/packages/flutter_tools/lib/src/base/process_manager.dart b/packages/flutter_tools/lib/src/base/process_manager.dart index 0cfc1f5780e..9a819c3710d 100644 --- a/packages/flutter_tools/lib/src/base/process_manager.dart +++ b/packages/flutter_tools/lib/src/base/process_manager.dart @@ -6,10 +6,19 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:archive/archive.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as path; + import 'context.dart'; +import 'process.dart'; ProcessManager get processManager => context[ProcessManager]; +/// A class that manages the creation of operating system processes. This +/// provides a lightweight wrapper around the underlying [Process] static +/// methods to allow the implementation of these methods to be mocked out or +/// decorated for testing or debugging purposes. class ProcessManager { Future start( String executable, @@ -64,3 +73,409 @@ class ProcessManager { return Process.killPid(pid, signal); } } + +/// A [ProcessManager] implementation that decorates the standard behavior by +/// recording all process invocation activity (including the stdout and stderr +/// of the associated processes) and serializing that recording to a ZIP file +/// when the Flutter tools process exits. +class RecordingProcessManager implements ProcessManager { + static const String kDefaultRecordTo = 'recording.zip'; + static const List _kSkippableExecutables = const [ + 'env', + 'xcrun', + ]; + + final FileSystemEntity _recordTo; + final ProcessManager _delegate = new ProcessManager(); + final Directory _tmpDir = Directory.systemTemp.createTempSync('flutter_tools_'); + final List> _manifest = >[]; + final Map> _runningProcesses = >{}; + + /// Constructs a new `RecordingProcessManager` that will record all process + /// invocations and serialize them to the a ZIP file at the specified + /// [recordTo] location. + /// + /// If [recordTo] is a directory, a ZIP file named + /// [kDefaultRecordTo](`recording.zip`) will be created in the specified + /// directory. + /// + /// If [recordTo] is a file (or doesn't exist), it is taken to be the name + /// of the ZIP file that will be created, and the containing folder will be + /// created as needed. + RecordingProcessManager({FileSystemEntity recordTo}) + : _recordTo = recordTo ?? Directory.current { + addShutdownHook(_onShutdown); + } + + @override + Future start( + String executable, + List arguments, + {String workingDirectory, + Map environment, + ProcessStartMode mode: ProcessStartMode.NORMAL}) async { + Process process = await _delegate.start( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + mode: mode, + ); + + Map manifestEntry = _createManifestEntry( + pid: process.pid, + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + mode: mode, + ); + _manifest.add(manifestEntry); + + _RecordingProcess result = new _RecordingProcess( + manager: this, + basename: _getBasename(process.pid, executable, arguments), + delegate: process, + ); + await result.startRecording(); + _runningProcesses[process.pid] = result.exitCode.then((int exitCode) { + _runningProcesses.remove(process.pid); + manifestEntry['exitCode'] = exitCode; + }); + + return result; + } + + @override + Future run( + String executable, + List arguments, + {String workingDirectory, + Map environment, + Encoding stdoutEncoding: SYSTEM_ENCODING, + Encoding stderrEncoding: SYSTEM_ENCODING}) async { + ProcessResult result = await _delegate.run( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + ); + + _manifest.add(_createManifestEntry( + pid: result.pid, + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + exitCode: result.exitCode, + )); + + String basename = _getBasename(result.pid, executable, arguments); + await _recordData(result.stdout, stdoutEncoding, '$basename.stdout'); + await _recordData(result.stderr, stderrEncoding, '$basename.stderr'); + + return result; + } + + Future _recordData(dynamic data, Encoding encoding, String basename) async { + String path = '${_tmpDir.path}/$basename'; + File file = await new File(path).create(); + RandomAccessFile recording = await file.open(mode: FileMode.WRITE); + try { + if (encoding == null) + await recording.writeFrom(data); + else + await recording.writeString(data, encoding: encoding); + await recording.flush(); + } finally { + await recording.close(); + } + } + + @override + ProcessResult runSync( + String executable, + List arguments, + {String workingDirectory, + Map environment, + Encoding stdoutEncoding: SYSTEM_ENCODING, + Encoding stderrEncoding: SYSTEM_ENCODING}) { + ProcessResult result = _delegate.runSync( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + ); + + _manifest.add(_createManifestEntry( + pid: result.pid, + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + exitCode: result.exitCode, + )); + + String basename = _getBasename(result.pid, executable, arguments); + _recordDataSync(result.stdout, stdoutEncoding, '$basename.stdout'); + _recordDataSync(result.stderr, stderrEncoding, '$basename.stderr'); + + return result; + } + + void _recordDataSync(dynamic data, Encoding encoding, String basename) { + String path = '${_tmpDir.path}/$basename'; + File file = new File(path)..createSync(); + RandomAccessFile recording = file.openSync(mode: FileMode.WRITE); + try { + if (encoding == null) + recording.writeFromSync(data); + else + recording.writeStringSync(data, encoding: encoding); + recording.flushSync(); + } finally { + recording.closeSync(); + } + } + + @override + bool killPid(int pid, [ProcessSignal signal = ProcessSignal.SIGTERM]) { + return _delegate.killPid(pid, signal); + } + + /// Creates a JSON-encodable manifest entry representing the specified + /// process invocation. + Map _createManifestEntry({ + int pid, + String executable, + List arguments, + String workingDirectory, + Map environment, + ProcessStartMode mode, + Encoding stdoutEncoding, + Encoding stderrEncoding, + int exitCode, + }) { + Map entry = {}; + if (pid != null) entry['pid'] = pid; + if (executable != null) entry['executable'] = executable; + if (arguments != null) entry['arguments'] = arguments; + if (workingDirectory != null) entry['workingDirectory'] = workingDirectory; + if (environment != null) entry['environment'] = environment; + if (mode != null) entry['mode'] = mode.toString(); + if (stdoutEncoding != null) entry['stdoutEncoding'] = stdoutEncoding.name; + if (stderrEncoding != null) entry['stderrEncoding'] = stderrEncoding.name; + if (exitCode != null) entry['exitCode'] = exitCode; + return entry; + } + + /// Returns a human-readable identifier for the specified executable. + String _getBasename(int pid, String executable, List arguments) { + String index = new NumberFormat('000').format(_manifest.length - 1); + String identifier = path.basename(executable); + if (_kSkippableExecutables.contains(identifier) + && arguments != null + && arguments.isNotEmpty) { + identifier = path.basename(arguments.first); + } + return '$index.$identifier.$pid'; + } + + /// Invoked when the outermost executable process is about to shutdown + /// safely. This saves our recording to a ZIP file at the location specified + /// in the [new RecordingProcessManager] constructor. + Future _onShutdown() async { + await _waitForRunningProcessesToExit(); + await _writeManifestToDisk(); + await _saveRecording(); + await _tmpDir.delete(recursive: true); + } + + /// Waits for all running processes to exit, and records their exit codes in + /// the process manifest. Any process that doesn't exit in a timely fashion + /// will have a `"daemon"` marker added to its manifest and be signalled with + /// `SIGTERM`. If such processes *still* don't exit in a timely fashion after + /// being signalled, they'll have a `"notResponding"` marker added to their + /// manifest. + Future _waitForRunningProcessesToExit() async { + await _waitForRunningProcessesToExitWithTimeout( + onTimeout: (int pid, Map manifestEntry) { + manifestEntry['daemon'] = true; + Process.killPid(pid); + }); + // Now that we explicitly signalled the processes that timed out asking + // them to shutdown, wait one more time for those processes to exit. + await _waitForRunningProcessesToExitWithTimeout( + onTimeout: (int pid, Map manifestEntry) { + manifestEntry['notResponding'] = true; + }); + } + + Future _waitForRunningProcessesToExitWithTimeout({ + void onTimeout(int pid, Map manifestEntry), + }) async { + await Future.wait(new List>.from(_runningProcesses.values)) + .timeout(new Duration(milliseconds: 20), onTimeout: () { + _runningProcesses.forEach((int pid, Future future) { + Map manifestEntry = _manifest + .firstWhere((Map entry) => entry['pid'] == pid); + onTimeout(pid, manifestEntry); + }); + }); + } + + /// Writes our process invocation manifest to disk in our temp folder. + Future _writeManifestToDisk() async { + JsonEncoder encoder = new JsonEncoder.withIndent(' '); + String encodedManifest = encoder.convert(_manifest); + File manifestFile = await new File('${_tmpDir.path}/process-manifest.txt').create(); + await manifestFile.writeAsString(encodedManifest, flush: true); + } + + /// Saves our recording to a ZIP file at the specified location. + Future _saveRecording() async { + File zipFile = await _createZipFile(); + List zipData = await _getRecordingZipBytes(); + await zipFile.writeAsBytes(zipData); + } + + /// Creates our recording ZIP file at the location specified + /// in the [new RecordingProcessManager] constructor. + Future _createZipFile() async { + File zipFile; + if (await FileSystemEntity.type(_recordTo.path) == FileSystemEntityType.DIRECTORY) { + zipFile = new File('${_recordTo.path}/$kDefaultRecordTo'); + } else { + zipFile = new File(_recordTo.path); + await new Directory(path.dirname(zipFile.path)).create(recursive: true); + } + + // Resolve collisions. + String basename = path.basename(zipFile.path); + for (int i = 1; await zipFile.exists(); i++) { + assert(await FileSystemEntity.isFile(zipFile.path)); + String disambiguator = new NumberFormat('00').format(i); + String newBasename = basename; + if (basename.contains('.')) { + List parts = basename.split('.'); + parts[parts.length - 2] += '-$disambiguator'; + newBasename = parts.join('.'); + } else { + newBasename += '-$disambiguator'; + } + zipFile = new File(path.join(path.dirname(zipFile.path), newBasename)); + } + + return await zipFile.create(); + } + + /// Gets the bytes of our ZIP file recording. + Future> _getRecordingZipBytes() async { + Archive archive = new Archive(); + Stream files = _tmpDir.list(recursive: true) + .where((FileSystemEntity entity) => FileSystemEntity.isFileSync(entity.path)); + List> addAllFilesToArchive = >[]; + await files.forEach((FileSystemEntity entity) { + File file = entity; + Future readAsBytes = file.readAsBytes(); + addAllFilesToArchive.add(readAsBytes.then((List data) { + archive.addFile(new ArchiveFile.noCompress( + path.basename(file.path), data.length, data)); + })); + }); + + await Future.wait(addAllFilesToArchive); + return new ZipEncoder().encode(archive); + } +} + +/// A [Process] implementation that records `stdout` and `stderr` stream events +/// to disk before forwarding them on to the underlying stream listener. +class _RecordingProcess implements Process { + final Process delegate; + final String basename; + final RecordingProcessManager manager; + + bool _started = false; + + StreamController> _stdoutController = new StreamController>(); + StreamController> _stderrController = new StreamController>(); + + _RecordingProcess({this.manager, this.basename, this.delegate}); + + Future startRecording() async { + assert(!_started); + _started = true; + await Future.wait(>[ + _recordStream(delegate.stdout, _stdoutController, 'stdout'), + _recordStream(delegate.stderr, _stderrController, 'stderr'), + ]); + } + + Future _recordStream( + Stream> stream, + StreamController> controller, + String suffix, + ) async { + String path = '${manager._tmpDir.path}/$basename.$suffix'; + File file = await new File(path).create(); + RandomAccessFile recording = await file.open(mode: FileMode.WRITE); + stream.listen( + (List data) { + // Write synchronously to guarantee that the order of data + // within our recording is preserved across stream notifications. + recording.writeFromSync(data); + // Flush immediately so that if the program crashes, forensic + // data from the recording won't be lost. + recording.flushSync(); + controller.add(data); + }, + onError: (dynamic error, StackTrace stackTrace) { + recording.closeSync(); + controller.addError(error, stackTrace); + }, + onDone: () { + recording.closeSync(); + controller.close(); + }, + ); + } + + @override + Future get exitCode => delegate.exitCode; + + @override + set exitCode(Future exitCode) => delegate.exitCode = exitCode; + + @override + Stream> get stdout { + assert(_started); + return _stdoutController.stream; + } + + @override + Stream> get stderr { + assert(_started); + return _stderrController.stream; + } + + @override + IOSink get stdin { + // We don't currently support recording `stdin`. + return delegate.stdin; + } + + @override + int get pid => delegate.pid; + + @override + bool kill([ProcessSignal signal = ProcessSignal.SIGTERM]) => delegate.kill(signal); +} diff --git a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart index bc79962a028..1d1ce7ed381 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart @@ -13,6 +13,7 @@ import '../android/android_sdk.dart'; import '../base/context.dart'; import '../base/logger.dart'; import '../base/process.dart'; +import '../base/process_manager.dart'; import '../cache.dart'; import '../dart/package_map.dart'; import '../device.dart'; @@ -93,6 +94,14 @@ class FlutterCommandRunner extends CommandRunner { 'Name of a build output within the engine out directory, if you are building Flutter locally.\n' 'Use this to select a specific version of the engine if you have built multiple engine targets.\n' 'This path is relative to --local-engine-src-path/out.'); + argParser.addOption('record-to', + help: + 'Enables recording of process invocations (including stdout and ' + 'stderr of all such invocations), and serializes that recording to ' + 'the specified location.\n' + 'If the location is a directory, a ZIP file named `recording.zip` ' + 'will be created in that directory. Otherwise, a ZIP file will be ' + 'created with the path specified in this flag.'); } @override @@ -142,6 +151,19 @@ class FlutterCommandRunner extends CommandRunner { context.setVariable(Logger, new VerboseLogger()); } + if (globalResults['record-to'] != null) { + // Turn on recording + String recordToPath = globalResults['record-to'].trim(); + FileSystemEntity recordTo; + if (recordToPath.isNotEmpty) { + recordTo = await FileSystemEntity.isDirectory(recordToPath) + ? new Directory(recordToPath) + : new File(recordToPath); + } + context.setVariable(ProcessManager, + new RecordingProcessManager(recordTo: recordTo)); + } + logger.quiet = globalResults['quiet']; if (globalResults.wasParsed('color')) diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 90181890e20..1552747779f 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: crypto: '>=1.1.1 <3.0.0' file: ^0.1.0 http: ^0.11.3 + intl: '>=0.14.0 <0.15.0' json_rpc_2: ^2.0.0 json_schema: 1.0.6 linter: ^0.1.21