fix(startup): stop offering Retry after a repair that needs a restart

A seed-and-restart repair writes the salvaged credentials straight to disk and
leaves this process's store closed, because the plugin still holds the bad
document in memory. repairCorruptStore says so plainly — "nothing may write a
preference before that restart … the caller keeps the app on the failure
screen precisely so nothing does" — but the caller did not. Clearing the
repairing flag re-enabled Retry, and pressing it reopened onto the stale map,
whose first write would flush it back over the seed and orphan every
ciphertext token in the database.

The repair hook returned a bare bool, which cannot express the difference
between "retry now" and "never retry in this process", so replace it with
StartupRepairResult. The restart case latches terminal state on the bootstrap,
withdraws Retry and Repair rather than grey them out — a disabled control
still invites another press — and says what to do instead, which nothing did:
repairNeedsRestart was a dialog title with no body anywhere. Desktop gets a
Quit button through the existing AppExitService seam; Copy and Upload stay
live everywhere, because a stuck user still needs the diagnostic out.

close #1732
This commit is contained in:
edde746
2026-08-01 06:59:20 +02:00
parent 3ae7aa554b
commit 9ecf8db90f
8 changed files with 304 additions and 36 deletions
+53 -2
View File
@@ -163,7 +163,7 @@ void main() {
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
repair: (_, _, _) async {
repairCalls++;
return true;
return StartupRepairResult.retry;
},
),
);
@@ -188,7 +188,7 @@ void main() {
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
repair: (_, _, _) async => false,
repair: (_, _, _) async => StartupRepairResult.none,
),
);
await tester.pump();
@@ -201,6 +201,57 @@ void main() {
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
});
testWidgets('a repair that needs a restart parks the app instead of retrying', (tester) async {
// The repair seeds the salvaged credentials straight to disk and leaves
// this process's store closed, because the plugin still holds the bad
// document. Re-running the gate would reopen onto that stale map and the
// first write would flush it over the seed, destroying the vault key and
// orphaning every ciphertext token in the database (#1732).
var attempts = 0;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async {
attempts++;
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
repair: (_, _, _) async => StartupRepairResult.restart,
),
);
await tester.pump();
await tester.tap(find.byKey(startupFailureRepairKey));
await tester.pump();
await tester.pump();
expect(attempts, 1);
// Withdrawn, not disabled: both are the actions that would touch the store.
expect(find.byKey(startupBootstrapRetryKey), findsNothing);
expect(find.byKey(startupFailureRepairKey), findsNothing);
// And the user is actually told what to do about it.
expect(find.byKey(startupFailureRestartKey), findsOneWidget);
});
testWidgets('the restart state survives and still allows uploading the diagnostic', (tester) async {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
repair: (_, _, _) async => StartupRepairResult.restart,
),
);
await tester.pump();
await tester.tap(find.byKey(startupFailureRepairKey));
await tester.pump();
await tester.pump();
// Copy and Upload stay live — the whole point of the failure screen is
// that a stuck user can still get the diagnostic out.
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
expect(find.byKey(startupFailureUploadKey), findsOneWidget);
});
testWidgets('persists the failure so it can be reported once the reporter is up', (tester) async {
// Not reported inline: the earliest gate phases run before crash
// reporting exists, so an inline capture would reach a no-op hub and be
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/services/app_exit_service.dart';
import 'package:plezy/services/startup_diagnostics.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/startup_failure_view.dart';
StartupFailureRecord _record() => StartupFailureRecord(
phase: StartupPhase.preferences,
errorType: 'CorruptPreferenceStoreException',
message: 'the preference store could not be parsed',
stackTrace: null,
timestamp: DateTime.utc(2026),
appVersion: '2.11.1',
platform: 'windows',
repairable: true,
);
Future<void> _pumpView(
WidgetTester tester, {
required bool restartRequired,
Future<bool> Function()? onExitRequested,
}) async {
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
home: Scaffold(
body: StartupFailureView(
failure: _record(),
restartRequired: restartRequired,
onRetry: () {},
onRepair: () async {},
requestExit: ({AppExitApplication? exitApplicationForTesting}) async =>
onExitRequested == null ? false : await onExitRequested(),
),
),
),
),
);
await tester.pump();
}
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
tearDown(() => PlatformDetector.debugSetIsDesktopOSOverride(null));
testWidgets('the ordinary failure offers retry and repair', (tester) async {
await _pumpView(tester, restartRequired: false);
expect(find.byKey(startupBootstrapRetryKey), findsOneWidget);
expect(find.byKey(startupFailureRepairKey), findsOneWidget);
expect(find.byKey(startupFailureRestartKey), findsNothing);
});
testWidgets('a pending restart withdraws every action that would touch the store', (tester) async {
await _pumpView(tester, restartRequired: true);
// Withdrawn rather than disabled: a greyed-out Retry still invites another
// press, and pressing it would flush the plugin's stale map over the
// freshly seeded credentials (#1732).
expect(find.byKey(startupBootstrapRetryKey), findsNothing);
expect(find.byKey(startupFailureRepairKey), findsNothing);
// The diagnostic actions stay, because a stuck user still needs them out.
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
expect(find.byKey(startupFailureUploadKey), findsOneWidget);
expect(find.text(t.startup.restartRequiredBody), findsOneWidget);
});
testWidgets('the desktop quit button asks the platform to exit', (tester) async {
PlatformDetector.debugSetIsDesktopOSOverride(true);
var exitRequests = 0;
await _pumpView(
tester,
restartRequired: true,
onExitRequested: () async {
exitRequests++;
return true;
},
);
expect(find.byKey(startupFailureQuitKey), findsOneWidget);
await tester.tap(find.byKey(startupFailureQuitKey));
await tester.pump();
expect(exitRequests, 1);
});
testWidgets('a platform that refuses to quit does not break the screen', (tester) async {
PlatformDetector.debugSetIsDesktopOSOverride(true);
await _pumpView(tester, restartRequired: true, onExitRequested: () async => throw StateError('no'));
await tester.tap(find.byKey(startupFailureQuitKey));
await tester.pump();
// The instruction is still on screen; the window controls remain the
// user's fallback.
expect(find.text(t.startup.restartRequiredBody), findsOneWidget);
});
testWidgets('a non-desktop host gets the instruction without a quit button', (tester) async {
PlatformDetector.debugSetIsDesktopOSOverride(false);
await _pumpView(tester, restartRequired: true);
expect(find.byKey(startupFailureQuitKey), findsNothing);
expect(find.text(t.startup.restartRequiredBody), findsOneWidget);
});
}