feat: collect linker metadata if available (#3070)

Runs aot_tools link_metadata if available. Does not block the build if it is not.
Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
Eric Seidel
2025-04-22 14:41:44 -07:00
committed by GitHub
parent 24c8c6c01e
commit 75c8c97003
10 changed files with 246 additions and 17 deletions
@@ -58,6 +58,10 @@ class IosFrameworkPatcher extends Patcher {
@override
ReleaseType get releaseType => ReleaseType.iosFramework;
/// The last build's link metadata.
@visibleForTesting
Map<String, dynamic>? lastBuildLinkMetadata;
@override
double? get linkPercentage => lastBuildLinkPercentage;
@@ -175,13 +179,22 @@ class IosFrameworkPatcher extends Patcher {
patchSnapshotDir: shorebirdEnv.buildDirectory,
);
await apple.runLinker(
final result = await apple.runLinker(
kernelFile: File(_appDillCopyPath),
releaseArtifact: releaseArtifactFile,
splitDebugInfoArgs: IosPatcher.splitDebugInfoArgs(splitDebugInfoPath),
aotOutputFile: File(_aotOutputPath),
vmCodeFile: File(_vmcodeOutputPath),
);
final linkPercentage = result.linkPercentage;
final exitCode = result.exitCode;
if (exitCode != ExitCode.success.code) throw ProcessExit(exitCode);
if (linkPercentage != null &&
linkPercentage < Patcher.linkPercentageWarningThreshold) {
logger.warn(Patcher.lowLinkPercentageWarning(linkPercentage));
}
lastBuildLinkPercentage = linkPercentage;
lastBuildLinkMetadata = result.linkMetadata;
}
final patchBuildFile =
@@ -249,6 +262,7 @@ class IosFrameworkPatcher extends Patcher {
CreatePatchMetadata metadata,
) async => metadata.copyWith(
linkPercentage: lastBuildLinkPercentage,
linkMetadata: lastBuildLinkMetadata,
environment: metadata.environment.copyWith(
xcodeVersion: await xcodeBuild.version(),
),
@@ -76,6 +76,10 @@ class IosPatcher extends Patcher {
@visibleForTesting
double? lastBuildLinkPercentage;
/// The last build's link metadata.
@visibleForTesting
Json? lastBuildLinkMetadata;
@override
double? get linkPercentage => lastBuildLinkPercentage;
@@ -258,19 +262,22 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
patchSnapshotDir: shorebirdEnv.buildDirectory,
);
final (:exitCode, :linkPercentage) = await apple.runLinker(
final result = await apple.runLinker(
kernelFile: File(_appDillCopyPath),
releaseArtifact: releaseArtifactFile,
splitDebugInfoArgs: splitDebugInfoArgs(splitDebugInfoPath),
aotOutputFile: File(_aotOutputPath),
vmCodeFile: File(_vmcodeOutputPath),
);
final linkPercentage = result.linkPercentage;
final exitCode = result.exitCode;
if (exitCode != ExitCode.success.code) throw ProcessExit(exitCode);
if (linkPercentage != null &&
linkPercentage < Patcher.linkPercentageWarningThreshold) {
logger.warn(Patcher.lowLinkPercentageWarning(linkPercentage));
}
lastBuildLinkPercentage = linkPercentage;
lastBuildLinkMetadata = result.linkMetadata;
}
final patchBuildFile = File(useLinker ? _vmcodeOutputPath : _aotOutputPath);
@@ -355,6 +362,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''');
CreatePatchMetadata metadata,
) async => metadata.copyWith(
linkPercentage: lastBuildLinkPercentage,
linkMetadata: lastBuildLinkMetadata,
environment: metadata.environment.copyWith(
xcodeVersion: await xcodeBuild.version(),
),
@@ -233,6 +233,19 @@ class AotTools {
return result.stdout.toString().contains('dump-debug-info');
}
/// Dump json metadata from a link debug result.
// Added in Flutter 3.29.3
Future<Map<String, dynamic>> getLinkMetadata({
required String debugDir,
String? workingDirectory,
}) async {
final result = await _exec([
'link_metadata',
debugDir,
], workingDirectory: workingDirectory);
return jsonDecode(result.stdout.toString()) as Map<String, dynamic>;
}
/// Generate a link vmcode file from two AOT snapshots.
Future<double?> link({
required String base,
@@ -27,6 +27,7 @@ class CreatePatchMetadata extends Equatable {
required this.inferredReleaseVersion,
required this.environment,
this.linkPercentage,
this.linkMetadata,
});
// coverage:ignore-start
@@ -40,6 +41,7 @@ class CreatePatchMetadata extends Equatable {
bool hasNativeChanges = false,
bool inferredReleaseVersion = false,
double? linkPercentage,
Json? linkMetadata,
BuildEnvironmentMetadata? environment,
}) => CreatePatchMetadata(
releasePlatform: releasePlatform,
@@ -49,6 +51,7 @@ class CreatePatchMetadata extends Equatable {
hasNativeChanges: hasNativeChanges,
inferredReleaseVersion: inferredReleaseVersion,
linkPercentage: linkPercentage,
linkMetadata: linkMetadata,
environment: environment ?? BuildEnvironmentMetadata.forTest(),
);
// coverage:ignore-end
@@ -70,6 +73,7 @@ class CreatePatchMetadata extends Equatable {
bool? hasNativeChanges,
bool? inferredReleaseVersion,
double? linkPercentage,
Json? linkMetadata,
BuildEnvironmentMetadata? environment,
}) => CreatePatchMetadata(
releasePlatform: releasePlatform ?? this.releasePlatform,
@@ -82,6 +86,7 @@ class CreatePatchMetadata extends Equatable {
inferredReleaseVersion:
inferredReleaseVersion ?? this.inferredReleaseVersion,
linkPercentage: linkPercentage ?? this.linkPercentage,
linkMetadata: linkMetadata ?? this.linkMetadata,
environment: environment ?? this.environment,
);
@@ -124,6 +129,9 @@ class CreatePatchMetadata extends Equatable {
/// Note: link percentage is currently only available for iOS patches.
final double? linkPercentage;
/// Metadata from the linker, if available.
final Json? linkMetadata;
/// Properties about the environment in which the patch was created.
///
/// Reason: see [BuildEnvironmentMetadata].
@@ -137,6 +145,7 @@ class CreatePatchMetadata extends Equatable {
usedIgnoreNativeChangesFlag,
hasNativeChanges,
linkPercentage,
linkMetadata,
inferredReleaseVersion,
environment,
];
@@ -41,6 +41,10 @@ CreatePatchMetadata _$CreatePatchMetadataFromJson(
'link_percentage',
(v) => (v as num?)?.toDouble(),
),
linkMetadata: $checkedConvert(
'link_metadata',
(v) => v as Map<String, dynamic>?,
),
);
return val;
},
@@ -52,6 +56,7 @@ CreatePatchMetadata _$CreatePatchMetadataFromJson(
'hasNativeChanges': 'has_native_changes',
'inferredReleaseVersion': 'inferred_release_version',
'linkPercentage': 'link_percentage',
'linkMetadata': 'link_metadata',
},
);
@@ -65,6 +70,7 @@ Map<String, dynamic> _$CreatePatchMetadataToJson(
'has_native_changes': instance.hasNativeChanges,
'inferred_release_version': instance.inferredReleaseVersion,
'link_percentage': instance.linkPercentage,
'link_metadata': instance.linkMetadata,
'environment': instance.environment.toJson(),
};
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:io/io.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:scoped_deps/scoped_deps.dart';
@@ -26,7 +27,30 @@ enum ApplePlatform {
/// A record containing the exit code and optionally link percentage
/// returned by `runLinker`.
typedef LinkResult = ({int exitCode, double? linkPercentage});
@immutable
class LinkResult {
/// Creates a new [LinkResult] representing failure.
const LinkResult.failure()
: _exitCodeObject = ExitCode.software,
linkPercentage = null,
linkMetadata = null;
/// Creates a new [LinkResult] representing success.
const LinkResult.success({required this.linkPercentage, this.linkMetadata})
: _exitCodeObject = ExitCode.success;
/// ExitCode.code isn't const, so store the actual object.
final ExitCode _exitCodeObject;
/// The exit code of the linker process.
int get exitCode => _exitCodeObject.code;
/// The percentage of code that was linked in the patch.
final double? linkPercentage;
/// Metadata from the linker, if available.
final Map<String, dynamic>? linkMetadata;
}
/// {@template missing_xcode_project_exception}
/// Thrown when the Flutter project does not have iOS configured as a platform.
@@ -224,7 +248,7 @@ class Apple {
if (!patch.existsSync()) {
logger.err('Unable to find patch AOT file at ${patch.path}');
return (exitCode: ExitCode.software.code, linkPercentage: null);
return const LinkResult.failure();
}
final analyzeSnapshot = File(
@@ -235,7 +259,7 @@ class Apple {
if (!analyzeSnapshot.existsSync()) {
logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}');
return (exitCode: ExitCode.software.code, linkPercentage: null);
return const LinkResult.failure();
}
final genSnapshot = shorebirdArtifacts.getArtifactPath(
@@ -289,12 +313,27 @@ $error''');
);
} on Exception catch (error) {
linkProgress.fail('Failed to link AOT files: $error');
return (exitCode: ExitCode.software.code, linkPercentage: null);
return const LinkResult.failure();
} finally {
await dumpDebugInfo();
}
Map<String, dynamic>? linkMetadata;
try {
if (dumpDebugInfoDir != null) {
linkMetadata = await aotTools.getLinkMetadata(
debugDir: dumpDebugInfoDir.path,
workingDirectory: buildDirectory.path,
);
}
} on Exception catch (error) {
logger.detail('[aot_tools] Failed to get link metadata: $error');
}
linkProgress.complete();
return (exitCode: ExitCode.success.code, linkPercentage: linkPercentage);
return LinkResult.success(
linkPercentage: linkPercentage,
linkMetadata: linkMetadata,
);
}
/// Parses the .xcscheme file to determine if it was created for an app
@@ -661,10 +661,8 @@ void main() {
vmCodeFile: any(named: 'vmCodeFile'),
),
).thenAnswer(
(_) async => (
exitCode: ExitCode.success.code,
linkPercentage: linkPercentage,
),
(_) async =>
const LinkResult.success(linkPercentage: linkPercentage),
);
when(
aotTools.isGeneratePatchDiffBaseSupported,
@@ -859,6 +857,19 @@ void main() {
});
});
test('sets link percentage', () async {
expect(patcher.linkPercentage, isNull);
await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
supplementArtifact: supplementArtifactFile,
),
);
expect(patcher.linkPercentage, isNotNull);
});
group('when code signing the patch', () {
setUp(() {
final privateKey = File(
@@ -1043,9 +1054,12 @@ void main() {
group('when linker is enabled', () {
const linkPercentage = 100.0;
const linkMetadata = {'link': 'metadata'};
setUp(() {
patcher.lastBuildLinkPercentage = linkPercentage;
patcher
..lastBuildLinkPercentage = linkPercentage
..lastBuildLinkMetadata = linkMetadata;
});
test('returns correct metadata', () async {
@@ -1078,6 +1092,7 @@ void main() {
hasNativeChanges: false,
inferredReleaseVersion: false,
linkPercentage: linkPercentage,
linkMetadata: linkMetadata,
environment: BuildEnvironmentMetadata(
flutterRevision: flutterRevision,
operatingSystem: operatingSystem,
@@ -968,10 +968,8 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
vmCodeFile: any(named: 'vmCodeFile'),
),
).thenAnswer(
(_) async => (
exitCode: ExitCode.success.code,
linkPercentage: linkPercentage,
),
(_) async =>
const LinkResult.success(linkPercentage: linkPercentage),
);
when(
aotTools.isGeneratePatchDiffBaseSupported,
@@ -1168,6 +1166,19 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
),
);
});
test('sets link percentage', () async {
expect(patcher.linkPercentage, isNull);
await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
releaseArtifact: releaseArtifactFile,
supplementArtifact: supplementArtifactFile,
),
);
expect(patcher.linkPercentage, isNotNull);
});
});
group('when code signing the patch', () {
@@ -1499,9 +1510,12 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
group('when linker is enabled', () {
const linkPercentage = 100.0;
const linkMetadata = {'link': 'metadata'};
setUp(() {
patcher.lastBuildLinkPercentage = linkPercentage;
patcher
..lastBuildLinkPercentage = linkPercentage
..lastBuildLinkMetadata = linkMetadata;
});
test('returns correct metadata', () async {
@@ -1534,6 +1548,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}'''),
hasNativeChanges: false,
inferredReleaseVersion: false,
linkPercentage: linkPercentage,
linkMetadata: linkMetadata,
environment: BuildEnvironmentMetadata(
flutterRevision: flutterRevision,
operatingSystem: operatingSystem,
@@ -950,6 +950,76 @@ Run "aot_tools help <command>" for more information about a command.
expect(result.existsSync(), isTrue);
});
});
group('getLinkMetadata', () {
late int exitCode;
late String stdout;
late String stderr;
setUp(() {
stdout = '';
stderr = '';
exitCode = 0;
when(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) 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;
});
when(
() => process.start(
any(),
any(),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) 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;
});
});
test(
'returns link metadata when aot_tools executes successfully',
() async {
stdout = '{}';
final result = await runWithOverrides(
() => aotTools.getLinkMetadata(debugDir: '/debug'),
);
expect(result, isA<Map<String, dynamic>>());
},
);
test(
'throws FormatException when aot_tools outputs invalid json',
() async {
stdout = 'invalid';
await expectLater(
() => runWithOverrides(
() => aotTools.getLinkMetadata(debugDir: '/debug'),
),
throwsFormatException,
);
},
);
});
});
});
}
@@ -56,6 +56,13 @@ void main() {
when(() => logger.progress(any())).thenReturn(progress);
when(() => platform.environment).thenReturn({});
when(
() => aotTools.getLinkMetadata(
debugDir: any(named: 'debugDir'),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async => {'key': 'value'});
});
group(MissingXcodeProjectException, () {
@@ -632,6 +639,39 @@ To add macOS, run "flutter create . --platforms macos"''');
expect(result.linkPercentage, equals(linkPercentage));
});
});
group('when call to aotTools.getLinkMetadata fails', () {
setUp(() {
when(
() => aotTools.isLinkDebugInfoSupported(),
).thenAnswer((_) async => true);
when(
() => aotTools.getLinkMetadata(
debugDir: any(named: 'debugDir'),
workingDirectory: any(named: 'workingDirectory'),
),
).thenThrow(Exception('oops'));
});
test('logs error and exits with code 70', () async {
await runWithOverrides(
() => apple.runLinker(
aotOutputFile: aotOutputFile,
kernelFile: File('missing'),
releaseArtifact: File('missing'),
vmCodeFile: File('missing'),
splitDebugInfoArgs: [],
),
);
verify(
() => logger.detail(
'[aot_tools] Failed to get link metadata: Exception: oops',
),
).called(1);
verify(() => progress.complete()).called(1);
});
});
});
});
}