feat(cli): hint at --dart-define/--obfuscate mismatch on link failure (#3699)

This commit is contained in:
Eric Seidel
2026-04-21 12:15:46 -07:00
committed by GitHub
parent 3c58af5a2d
commit 71f908e19d
2 changed files with 395 additions and 16 deletions
@@ -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'));
});
});
});
}