feat(cli): hint at --dart-define/--obfuscate mismatch on link failure (#3699)
This commit is contained in:
@@ -102,6 +102,64 @@ stderr: $stderr''';
|
||||
}
|
||||
}
|
||||
|
||||
/// {@template link_failure_exception}
|
||||
/// Exception thrown when `aot_tools link` reports a structured `link_failure`
|
||||
/// in its JSON output. Wraps the underlying [execFailure] and augments it
|
||||
/// with a remediation hint when the failure has a recognizable signature.
|
||||
/// {@endtemplate}
|
||||
class LinkFailureException implements Exception {
|
||||
/// {@macro link_failure_exception}
|
||||
const LinkFailureException({
|
||||
required this.execFailure,
|
||||
required this.linkFailure,
|
||||
});
|
||||
|
||||
/// The underlying non-zero-exit failure from aot_tools.
|
||||
final AotToolsExecutionFailure execFailure;
|
||||
|
||||
/// The parsed `link_failure` event from the link JSONL output.
|
||||
final Map<String, dynamic> linkFailure;
|
||||
|
||||
/// Returns a remediation hint when the failure matches a known pattern,
|
||||
/// or null otherwise.
|
||||
String? get hint {
|
||||
final details = linkFailure['details'];
|
||||
if (details is! Map) return null;
|
||||
final dataHash = details['vm_data_hash'];
|
||||
final instructionsHash = details['vm_instructions_hash'];
|
||||
if (dataHash is! Map || instructionsHash is! Map) return null;
|
||||
final dataDiffers = dataHash['base'] != dataHash['patch'];
|
||||
final instructionsMatch =
|
||||
instructionsHash['base'] == instructionsHash['patch'];
|
||||
if (dataDiffers && instructionsMatch) {
|
||||
return '''
|
||||
The VM data section differs between the release and the patch, while the
|
||||
instruction section matches. This typically means the release and patch were
|
||||
built with different --dart-define values or a different --obfuscate setting,
|
||||
since those affect compile-time constants that live in the VM data section.
|
||||
|
||||
Verify that `shorebird patch` was invoked with the exact same --dart-define
|
||||
(and --dart-define-from-file) flags as `shorebird release`, and that the
|
||||
--obfuscate setting matches.''';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final reason = linkFailure['reason'] ?? 'aot_tools link reported a failure';
|
||||
final buffer = StringBuffer('$reason')..writeln();
|
||||
final hint = this.hint;
|
||||
if (hint != null) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln(hint);
|
||||
}
|
||||
buffer.write(execFailure);
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper around the shorebird `aot-tools` executable.
|
||||
class AotTools {
|
||||
/// Returns true if the linker should be used for the given Flutter revision.
|
||||
@@ -283,28 +341,56 @@ class AotTools {
|
||||
const linkJson = 'link.jsonl';
|
||||
final outputDir = p.dirname(outputPath);
|
||||
final linkerUsesGenSnapshot = await _linkerUsesGenSnapshot();
|
||||
await _exec([
|
||||
'link',
|
||||
'--base=$base',
|
||||
'--patch=$patch',
|
||||
'--analyze-snapshot=$analyzeSnapshot',
|
||||
'--output=$outputPath',
|
||||
'--verbose',
|
||||
if (linkerUsesGenSnapshot) ...[
|
||||
'--gen-snapshot=$genSnapshot',
|
||||
'--kernel=$kernel',
|
||||
'--reporter=json',
|
||||
'--redirect-to=${p.join(outputDir, linkJson)}',
|
||||
],
|
||||
if (dumpDebugInfoPath != null) '--dump-debug-info=$dumpDebugInfoPath',
|
||||
if (additionalArgs.isNotEmpty) ...['--', ...additionalArgs],
|
||||
], workingDirectory: workingDirectory);
|
||||
try {
|
||||
await _exec([
|
||||
'link',
|
||||
'--base=$base',
|
||||
'--patch=$patch',
|
||||
'--analyze-snapshot=$analyzeSnapshot',
|
||||
'--output=$outputPath',
|
||||
'--verbose',
|
||||
if (linkerUsesGenSnapshot) ...[
|
||||
'--gen-snapshot=$genSnapshot',
|
||||
'--kernel=$kernel',
|
||||
'--reporter=json',
|
||||
'--redirect-to=${p.join(outputDir, linkJson)}',
|
||||
],
|
||||
if (dumpDebugInfoPath != null) '--dump-debug-info=$dumpDebugInfoPath',
|
||||
if (additionalArgs.isNotEmpty) ...['--', ...additionalArgs],
|
||||
], workingDirectory: workingDirectory);
|
||||
} on AotToolsExecutionFailure catch (e) {
|
||||
if (linkerUsesGenSnapshot && workingDirectory != null) {
|
||||
final linkFailure = _extractLinkFailure(
|
||||
File(p.join(workingDirectory, linkJson)),
|
||||
);
|
||||
if (linkFailure != null) {
|
||||
throw LinkFailureException(execFailure: e, linkFailure: linkFailure);
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return linkerUsesGenSnapshot
|
||||
? _extractLinkPercentage(File(p.join(workingDirectory!, linkJson)))
|
||||
: null;
|
||||
}
|
||||
|
||||
/// Returns the first `link_failure` event in the link JSONL output, or
|
||||
/// null if the file is missing, malformed, or contains no such event.
|
||||
Map<String, dynamic>? _extractLinkFailure(File file) {
|
||||
if (!file.existsSync()) return null;
|
||||
try {
|
||||
return const LineSplitter()
|
||||
.convert(file.readAsStringSync())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.map(json.decode)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.firstWhereOrNull((line) => line['type'] == 'link_failure');
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
double? _extractLinkPercentage(File file) {
|
||||
if (!file.existsSync()) return null;
|
||||
final status = const LineSplitter()
|
||||
|
||||
@@ -667,6 +667,218 @@ stderr: error'''),
|
||||
], workingDirectory: any(named: 'workingDirectory')),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'throws LinkFailureException with hint on VM data mismatch',
|
||||
() async {
|
||||
workingDirectory = Directory.systemTemp.createTempSync();
|
||||
when(
|
||||
() => process.start(aotToolsPath, [
|
||||
'--version',
|
||||
], workingDirectory: any(named: 'workingDirectory')),
|
||||
).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.run(
|
||||
aotToolsPath,
|
||||
any(that: contains('--gen-snapshot=$genSnapshot')),
|
||||
workingDirectory: any(named: 'workingDirectory'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => const ShorebirdProcessResult(
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: 'error',
|
||||
),
|
||||
);
|
||||
when(
|
||||
() => process.start(
|
||||
aotToolsPath,
|
||||
any(that: contains('--gen-snapshot=$genSnapshot')),
|
||||
workingDirectory: any(named: 'workingDirectory'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
final linkFailure = jsonEncode({
|
||||
'type': 'link_failure',
|
||||
'reason': 'base and patch snapshots have differing VM sections',
|
||||
'details': {
|
||||
'vm_data_length': {'base': 39296, 'patch': 39296},
|
||||
'vm_instructions_length': {'base': 65280, 'patch': 65280},
|
||||
'vm_data_hash': {'base': 4272422645, 'patch': 2308514119},
|
||||
'vm_instructions_hash': {
|
||||
'base': 1550369841,
|
||||
'patch': 1550369841,
|
||||
},
|
||||
},
|
||||
});
|
||||
File(
|
||||
p.join(workingDirectory.path, 'link.jsonl'),
|
||||
).writeAsStringSync('$linkFailure\n');
|
||||
|
||||
final mockProcess = MockProcess();
|
||||
when(() => mockProcess.exitCode).thenAnswer((_) async => 1);
|
||||
when(
|
||||
() => mockProcess.stdout,
|
||||
).thenAnswer((_) => const Stream.empty());
|
||||
when(
|
||||
() => mockProcess.stderr,
|
||||
).thenAnswer((_) => Stream.value(utf8.encode('error')));
|
||||
return mockProcess;
|
||||
});
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => aotTools.link(
|
||||
base: base,
|
||||
patch: patch,
|
||||
analyzeSnapshot: analyzeSnapshot,
|
||||
genSnapshot: genSnapshot,
|
||||
kernel: kernel,
|
||||
workingDirectory: workingDirectory.path,
|
||||
outputPath: outputPath,
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<LinkFailureException>()
|
||||
.having(
|
||||
(e) => '$e',
|
||||
'toString',
|
||||
contains('differing VM sections'),
|
||||
)
|
||||
.having(
|
||||
(e) => e.hint,
|
||||
'hint',
|
||||
allOf(
|
||||
contains('--dart-define'),
|
||||
contains('--obfuscate'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'rethrows AotToolsExecutionFailure when jsonl is malformed',
|
||||
() async {
|
||||
workingDirectory = Directory.systemTemp.createTempSync();
|
||||
when(
|
||||
() => process.start(aotToolsPath, [
|
||||
'--version',
|
||||
], workingDirectory: any(named: 'workingDirectory')),
|
||||
).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,
|
||||
any(that: contains('--gen-snapshot=$genSnapshot')),
|
||||
workingDirectory: any(named: 'workingDirectory'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
File(
|
||||
p.join(workingDirectory.path, 'link.jsonl'),
|
||||
).writeAsStringSync('this is not json\n');
|
||||
|
||||
final mockProcess = MockProcess();
|
||||
when(() => mockProcess.exitCode).thenAnswer((_) async => 1);
|
||||
when(
|
||||
() => mockProcess.stdout,
|
||||
).thenAnswer((_) => const Stream.empty());
|
||||
when(
|
||||
() => mockProcess.stderr,
|
||||
).thenAnswer((_) => Stream.value(utf8.encode('boom')));
|
||||
return mockProcess;
|
||||
});
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => aotTools.link(
|
||||
base: base,
|
||||
patch: patch,
|
||||
analyzeSnapshot: analyzeSnapshot,
|
||||
genSnapshot: genSnapshot,
|
||||
kernel: kernel,
|
||||
workingDirectory: workingDirectory.path,
|
||||
outputPath: outputPath,
|
||||
),
|
||||
),
|
||||
throwsA(isA<AotToolsExecutionFailure>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'rethrows AotToolsExecutionFailure when no link_failure event',
|
||||
() async {
|
||||
workingDirectory = Directory.systemTemp.createTempSync();
|
||||
when(
|
||||
() => process.start(aotToolsPath, [
|
||||
'--version',
|
||||
], workingDirectory: any(named: 'workingDirectory')),
|
||||
).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,
|
||||
any(that: contains('--gen-snapshot=$genSnapshot')),
|
||||
workingDirectory: any(named: 'workingDirectory'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
// No link.jsonl written — simulates a crash before reporting.
|
||||
final mockProcess = MockProcess();
|
||||
when(() => mockProcess.exitCode).thenAnswer((_) async => 1);
|
||||
when(
|
||||
() => mockProcess.stdout,
|
||||
).thenAnswer((_) => const Stream.empty());
|
||||
when(
|
||||
() => mockProcess.stderr,
|
||||
).thenAnswer((_) => Stream.value(utf8.encode('boom')));
|
||||
return mockProcess;
|
||||
});
|
||||
|
||||
await expectLater(
|
||||
runWithOverrides(
|
||||
() => aotTools.link(
|
||||
base: base,
|
||||
patch: patch,
|
||||
analyzeSnapshot: analyzeSnapshot,
|
||||
genSnapshot: genSnapshot,
|
||||
kernel: kernel,
|
||||
workingDirectory: workingDirectory.path,
|
||||
outputPath: outputPath,
|
||||
),
|
||||
),
|
||||
throwsA(isA<AotToolsExecutionFailure>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('isLinkDebugInfoSupported', () {
|
||||
@@ -1186,4 +1398,85 @@ Run "aot_tools help <command>" for more information about a command.
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group(LinkFailureException, () {
|
||||
const execFailure = AotToolsExecutionFailure(
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
command: 'aot_tools link',
|
||||
);
|
||||
|
||||
LinkFailureException build(Map<String, dynamic> linkFailure) =>
|
||||
LinkFailureException(
|
||||
execFailure: execFailure,
|
||||
linkFailure: linkFailure,
|
||||
);
|
||||
|
||||
group('hint', () {
|
||||
test('is null when details is missing', () {
|
||||
expect(build({'type': 'link_failure'}).hint, isNull);
|
||||
});
|
||||
|
||||
test('is null when hash fields are not maps', () {
|
||||
expect(
|
||||
build({
|
||||
'details': {'vm_data_hash': 'oops', 'vm_instructions_hash': 0},
|
||||
}).hint,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('is null when instructions also differ', () {
|
||||
expect(
|
||||
build({
|
||||
'details': {
|
||||
'vm_data_hash': {'base': 1, 'patch': 2},
|
||||
'vm_instructions_hash': {'base': 3, 'patch': 4},
|
||||
},
|
||||
}).hint,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('is null when data matches', () {
|
||||
expect(
|
||||
build({
|
||||
'details': {
|
||||
'vm_data_hash': {'base': 1, 'patch': 1},
|
||||
'vm_instructions_hash': {'base': 2, 'patch': 2},
|
||||
},
|
||||
}).hint,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('is set for the VM-data-only mismatch signature', () {
|
||||
final hint = build({
|
||||
'details': {
|
||||
'vm_data_hash': {'base': 1, 'patch': 2},
|
||||
'vm_instructions_hash': {'base': 3, 'patch': 3},
|
||||
},
|
||||
}).hint;
|
||||
expect(hint, isNotNull);
|
||||
expect(hint, contains('--dart-define'));
|
||||
expect(hint, contains('--obfuscate'));
|
||||
});
|
||||
});
|
||||
|
||||
group('toString', () {
|
||||
test('uses a fallback reason when none is present', () {
|
||||
expect(
|
||||
build({}).toString(),
|
||||
contains('aot_tools link reported a failure'),
|
||||
);
|
||||
});
|
||||
|
||||
test('includes the reason and underlying execFailure', () {
|
||||
final out = build({'reason': 'nope'}).toString();
|
||||
expect(out, contains('nope'));
|
||||
expect(out, contains('aot_tools link failed with exit code 1'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user