blob: f2b524d0030037b23fa6e592da5ef582559c5c91 [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. 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';
class SubprocessLauncher {
final String context;
final Map<String, String> defaultEnvironment;
String get prefix => context.isNotEmpty ? '$context: ' : '';
/// Listen to [stream] as a stream of lines of text, writing them to both
/// [output] and a returned String.
///
/// This is borrowed from flutter:dev/tools/dartdoc.dart; modified.
static Future<String> _printStream(Stream<List<int>> stream, Stdout output,
{required Iterable<String> Function(String line) filter,
String prefix = ''}) =>
stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.expand(filter)
.map(
(String line) {
final value = '${'$prefix$line'.trim()}\n';
output.write(value);
return value;
},
).join();
SubprocessLauncher(this.context, [this.defaultEnvironment = const {}]);
/// A wrapper around start/await `process.exitCode` that displays the output
/// of [executable] with [arguments] continuously and throws on non-zero exit
/// codes.
///
/// It will also parse any valid JSON objects (one per line) it encounters
/// on stdout/stderr, and return them. Returns null if no JSON objects
/// were encountered, or if DRY_RUN is set to 1 in the execution environment.
// TODO(jcollins-g): refactor to return a stream of stderr/stdout lines and
// their associated JSON objects.
Future<Iterable<Map<String, Object?>>> runStreamed(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
bool withStats = false,
}) async {
environment = {...defaultEnvironment, ...?environment};
var jsonObjects = <Map<String, Object?>>[];
/// Allow us to pretend we didn't pass the JSON flag in to dartdoc by
/// printing what dartdoc would have printed without it, yet storing
/// json objects into [jsonObjects].
Iterable<String> jsonCallback(String line) {
Map<String, Object?>? result;
try {
result = json.decoder.convert(line) as Map<String, dynamic>?;
} on FormatException {
// Assume invalid JSON is actually a line of normal text.
// ignore: avoid_catching_errors
} on TypeError catch (e, st) {
// The convert function returns a String if there is no JSON in the
// line. Just ignore it and leave result null.
print(e);
print(st);
}
if (result != null) {
jsonObjects.add(result);
if (result.containsKey('message')) {
line = result['message'] as String;
} else if (result.containsKey('data')) {
var data = result['data'] as Map;
line = data['text'] as String;
}
}
return line.split('\n');
}
stderr.write('$prefix+ ');
if (workingDirectory != null) stderr.write('(cd "$workingDirectory" && ');
if (environment.isNotEmpty) {
stderr.write(environment.entries.map((MapEntry<String, String> entry) {
if (entry.key.contains(_quotables)) {
return "${entry.key}='${entry.value}'";
} else {
return '${entry.key}=${entry.value}';
}
}).join(' '));
stderr.write(' ');
}
stderr.write(executable);
if (arguments.isNotEmpty) {
for (var arg in arguments) {
if (arg.contains(_quotables)) {
stderr.write(" '$arg'");
} else {
stderr.write(' $arg');
}
}
}
if (workingDirectory != null) stderr.write(')');
stderr.write('\n');
if (Platform.environment.containsKey('DRY_RUN')) return {};
if (withStats) {
(executable, arguments) = wrapWithTime(executable, arguments);
}
if (Platform.isLinux) {
(executable, arguments) = _wrapWithStdbuf(executable, arguments);
}
var process = await Process.start(executable, arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment);
// Stream stdout and stderr to _this_ process's stdout and stderr.
var stdoutFuture = _printStream(process.stdout, stdout,
prefix: prefix, filter: jsonCallback);
var stderrFuture = _printStream(process.stderr, stderr,
prefix: prefix, filter: jsonCallback);
var (_, _, exitCode) =
await (stdoutFuture, stderrFuture, process.exitCode).wait;
if (exitCode != 0) {
throw SubprocessException(
command: [executable, ...arguments].join(' '),
workingDirectory: workingDirectory,
exitCode: exitCode,
environment: environment,
);
}
return jsonObjects;
}
/// Wraps [command] and [args] with a call to `stdbuf`.
(String, List<String>) _wrapWithStdbuf(String command, List<String> args) =>
// Use GNU coreutils to force line buffering. This makes sure that
// subprocesses that die due to fatal signals do not chop off the last few
// lines of their output.
//
// Dart does not actually do this (seems to flush manually) unless the VM
// crashes.
(
'stdbuf',
[
...['-o', 'L', '-e', 'L'],
command,
...args,
],
);
/// Wraps [command] and [args] with a command to `time`.
static (String, List<String>) wrapWithTime(
String command, List<String> args) =>
('/usr/bin/time', ['-l', command, ...args]);
Future<Iterable<Map<String, Object?>>> runStreamedDartCommand(
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
bool withStats = false,
}) async =>
await runStreamed(
Platform.executable,
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
withStats: withStats,
);
static final _quotables = RegExp(r'[ "\r\n\$]');
}
/// An exception that represents an issue during subprocess execution.
class SubprocessException implements Exception {
final String command;
final String? workingDirectory;
final int exitCode;
final Map<String, String> environment;
SubprocessException({
required this.command,
required this.workingDirectory,
required this.environment,
required this.exitCode,
});
@override
String toString() => 'SubprocessException['
'command: "$command", '
'workingDirectory: "${workingDirectory ?? '(null)'}", '
'exitCode: $exitCode, '
'environment: $environment]';
}