fix(shorebird_cli): surface flutter precache failures instead of swallowing them (#3789)

This commit is contained in:
Eric Seidel
2026-05-20 04:21:58 -07:00
committed by GitHub
parent c295240bfc
commit 1885b63987
3 changed files with 91 additions and 48 deletions
@@ -12,19 +12,22 @@ import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// Exception thrown when a required file in the Shorebird cache is missing or
/// unreadable, indicating a corrupted installation.
/// Exception thrown when the Shorebird cache appears to be corrupted.
///
/// Surfaces a user-actionable message directing the user to run
/// `shorebird cache clean` and retry.
class CacheCorruptedException implements Exception {
/// Creates a [CacheCorruptedException] for the given [filePath].
const CacheCorruptedException(this.filePath);
/// Creates a [CacheCorruptedException] explaining why the cache is
/// considered corrupted via [reason] (a complete sentence).
const CacheCorruptedException(this.reason);
/// The path to the missing or unreadable file.
final String filePath;
/// Human-readable explanation of why the cache is considered corrupted.
final String reason;
@override
String toString() =>
'Could not read $filePath. Your Shorebird installation may be '
"corrupted. Try running 'shorebird cache clean' and retrying.";
'$reason Your Shorebird installation may be corrupted. '
"Try running 'shorebird cache clean' and retrying.";
}
/// A reference to a [ShorebirdEnv] instance.
@@ -78,7 +81,7 @@ class ShorebirdEnv {
try {
return file.readAsStringSync().trim();
} on FileSystemException {
throw CacheCorruptedException(file.path);
throw CacheCorruptedException('Could not read ${file.path}.');
}
}
@@ -91,7 +94,7 @@ class ShorebirdEnv {
try {
return file.readAsStringSync().trim();
} on FileSystemException {
throw CacheCorruptedException(file.path);
throw CacheCorruptedException('Could not read ${file.path}.');
}
}
@@ -43,6 +43,13 @@ class ShorebirdFlutter {
}
/// Install the provided Flutter [revision].
///
/// Runs `flutter precache` on first install as a convenience so the first
/// build is not unexpectedly slow. A precache failure is treated as a
/// corrupted install: Flutter's stamp-based cache will otherwise trust a
/// partial extraction and surface the missing artifact later as an opaque
/// Gradle error (see shorebirdtech/shorebird#3783). The user is directed
/// to run `shorebird cache clean` to start over.
Future<void> installRevision({required String revision}) async {
final targetDirectory = Directory(_workingDirectory(revision: revision));
if (targetDirectory.existsSync()) return;
@@ -65,9 +72,8 @@ class ShorebirdFlutter {
await git.checkout(directory: targetDirectory.path, revision: revision);
installProgress.complete();
} catch (error) {
installProgress.fail(
'Failed to install Flutter $version (${shortRevisionString(revision)})',
);
final short = shortRevisionString(revision);
installProgress.fail('Failed to install Flutter $version ($short)');
logger.err('$error');
rethrow;
}
@@ -76,18 +82,28 @@ class ShorebirdFlutter {
'Running ${lightCyan.wrap('flutter precache')}',
);
final precacheArguments = ['precache', ...precacheArgs];
final ShorebirdProcessResult result;
try {
await process.run(executable, [
'precache',
...precacheArgs,
], workingDirectory: targetDirectory.path);
precacheProgress.complete();
} on Exception {
result = await process.run(
executable,
precacheArguments,
workingDirectory: targetDirectory.path,
);
} on Exception catch (error) {
precacheProgress.fail('Failed to precache Flutter $version');
logger.info(
'''This is not a critical error, but your next build make take longer than usual.''',
throw CacheCorruptedException(
'Failed to precache Flutter $version: $error.',
);
}
if (result.exitCode != ExitCode.success.code) {
precacheProgress.fail('Failed to precache Flutter $version');
final stderr = '${result.stderr}'.trim();
throw CacheCorruptedException(
'flutter precache exited with code ${result.exitCode}: $stderr.',
);
}
precacheProgress.complete();
}
/// Whether the current revision is unmodified.
@@ -110,7 +110,10 @@ void main() {
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async => precacheProcessResult);
when(() => versionProcessResult.exitCode).thenReturn(0);
when(
() => precacheProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(() => precacheProcessResult.stderr).thenReturn('');
});
group('precacheArgs', () {
@@ -978,7 +981,7 @@ origin/flutter_release/3.10.6''';
).called(1);
});
group('when unable to precache', () {
group('when precache throws', () {
setUp(() {
when(
() => process.run(
@@ -989,33 +992,54 @@ origin/flutter_release/3.10.6''';
).thenThrow(Exception('oh no!'));
});
test('logs error and continues', () async {
await expectLater(
runWithOverrides(
() => shorebirdFlutter.installRevision(revision: revision),
),
completes,
);
verify(
() => process.run(
'flutter',
[
'precache',
...runWithOverrides(() => shorebirdFlutter.precacheArgs),
],
workingDirectory: p.join(flutterDirectory.parent.path, revision),
),
).called(1);
test(
'throws CacheCorruptedException directing to shorebird cache clean',
() async {
await expectLater(
runWithOverrides(
() => shorebirdFlutter.installRevision(revision: revision),
),
throwsA(
isA<CacheCorruptedException>().having(
(e) => e.toString(),
'toString',
contains('shorebird cache clean'),
),
),
);
verify(
() => progress.fail('Failed to precache Flutter 3.10.6'),
).called(1);
},
);
});
verify(
() => progress.fail('Failed to precache Flutter 3.10.6'),
).called(1);
verify(
() => logger.info(
'''This is not a critical error, but your next build make take longer than usual.''',
),
).called(1);
group('when precache exits with a non-zero code', () {
setUp(() {
when(() => precacheProcessResult.exitCode).thenReturn(1);
when(() => precacheProcessResult.stderr).thenReturn('boom');
});
test(
'throws CacheCorruptedException directing to shorebird cache clean',
() async {
await expectLater(
runWithOverrides(
() => shorebirdFlutter.installRevision(revision: revision),
),
throwsA(
isA<CacheCorruptedException>().having(
(e) => e.toString(),
'toString',
contains('shorebird cache clean'),
),
),
);
verify(
() => progress.fail('Failed to precache Flutter 3.10.6'),
).called(1);
},
);
});
group('when clone and checkout succeed', () {