From 71f908e19ddd9ddc13fcf02402e11df60aa01e5c Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 21 Apr 2026 12:15:46 -0700 Subject: [PATCH] feat(cli): hint at --dart-define/--obfuscate mismatch on link failure (#3699) --- .../lib/src/executables/aot_tools.dart | 118 ++++++- .../test/src/executables/aot_tools_test.dart | 293 ++++++++++++++++++ 2 files changed, 395 insertions(+), 16 deletions(-) diff --git a/packages/shorebird_cli/lib/src/executables/aot_tools.dart b/packages/shorebird_cli/lib/src/executables/aot_tools.dart index 4111bd92..cfe11e20 100644 --- a/packages/shorebird_cli/lib/src/executables/aot_tools.dart +++ b/packages/shorebird_cli/lib/src/executables/aot_tools.dart @@ -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 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? _extractLinkFailure(File file) { + if (!file.existsSync()) return null; + try { + return const LineSplitter() + .convert(file.readAsStringSync()) + .where((line) => line.isNotEmpty) + .map(json.decode) + .cast>() + .firstWhereOrNull((line) => line['type'] == 'link_failure'); + } on FormatException { + return null; + } + } + double? _extractLinkPercentage(File file) { if (!file.existsSync()) return null; final status = const LineSplitter() diff --git a/packages/shorebird_cli/test/src/executables/aot_tools_test.dart b/packages/shorebird_cli/test/src/executables/aot_tools_test.dart index 2eca4d0a..5c44ef60 100644 --- a/packages/shorebird_cli/test/src/executables/aot_tools_test.dart +++ b/packages/shorebird_cli/test/src/executables/aot_tools_test.dart @@ -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() + .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()), + ); + }, + ); + + 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()), + ); + }, + ); }); group('isLinkDebugInfoSupported', () { @@ -1186,4 +1398,85 @@ Run "aot_tools help " for more information about a command. ); }); }); + + group(LinkFailureException, () { + const execFailure = AotToolsExecutionFailure( + exitCode: 1, + stdout: '', + stderr: '', + command: 'aot_tools link', + ); + + LinkFailureException build(Map 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')); + }); + }); + }); }