From 54d63a05a875a534cbead527928b47b530ed4e8b Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 31 Mar 2026 17:13:25 -0700 Subject: [PATCH] feat: include snapshots in patch debug dump (#3672) --- .../lib/src/platform/apple/apple.dart | 24 ++++ .../test/src/platform/apple/apple_test.dart | 111 ++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/packages/shorebird_cli/lib/src/platform/apple/apple.dart b/packages/shorebird_cli/lib/src/platform/apple/apple.dart index d4779425..48de6c98 100644 --- a/packages/shorebird_cli/lib/src/platform/apple/apple.dart +++ b/packages/shorebird_cli/lib/src/platform/apple/apple.dart @@ -179,6 +179,30 @@ class Apple { Future dumpDebugInfo() async { if (dumpDebugInfoDir == null) return; + // Copy snapshots into the debug dump for offline diagnosis. + // 1 release snapshot + 3 patch compilation stages. + final snapshotsDir = Directory(p.join(dumpDebugInfoDir.path, 'snapshots')) + ..createSync(recursive: true); + void maybeCopySnapshot(File file, {String? destName}) { + if (file.existsSync()) { + file.copySync( + p.join(snapshotsDir.path, destName ?? p.basename(file.path)), + ); + } + } + + maybeCopySnapshot(releaseArtifact, destName: 'App'); + maybeCopySnapshot(aotOutputFile); + // Intermediate snapshots created by aot_tools during linking. + final patchDir = aotOutputFile.parent.path; + final patchBaseName = p.basenameWithoutExtension(aotOutputFile.path); + maybeCopySnapshot( + File(p.join(patchDir, '$patchBaseName.ct.aot')), + ); + maybeCopySnapshot( + File(p.join(patchDir, '$patchBaseName.optimized.aot')), + ); + final debugInfoZip = await dumpDebugInfoDir.zipToTempFile(); debugInfoZip.copySync(p.join('build', Patcher.debugInfoFile.path)); logger.detail('Link debug info saved to ${Patcher.debugInfoFile.path}'); diff --git a/packages/shorebird_cli/test/src/platform/apple/apple_test.dart b/packages/shorebird_cli/test/src/platform/apple/apple_test.dart index d163ae53..22ff4a78 100644 --- a/packages/shorebird_cli/test/src/platform/apple/apple_test.dart +++ b/packages/shorebird_cli/test/src/platform/apple/apple_test.dart @@ -579,6 +579,117 @@ Otherwise, to repair macos, run "flutter create . --platforms macos"''', ).called(1); }); + test('copies existing snapshots into debug dump', () async { + // Create a release artifact in a temp directory. + final releaseDir = Directory.systemTemp.createTempSync(); + final releaseArtifactFile = File(p.join(releaseDir.path, 'App')) + ..writeAsStringSync('release'); + + // Create intermediate patch snapshots alongside out.aot. + File(p.join(buildDirectory.path, 'out.ct.aot')) + ..writeAsStringSync('ct'); + File(p.join(buildDirectory.path, 'out.optimized.aot')) + ..writeAsStringSync('optimized'); + + // Capture the dumpDebugInfoPath so we can inspect it. + String? capturedDebugInfoPath; + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + additionalArgs: any(named: 'additionalArgs'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + ), + ).thenAnswer((invocation) async { + capturedDebugInfoPath = + invocation.namedArguments[#dumpDebugInfoPath] as String?; + return linkPercentage; + }); + + await runWithOverrides( + () => apple.runLinker( + aotOutputFile: aotOutputFile, + kernelFile: File('missing'), + releaseArtifact: releaseArtifactFile, + vmCodeFile: File('missing'), + splitDebugInfoArgs: [], + ), + ); + + expect(capturedDebugInfoPath, isNotNull); + final snapshotsDir = Directory( + p.join(capturedDebugInfoPath!, 'snapshots'), + ); + expect(snapshotsDir.existsSync(), isTrue); + + final snapshotFiles = snapshotsDir + .listSync() + .whereType() + .map((f) => p.basename(f.path)) + .toSet(); + expect( + snapshotFiles, + containsAll([ + 'App', + 'out.aot', + 'out.ct.aot', + 'out.optimized.aot', + ]), + ); + }); + + test('skips missing intermediate snapshots', () async { + // Release artifact exists but intermediate snapshots do not. + final releaseDir = Directory.systemTemp.createTempSync(); + final releaseArtifactFile = File(p.join(releaseDir.path, 'App')) + ..writeAsStringSync('release'); + + String? capturedDebugInfoPath; + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + outputPath: any(named: 'outputPath'), + workingDirectory: any(named: 'workingDirectory'), + additionalArgs: any(named: 'additionalArgs'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + ), + ).thenAnswer((invocation) async { + capturedDebugInfoPath = + invocation.namedArguments[#dumpDebugInfoPath] as String?; + return linkPercentage; + }); + + await runWithOverrides( + () => apple.runLinker( + aotOutputFile: aotOutputFile, + kernelFile: File('missing'), + releaseArtifact: releaseArtifactFile, + vmCodeFile: File('missing'), + splitDebugInfoArgs: [], + ), + ); + + final snapshotsDir = Directory( + p.join(capturedDebugInfoPath!, 'snapshots'), + ); + final snapshotFiles = snapshotsDir + .listSync() + .whereType() + .map((f) => p.basename(f.path)) + .toSet(); + // Only release + patch should be present (no ct/optimized). + expect(snapshotFiles, equals({'App', 'out.aot'})); + }); + group('when running in codemagic', () { late Directory codemagicExportDir;