Files
plezy/test/startup_bootstrap_test.dart
T
edde746 7f0cad339c fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
2026-07-31 21:45:32 +02:00

441 lines
15 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/database/tvos_database_recovery_store.dart';
import 'package:plezy/main.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/prefs_recovery.dart';
import 'package:plezy/services/startup_diagnostics.dart';
import 'package:plezy/widgets/startup_failure_view.dart';
import 'test_helpers/download_fixtures.dart';
import 'test_helpers/prefs.dart';
final class _OpenTrackingInterceptor extends QueryInterceptor {
_OpenTrackingInterceptor({this.failure, this.updateFailure});
final Object? failure;
final Object? updateFailure;
var ensureOpenCalls = 0;
var ensureOpenCompleted = false;
var closed = false;
@override
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
ensureOpenCalls++;
final failure = this.failure;
if (failure != null) throw failure;
final result = await executor.ensureOpen(user);
ensureOpenCompleted = true;
return result;
}
@override
Future<int> runUpdate(QueryExecutor executor, String statement, List<Object?> args) {
final updateFailure = this.updateFailure;
if (updateFailure != null) throw updateFailure;
return executor.runUpdate(statement, args);
}
@override
Future<void> close(QueryExecutor inner) async {
await inner.close();
closed = true;
}
}
void main() {
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
final completion = Completer<int>();
var bootstrapWasMounted = false;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () {
bootstrapWasMounted = find.byKey(startupBootstrapProgressKey).evaluate().isNotEmpty;
return completion.future;
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
),
);
expect(bootstrapWasMounted, isTrue);
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
completion.complete(1);
await tester.pump();
});
testWidgets('replaces bootstrap UI with the initialized app on success', (tester) async {
final completion = Completer<int>();
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () => completion.future,
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
),
);
completion.complete(7);
await tester.pump();
expect(find.text('ready 7'), findsOneWidget);
expect(find.byKey(startupBootstrapProgressKey), findsNothing);
});
testWidgets('names the failing phase instead of showing a bare error', (tester) async {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw const StartupPhaseException(StartupPhase.database, FormatException('boom')),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (_, _, _) async {},
),
);
await tester.pump();
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
expect(find.byKey(startupBootstrapRetryKey), findsOneWidget);
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
// The phase and concrete type are what turn "Error" into a report.
expect(find.textContaining('database'), findsOneWidget);
expect(find.textContaining('FormatException'), findsOneWidget);
});
testWidgets('expands the full detail block on request', (tester) async {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw StateError('database unavailable'),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (_, _, _) async {},
),
);
await tester.pump();
expect(find.byKey(startupFailureDetailsKey), findsNothing);
await tester.tap(find.text('Show details'));
await tester.pump();
expect(find.byKey(startupFailureDetailsKey), findsOneWidget);
expect(find.textContaining('database unavailable'), findsWidgets);
});
testWidgets('offers a repair only for a repairable failure', (tester) async {
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw StateError('unrelated'),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (_, _, _) async {},
),
);
await tester.pump();
expect(find.byKey(startupFailureRepairKey), findsNothing);
await tester.pumpWidget(
StartupBootstrap<int>(
key: const Key('repairable'),
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (_, _, _) async {},
),
);
await tester.pump();
expect(find.byKey(startupFailureRepairKey), findsOneWidget);
});
testWidgets('re-runs initialization after a successful repair', (tester) async {
var attempts = 0;
var repairCalls = 0;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async {
attempts++;
if (attempts == 1) {
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
}
return 7;
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
reportFailure: (_, _, _) async {},
repair: (_, _, _) async {
repairCalls++;
return true;
},
),
);
await tester.pump();
await tester.tap(find.byKey(startupFailureRepairKey));
await tester.pump();
await tester.pump();
expect(repairCalls, 1);
expect(attempts, 2);
expect(find.text('ready 7'), findsOneWidget);
});
testWidgets('a repair that reports no change does not retry', (tester) async {
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')),
reportFailure: (_, _, _) async {},
repair: (_, _, _) async => false,
),
);
await tester.pump();
await tester.tap(find.byKey(startupFailureRepairKey));
await tester.pump();
await tester.pump();
expect(attempts, 1);
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
});
testWidgets('reports the failure to the crash reporter', (tester) async {
StartupFailureRecord? reported;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () async => throw const StartupPhaseException(StartupPhase.storage, 'nope'),
buildApp: (_, value) => Text('ready $value'),
reportFailure: (record, _, _) async => reported = record,
),
);
await tester.pump();
// The gate catches the error, so nothing else would ever see it.
expect(reported?.phase, StartupPhase.storage);
});
testWidgets('retry clears the failed generation and can commit a later success', (tester) async {
final retryCompletion = Completer<int>();
var attempts = 0;
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () {
attempts++;
if (attempts == 1) return Future<int>.error(StateError('first attempt'));
return retryCompletion.future;
},
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
),
);
await tester.pump();
await tester.tap(find.byKey(startupBootstrapRetryKey));
await tester.pump();
expect(attempts, 2);
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
retryCompletion.complete(42);
await tester.pump();
expect(find.text('ready 42'), findsOneWidget);
expect(find.byKey(startupBootstrapFailureKey), findsNothing);
});
testWidgets('discards a completion from a disposed bootstrap generation', (tester) async {
final completion = Completer<int>();
final discarded = <int>[];
await tester.pumpWidget(
StartupBootstrap<int>(
initialize: () => completion.future,
buildApp: (_, value) => Text('ready $value'),
discard: discarded.add,
),
);
await tester.pumpWidget(const SizedBox.shrink());
completion.complete(9);
await tester.pump();
expect(discarded, [9]);
expect(find.text('ready 9'), findsNothing);
});
test('storage-full lazy database open discards native work before retrying', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_storage_full_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final failedOpen = _OpenTrackingInterceptor(
failure: const FileSystemException('write failed: No space left on device'),
);
final successfulOpen = _OpenTrackingInterceptor();
AppDatabase? seeded;
AppDatabase? resultDatabase;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'active',
globalKey: 'srv:active',
type: 'movie',
status: DownloadStatus.downloading.index,
);
await seeded.updateBgTaskId('srv:active', 'native-task');
await seeded.addToQueue(mediaGlobalKey: 'srv:active');
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'complete',
globalKey: 'srv:complete',
type: 'movie',
status: DownloadStatus.completed.index,
);
await seeded.close();
seeded = null;
var openAttempts = 0;
var recoveries = 0;
final bootstrap = await openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
final interceptor = openAttempts == 1 ? failedOpen : successfulOpen;
return NativeDatabase(databaseFile).interceptWith(interceptor);
},
);
},
recoverNativeDownloads: () async {
expect(failedOpen.closed, isTrue);
recoveries++;
},
storageFullMessage: 'Storage full',
);
resultDatabase = bootstrap.database;
final active = await resultDatabase.getDownloadedMedia('srv:active');
final complete = await resultDatabase.getDownloadedMedia('srv:complete');
expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable);
expect(openAttempts, 2);
expect(recoveries, 1);
expect(failedOpen.ensureOpenCalls, 1);
expect(successfulOpen.ensureOpenCalls, greaterThanOrEqualTo(1));
expect(successfulOpen.ensureOpenCompleted, isTrue);
expect(active?.status, DownloadStatus.failed.index);
expect(active?.bgTaskId, isNull);
expect(active?.errorMessage, 'Storage full');
expect(complete?.status, DownloadStatus.completed.index);
expect(await resultDatabase.select(resultDatabase.downloadQueue).get(), isEmpty);
expect(await resultDatabase.customSelect('SELECT 1').get(), isNotEmpty);
} finally {
await resultDatabase?.close();
await seeded?.close();
await tempDir.delete(recursive: true);
}
});
test('post-recovery download failure closes the reopened database before rethrowing', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_recovery_update_failure_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final failedOpen = _OpenTrackingInterceptor(
failure: const FileSystemException('write failed: No space left on device'),
);
final updateError = StateError('injected post-recovery update failure');
final reopened = _OpenTrackingInterceptor(updateFailure: updateError);
AppDatabase? seeded;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'active',
globalKey: 'srv:active',
type: 'movie',
status: DownloadStatus.downloading.index,
);
await seeded.close();
seeded = null;
var openAttempts = 0;
final open = openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
final interceptor = openAttempts == 1 ? failedOpen : reopened;
return NativeDatabase(databaseFile).interceptWith(interceptor);
},
);
},
recoverNativeDownloads: () async {},
storageFullMessage: 'Storage full',
);
await expectLater(open, throwsA(same(updateError)));
expect(openAttempts, 2);
expect(reopened.closed, isTrue);
} finally {
await seeded?.close();
await tempDir.delete(recursive: true);
}
});
test('non-storage lazy database-open errors bypass download recovery', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_open_error_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final error = StateError('injected database setup failure');
final failedOpen = _OpenTrackingInterceptor(failure: error);
var openAttempts = 0;
var recoveries = 0;
try {
final open = openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
return NativeDatabase(databaseFile).interceptWith(failedOpen);
},
);
},
recoverNativeDownloads: () async {
recoveries++;
},
storageFullMessage: 'Storage full',
);
await expectLater(open, throwsA(same(error)));
expect(failedOpen.ensureOpenCalls, 1);
expect(failedOpen.closed, isTrue);
expect(openAttempts, 1);
expect(recoveries, 0);
} finally {
await tempDir.delete(recursive: true);
}
});
}