refactor(shorebird_cli): AotTools process execution and error reporting (#2503)

This commit is contained in:
Felix Angelov
2024-09-30 13:59:51 -04:00
committed by GitHub
parent c2899660a4
commit cf617aef81
2 changed files with 220 additions and 180 deletions
@@ -67,6 +67,39 @@ const _preLinkerFlutterRevisions = <String>{
'1a6115bebe31e63508c312d14e69e973e1a59dbf',
};
/// {@template aot_tools_execution_failure}
/// Exception thrown when aot_tools execution exits with a non-zero exit code.
/// {@endtemplate}
class AotToolsExecutionFailure implements Exception {
/// {@macro aot_tools_execution_failure}
const AotToolsExecutionFailure({
required this.exitCode,
required this.stdout,
required this.stderr,
required this.command,
});
/// The exit code of the failed aot_tools execution.
final int exitCode;
/// The standard output of the failed aot_tools execution.
final String stdout;
/// The standard error of the failed aot_tools execution.
final String stderr;
/// The command that was executed.
final String command;
@override
String toString() {
return '''
$command failed with exit code $exitCode
stdout: $stdout
stderr: $stderr''';
}
}
/// Wrapper around the shorebird `aot-tools` executable.
class AotTools {
/// Returns true if the linker should be used for the given Flutter revision.
@@ -86,17 +119,13 @@ class AotTools {
/// Runs the `aot-tools` executable with the given [command] and await for
/// its completion.
///
/// If no [runCommand] is provided, [ShorebirdProcess.run] is used.
/// If the command exits with a non-zero exit code, an
/// [AotToolsExecutionFailure] is thrown.
Future<ShorebirdProcessResult> _exec(
List<String> command, {
Future<ShorebirdProcessResult> Function(
String,
List<String>, {
String? workingDirectory,
})? runCommand,
String? workingDirectory,
bool throwOnError = true,
}) async {
final runFn = runCommand ?? process.run;
await cache.updateAll();
// This will be a path to either a kernel (.dill) file or a Dart script if
@@ -105,68 +134,78 @@ class AotTools {
artifact: ShorebirdArtifact.aotTools,
);
final ShorebirdProcessResult result;
// Similar to [ShorebirdProcess.run] but uses [ShorebirdProcess.start]
// instead and captures the live stdout and stderr of the process.
Future<ShorebirdProcessResult> execute(
String exe,
List<String> args, {
String? workingDirectory,
}) async {
final subprocess = await process.start(
exe,
args,
workingDirectory: workingDirectory,
);
final stdout = StringBuffer();
final stderr = StringBuffer();
final stdoutSubscription = subprocess.stdout.map(utf8.decode).listen(
(data) {
logger.detail(data);
stdout.write(data);
},
);
final stderrSubscription = subprocess.stderr.map(utf8.decode).listen(
(data) {
logger.detail(data);
stderr.write(data);
},
);
final exitCode = await subprocess.exitCode;
stdoutSubscription.cancel().ignore();
stderrSubscription.cancel().ignore();
return ShorebirdProcessResult(
exitCode: exitCode,
stdout: stdout.toString(),
stderr: stderr.toString(),
);
}
// Fallback behavior for older versions of shorebird where aot-tools was
// distributed as an executable.
final extension = p.extension(artifactPath);
if (extension != '.dill' && extension != '.dart') {
return runFn(
result = await execute(
artifactPath,
command,
workingDirectory: workingDirectory,
);
} else {
// local engine versions use .dart and we distribute aot-tools as a .dill
result = await execute(
shorebirdEnv.dartBinaryFile.path,
['run', artifactPath, ...command],
workingDirectory: workingDirectory,
);
}
// local engine versions use .dart and we distribute aot-tools as a .dill
return runFn(
shorebirdEnv.dartBinaryFile.path,
['run', artifactPath, ...command],
workingDirectory: workingDirectory,
);
}
if (throwOnError && result.exitCode != 0) {
throw AotToolsExecutionFailure(
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
command: ['aot_tools', ...command].join(' '),
);
}
/// Similar to [_exec], but logs the sub process stdout and stderr
/// as they are emitted.
Future<ShorebirdProcessResult> _execWithLiveLogs(
List<String> command, {
String? workingDirectory,
}) {
return _exec(
command,
runCommand: (
String exe,
List<String> args, {
String? workingDirectory,
}) async {
final spawnedProcess = await process.start(
exe,
args,
workingDirectory: workingDirectory,
);
final stdout = StringBuffer();
final stderr = StringBuffer();
final stdoutSub = spawnedProcess.stdout.map(utf8.decode).listen((data) {
logger.detail(data);
stdout.write(data);
});
final stderrSub =
spawnedProcess.stderr.map(utf8.decode).listen(stderr.write);
final exitCode = await spawnedProcess.exitCode;
await stdoutSub.cancel();
await stderrSub.cancel();
return ShorebirdProcessResult(
exitCode: exitCode,
stdout: stdout.toString(),
stderr: stderr.toString(),
);
},
workingDirectory: workingDirectory,
);
return result;
}
Future<bool> _linkerUsesGenSnapshot() async {
@@ -179,7 +218,7 @@ class AotTools {
// If callers need to care about null, we can change this function to
// return Version?.
final noVersion = Version(0, 0, 0);
final result = await _exec(['--version']);
final result = await _exec(['--version'], throwOnError: false);
if (result.exitCode != ExitCode.success.code) {
return noVersion;
}
@@ -209,7 +248,7 @@ class AotTools {
const linkJson = 'link.jsonl';
final outputDir = p.dirname(outputPath);
final linkerUsesGenSnapshot = await _linkerUsesGenSnapshot();
final result = await _execWithLiveLogs(
await _exec(
[
'link',
'--base=$base',
@@ -228,13 +267,6 @@ class AotTools {
workingDirectory: workingDirectory,
);
if (result.exitCode != 0) {
throw Exception('''
Failed to link:
stdout: ${result.stdout}
stderr: ${result.stderr}''');
}
return linkerUsesGenSnapshot
? _extractLinkPercentage(File(p.join(workingDirectory!, linkJson)))
: null;
@@ -275,7 +307,7 @@ stderr: ${result.stderr}''');
}) async {
final tmpDir = Directory.systemTemp.createTempSync();
final outFile = File(p.join(tmpDir.path, 'diff_base'));
final result = await _exec(
await _exec(
[
'dump_blobs',
'--analyze-snapshot=$analyzeSnapshotPath',
@@ -284,10 +316,6 @@ stderr: ${result.stderr}''');
],
);
if (result.exitCode != ExitCode.success.code) {
throw Exception('Failed to generate patch diff base: ${result.stderr}');
}
if (!outFile.existsSync()) {
throw Exception(
'Failed to generate patch diff base: output file does not exist',
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
@@ -67,7 +66,7 @@ void main() {
final outputPath = p.join('.', 'path', 'to', 'out.vmcode');
final linkJsonPath = p.join('.', 'path', 'to', 'link.jsonl');
test('throws Exception when process exits with non-zero code', () async {
test('throws exception when process exits with non-zero code', () async {
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.aotTools,
@@ -118,13 +117,14 @@ void main() {
),
),
throwsA(
isA<Exception>().having(
isA<AotToolsExecutionFailure>().having(
(e) => '$e',
'exception',
'''
Exception: Failed to link:
'toString',
contains(
'''
stdout: info
stderr: error''',
),
),
),
);
@@ -206,19 +206,6 @@ stderr: error''',
});
test('forwards stdout from aot_tools link to the logger', () async {
when(
() => process.run(
aotToolsPath,
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async {
return const ShorebirdProcessResult(
exitCode: 0,
stdout: '',
stderr: '',
);
});
when(
() => process.start(
aotToolsPath,
@@ -252,7 +239,8 @@ stderr: error''',
),
);
verify(() => logger.detail('stdout')).called(1);
// One for --version and one for the link command.
verify(() => logger.detail('stdout')).called(2);
});
});
@@ -506,17 +494,23 @@ stderr: error''',
test('passes gen_snapshot to aot_tools', () async {
when(
() => process.run(
() => process.start(
aotToolsPath,
['--version'],
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer(
(_) async => const ShorebirdProcessResult(
exitCode: 0,
stdout: '0.0.1',
stderr: '',
),
(_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode('0.0.1')));
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream.empty());
return mockProcess;
},
);
when(
() => process.start(
@@ -576,18 +570,24 @@ stderr: error''',
test('returns link percentage', () async {
workingDirectory = Directory.systemTemp.createTempSync();
when(
() => process.run(
() => process.start(
aotToolsPath,
['--version'],
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async {
return const ShorebirdProcessResult(
exitCode: 0,
stdout: '0.0.1',
stderr: '',
);
});
).thenAnswer(
(_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode('0.0.1')));
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream.empty());
return mockProcess;
},
);
when(
() => process.start(
aotToolsPath,
@@ -653,9 +653,18 @@ stderr: error''',
group('isLinkDebugInfoSupported', () {
test('returns true when the argument is present in the help', () async {
final result = MockShorebirdProcessResult();
when(() => result.exitCode).thenReturn(ExitCode.success.code);
when(() => result.stdout).thenReturn('''
when(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(() => mockProcess.stdout).thenAnswer(
(_) => Stream.value(
utf8.encode('''
Link two aot snapshots.
Usage: aot_tools link [arguments]
@@ -677,15 +686,14 @@ Usage: aot_tools link [arguments]
--redirect-to Redirect output to a file.
Run "aot_tools help" to see global options.
''');
when(
() => process.run(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async => result);
'''),
),
);
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream.empty());
return mockProcess;
});
await expectLater(
runWithOverrides(() => aotTools.isLinkDebugInfoSupported()),
@@ -696,9 +704,18 @@ Run "aot_tools help" to see global options.
test(
'returns false when the argument is not present in the help',
() async {
final result = MockShorebirdProcessResult();
when(() => result.exitCode).thenReturn(ExitCode.success.code);
when(() => result.stdout).thenReturn('''
when(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(() => mockProcess.stdout).thenAnswer(
(_) => Stream.value(
utf8.encode('''
Link two aot snapshots.
Usage: aot_tools link [arguments]
@@ -719,15 +736,14 @@ Usage: aot_tools link [arguments]
--redirect-to Redirect output to a file.
Run "aot_tools help" to see global options.
''');
when(
() => process.run(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async => result);
'''),
),
);
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream.empty());
return mockProcess;
});
await expectLater(
runWithOverrides(() => aotTools.isLinkDebugInfoSupported()),
@@ -742,17 +758,23 @@ Run "aot_tools help" to see global options.
var stdout = '';
setUp(() {
when(
() => process.run(
() => process.start(
dartBinaryFile.path,
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer(
(_) async => ShorebirdProcessResult(
exitCode: ExitCode.success.code,
stdout: stdout,
stderr: '',
),
(_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => 0);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode(stdout)));
when(
() => mockProcess.stderr,
).thenAnswer((_) => const Stream.empty());
return mockProcess;
},
);
});
@@ -812,37 +834,39 @@ Run "aot_tools help <command>" for more information about a command.
});
group('generatePatchDiffBase', () {
late int exitCode;
late String stdout;
late String stderr;
setUp(() {
exitCode = 0;
stdout = '';
stderr = '';
when(
() => process.run(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer(
(_) async => const ShorebirdProcessResult(
exitCode: 0,
stdout: '',
stderr: 'error',
),
(_) async {
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => exitCode);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode(stdout)));
when(
() => mockProcess.stderr,
).thenAnswer((_) => Stream.value(utf8.encode(stderr)));
return mockProcess;
},
);
});
group('when command returns non-zero exit code', () {
setUp(() {
when(
() => process.run(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer(
(_) async => const ShorebirdProcessResult(
exitCode: 1,
stdout: '',
stderr: 'error',
),
);
exitCode = 1;
stderr = 'error';
});
test('throws exception', () async {
@@ -854,10 +878,10 @@ Run "aot_tools help <command>" for more information about a command.
),
),
throwsA(
isA<Exception>().having(
isA<AotToolsExecutionFailure>().having(
(e) => '$e',
'exception',
'Exception: Failed to generate patch diff base: error',
'toString',
contains('stderr: error'),
),
),
);
@@ -865,22 +889,6 @@ Run "aot_tools help <command>" for more information about a command.
});
group('when out file does not exist', () {
setUp(() {
when(
() => process.run(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer(
(_) async => const ShorebirdProcessResult(
exitCode: 0,
stdout: '',
stderr: '',
),
);
});
test('throws exception', () async {
await expectLater(
() => runWithOverrides(
@@ -903,7 +911,7 @@ Run "aot_tools help <command>" for more information about a command.
group('when out file is created', () {
setUp(() {
when(
() => process.run(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
@@ -915,11 +923,15 @@ Run "aot_tools help <command>" for more information about a command.
.split('=')
.last;
File(outArgument).createSync(recursive: true);
return const ShorebirdProcessResult(
exitCode: 0,
stdout: '',
stderr: '',
);
final mockProcess = MockProcess();
when(() => mockProcess.exitCode).thenAnswer((_) async => exitCode);
when(
() => mockProcess.stdout,
).thenAnswer((_) => Stream.value(utf8.encode(stdout)));
when(
() => mockProcess.stderr,
).thenAnswer((_) => Stream.value(utf8.encode(stderr)));
return mockProcess;
});
});