// 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: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, List arguments, {String workingDirectory, Map environment, ProcessStartMode mode: ProcessStartMode.NORMAL}) { return Process.start( executable, arguments, workingDirectory: workingDirectory, environment: environment, mode: mode, ); } Future run( String executable, List arguments, {String workingDirectory, Map environment, Encoding stdoutEncoding: SYSTEM_ENCODING, Encoding stderrEncoding: SYSTEM_ENCODING}) { return Process.run( executable, arguments, workingDirectory: workingDirectory, environment: environment, stdoutEncoding: stdoutEncoding, stderrEncoding: stderrEncoding, ); } ProcessResult runSync( String executable, List arguments, {String workingDirectory, Map environment, Encoding stdoutEncoding: SYSTEM_ENCODING, Encoding stderrEncoding: SYSTEM_ENCODING}) { return Process.runSync( executable, arguments, workingDirectory: workingDirectory, environment: environment, stdoutEncoding: stdoutEncoding, stderrEncoding: stderrEncoding, ); } bool killPid(int pid, [ProcessSignal signal = ProcessSignal.SIGTERM]) { 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); }