Files
plezy/lib/services/startup_diagnostics.dart
T
edde746 957711a650 fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of
10336 bytes, every one of them zero, and reported each as "still failing". The
gate classified it correctly every time and the consented repair would have
cleared it in-process, but nothing on the failure screen said so: Retry was
first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside
it as a tonal afterthought. Retry re-reads the same document, so for a
corrupt-store failure it is an action that cannot succeed however many times it
is pressed — and it was the one the screen recommended.

Repair now takes the primary styling, the focus node and first position whenever
it is offered, and the body text says plainly that retrying will not help.
Retry keeps its place for every other failure, where a locked database or a
denied directory really can change between attempts.

The consent dialog was also promising an outcome it could not always deliver.
Servers and profiles survive a repair only because their tokens are ciphertext
in the database and the key that decrypts them lives in the store, so a store
the key cannot be read out of signs the user out of everything — exactly the
all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without
touching it, and the dialog now names the real cost from that. The retained copy is
labelled as holding credentials unless the bytes prove otherwise: what the
salvage recovered says nothing about what the file still contains, because a
store truncated mid-value keeps most of a vault key in plaintext while the
salvage pattern — which needs the value's closing quote — matches nothing at
all. Only an all-zero file drops the warning, so the one case that cries wolf
is the one that provably holds no secret.

`describe()` finally carries whether a repair was on offer. That line is the
difference between a report a maintainer can act on and two days of guessing
whether the button was even on screen.

close #1732
2026-08-02 04:37:29 +02:00

503 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
/// What the consented storage repair leaves the startup gate able to do.
///
/// A bare "did it run" boolean cannot express the restart case, and getting it
/// wrong is destructive: after a seed-and-restart repair the plugin still holds
/// the bad document in memory, so re-running initialization would flush that
/// stale map back over the freshly seeded store and orphan every ciphertext
/// token in the database (#1732).
enum StartupRepairResult {
/// Nothing was repaired — the user declined, or there was nothing to do.
none,
/// The store was repaired and initialization can be retried in this process.
retry,
/// The store was repaired but the process must restart before it is usable.
/// Initialization must not run again, and nothing may write a preference.
restart,
}
/// Named steps of the startup gate.
///
/// The gate used to report a bare `error.runtimeType` with no indication of
/// which step failed, and `Future.wait` discarded every error but the first,
/// so even that was ambiguous between four concurrent steps (#1732). Every
/// step now carries a stable identifier that reaches the failure screen, the
/// log, the persisted record and Sentry.
enum StartupPhase {
preferences('preferences'),
crashReporting('crash-reporting'),
locale('locale'),
windowManager('window-manager'),
deviceCapabilities('device-capabilities'),
storage('storage'),
database('database'),
imageCache('image-cache'),
downloadStorage('download-storage');
const StartupPhase(this.id);
/// Stable wire/log identifier. Do not rename: persisted records and Sentry
/// tags are matched on it.
final String id;
static StartupPhase? fromId(String? id) =>
id == null ? null : StartupPhase.values.where((phase) => phase.id == id).firstOrNull;
}
/// Tags a startup failure with the gate phase it came from.
///
/// Transparent by design: [cause] is the original error, so existing
/// classification (`isStorageFullError`, corrupt-store detection) keeps working
/// on `exception.cause` and the reported runtime type stays the real one.
class StartupPhaseException implements Exception {
const StartupPhaseException(this.phase, this.cause);
final StartupPhase phase;
final Object cause;
/// Unwraps nested wrappers so callers always classify the real error.
static Object unwrap(Object error) {
var current = error;
while (current is StartupPhaseException) {
current = current.cause;
}
return current;
}
static StartupPhase? phaseOf(Object error) => error is StartupPhaseException ? error.phase : null;
@override
String toString() => 'StartupPhaseException(${phase.id}): ${StartupFailureRecord.describeErrorSafely(cause)}';
}
/// A startup-gate failure, reduced to an allowlist of fields that are safe to
/// show, copy, persist and upload.
///
/// Redaction is defence in depth here, not the mechanism: nothing derived from
/// preference contents, database rows or file bytes is ever placed in a
/// record. That matters because `LogRedactionManager`'s registered-value set is
/// seeded by `StorageService.onInit`, which runs *inside* the gate — a failure
/// at or before that step leaves only the pattern matcher active.
String _newRecordId() =>
'${DateTime.now().microsecondsSinceEpoch.toRadixString(16)}-'
'${Random().nextInt(0xFFFFFF).toRadixString(16)}';
class StartupFailureRecord {
StartupFailureRecord({
required this.phase,
required this.errorType,
required String message,
required String? stackTrace,
required this.timestamp,
required this.appVersion,
required this.platform,
this.repairable = false,
this.reported = false,
String? id,
}) : id = id ?? _newRecordId(),
message = LogRedactionManager.redact(message),
stackTrace = stackTrace == null ? null : LogRedactionManager.redact(stackTrace);
/// Builds a record from a thrown [error].
///
/// [StartupPhaseException] wrappers are unwrapped so the recorded type and
/// message describe the real failure, and [phase] defaults to the one the
/// wrapper carries.
factory StartupFailureRecord.fromError({
required Object error,
required StackTrace? stackTrace,
required String appVersion,
required String platform,
StartupPhase? phase,
bool repairable = false,
DateTime? timestamp,
}) {
final cause = StartupPhaseException.unwrap(error);
return StartupFailureRecord(
phase: phase ?? StartupPhaseException.phaseOf(error),
errorType: cause.runtimeType.toString(),
message: describeErrorSafely(cause),
stackTrace: stackTrace?.toString(),
timestamp: timestamp ?? DateTime.now(),
appVersion: appVersion,
platform: platform,
repairable: repairable,
);
}
/// Renders [error] without the payload some exception types embed.
///
/// `FormatException.toString()` prints an excerpt of `source` around
/// `offset`, and during startup that source is very often a document we must
/// never surface: the preference store holds the credential-vault key,
/// tracker refresh tokens and Seerr cookies in plaintext. Field-pattern
/// redaction cannot be relied on here because the registered-value set is
/// seeded inside the gate that just failed. Keep the parser's own message
/// and offset, drop the excerpt.
@visibleForTesting
static String describeErrorSafely(Object error) {
final cause = StartupPhaseException.unwrap(error);
if (cause is! FormatException) return cause.toString();
final offset = cause.offset;
final message = cause.message.isEmpty ? 'FormatException' : cause.message;
return offset == null ? message : '$message (at offset $offset)';
}
/// Distinguishes this record from any other, including one written moments
/// later by a retry that also failed.
///
/// A slow flush can still be sending record A when a retry writes record B;
/// without an identity, marking A reported would overwrite B on disk and
/// lose the newer failure entirely.
final String id;
final StartupPhase? phase;
final String errorType;
/// Already redacted by the constructor.
final String message;
/// Already redacted by the constructor.
final String? stackTrace;
final DateTime timestamp;
final String appVersion;
final String platform;
/// Whether the gate can offer an in-app repair for this failure.
final bool repairable;
/// Whether this record has already reached the crash reporter.
///
/// The earliest gate phases run before crash reporting is initialised, so a
/// failure there is captured by a no-op hub and silently discarded. Records
/// are therefore always persisted first and sent once the reporter is up —
/// on the in-app retry, or on the next launch (#1732).
final bool reported;
StartupFailureRecord copyWith({bool? reported}) => StartupFailureRecord(
id: id,
phase: phase,
errorType: errorType,
message: message,
stackTrace: stackTrace,
timestamp: timestamp,
appVersion: appVersion,
platform: platform,
repairable: repairable,
reported: reported ?? this.reported,
);
String get phaseId => phase?.id ?? 'unknown';
/// One-line summary for the failure screen and the log.
String get headline => '[$phaseId] $errorType: $message';
/// Full plain-text block for the clipboard and the diagnostics upload.
///
/// Includes [repairable] because the uploaded text is usually all a
/// maintainer gets. Without it a report of "still broken" cannot be told
/// apart from "the screen offered no way forward" and "a repair was offered
/// and not taken" — the ambiguity that stalled #1732 for two days.
String describe() {
final buffer = StringBuffer()
..writeln('Plezy startup failure')
..writeln('Version: $appVersion')
..writeln('Platform: $platform')
..writeln('When: ${timestamp.toUtc().toIso8601String()}')
..writeln('Phase: $phaseId')
..writeln('Error: $errorType')
..writeln('Repair offered: ${repairable ? 'yes' : 'no'}')
..writeln('Message: $message');
final stack = stackTrace;
if (stack != null && stack.isNotEmpty) {
buffer
..writeln('Stack trace:')
..writeln(stack);
}
return buffer.toString().trimRight();
}
Map<String, Object?> toJson() => {
'id': id,
'phase': phase?.id,
'errorType': errorType,
'message': message,
'stackTrace': stackTrace,
'timestamp': timestamp.toUtc().toIso8601String(),
'appVersion': appVersion,
'platform': platform,
'repairable': repairable,
'reported': reported,
};
static StartupFailureRecord? fromJson(Map<String, Object?> json) {
final message = json['message'];
final errorType = json['errorType'];
final timestamp = DateTime.tryParse(json['timestamp'] as String? ?? '');
if (message is! String || errorType is! String || timestamp == null) return null;
return StartupFailureRecord(
// Records written before ids existed fall back to their timestamp, which
// is stable for a given file even if it is not globally unique.
id: json['id'] as String? ?? 'legacy-${timestamp.microsecondsSinceEpoch}',
phase: StartupPhase.fromId(json['phase'] as String?),
errorType: errorType,
message: message,
stackTrace: json['stackTrace'] as String?,
timestamp: timestamp,
appVersion: json['appVersion'] as String? ?? 'unknown',
platform: json['platform'] as String? ?? 'unknown',
repairable: json['repairable'] as bool? ?? false,
reported: json['reported'] as bool? ?? false,
);
}
}
/// Persists the most recent startup-gate failure so it survives the process.
///
/// A failing launch has no other egress: the log buffer is in memory only, a
/// GUI-launched Windows release build has no console, and the in-app log
/// viewer sits behind the gate that just failed. Writing one small record next
/// to the database lets the next *successful* launch surface it in
/// Settings Logs, where the user can upload it (#1732).
///
/// The record is an allowlist of already-redacted fields; raw store contents
/// never reach it.
abstract final class StartupDiagnosticsStore {
static const String fileName = 'startup_failure.json';
@visibleForTesting
static Directory? debugDirectoryOverride;
static StartupFailureRecord? _pending;
/// A crash-report flush that must finish before the record may be consumed.
static Future<void>? _flushInFlight;
/// The persist started by [record]. Callers launch it unawaited from the
/// failure path, so every later reader has to settle it first or it can land
/// after a peek (losing the report) or after a consume (stranding a stale
/// unreported file).
static Future<void>? _writeInFlight;
/// Record observed during this launch, if any. Set both when a failure is
/// recorded and when one written by an earlier launch is consumed.
static StartupFailureRecord? get pending => _pending;
static Future<File?> _file() async {
try {
final directory = debugDirectoryOverride ?? await getApplicationSupportDirectory();
return File(p.join(directory.path, fileName));
} catch (error, stackTrace) {
appLogger.d('Startup diagnostics location unavailable', error: error, stackTrace: stackTrace);
return null;
}
}
/// Best-effort write. A diagnostics failure must never worsen the failure it
/// is describing, so every error here is logged and swallowed.
static Future<void> record(StartupFailureRecord failure) {
// Published synchronously: the failure screen and the logs banner read it
// straight away, and the disk write below may never land at all.
_pending = failure;
return _enqueueWrite(() => _write(failure));
}
/// Serializes every mutation of the record file.
///
/// Writes are launched unawaited from the failure path, and a retry that
/// also fails writes while the first one may still be running. Two
/// concurrent writers to one file can land out of order, and a
/// read-modify-write like [markReported] can interleave with a write and
/// resurrect the record it just superseded. Chaining makes disk order match
/// call order and makes the compare-and-set atomic against writes (#1732).
static Future<void> _enqueueWrite(Future<void> Function() operation) {
final previous = _writeInFlight;
final task = () async {
if (previous != null) {
try {
await previous;
} catch (_) {
// Best effort: an earlier failed write must not block this one.
}
}
await operation();
}();
_writeInFlight = task;
return task.whenComplete(() {
if (identical(_writeInFlight, task)) _writeInFlight = null;
});
}
static Future<void> _write(StartupFailureRecord failure) async {
try {
final file = await _file();
if (file == null) return;
if (!await file.parent.exists()) await file.parent.create(recursive: true);
await file.writeAsString(jsonEncode(failure.toJson()), flush: true);
} catch (error, stackTrace) {
appLogger.d('Could not persist the startup failure record', error: error, stackTrace: stackTrace);
}
}
/// Waits for an in-flight [record] so readers never race the write.
static Future<void> _settleWrite() async {
final write = _writeInFlight;
if (write == null) return;
try {
await write;
} catch (error, stackTrace) {
appLogger.d('Startup failure record write failed', error: error, stackTrace: stackTrace);
}
}
/// Registers a crash-report flush so [consumePrevious] cannot delete the
/// record out from under it.
///
/// The flush is deliberately off the startup critical path, and the success
/// path consumes the record as soon as the gate completes. Without this gate
/// a fast launch could delete the file before the flush had read it, losing
/// the report entirely — the exact failure this whole path exists to
/// prevent (#1732).
static void holdForFlush(Future<void> flush) {
_flushInFlight = flush;
}
/// Reads a persisted record without consuming it.
///
/// Used by the crash-report flush, which has to run before the record is
/// consumed for display and must not remove it if the send fails.
static Future<StartupFailureRecord?> peekPersisted() async {
await _settleWrite();
try {
final file = await _file();
if (file == null || !await file.exists()) return null;
final decoded = jsonDecode(await file.readAsString());
if (decoded is! Map) return null;
return StartupFailureRecord.fromJson(decoded.cast<String, Object?>());
} catch (error, stackTrace) {
appLogger.d('Could not peek at the startup failure record', error: error, stackTrace: stackTrace);
return null;
}
}
/// Rewrites the persisted record as already reported, so a later launch does
/// not send it a second time. It stays on disk for Settings > Logs.
static Future<void> markReported(StartupFailureRecord failure) {
// Queued with the writes: reading and rewriting outside the queue lets a
// concurrent `record` land between the two and be overwritten, losing the
// newer failure.
return _enqueueWrite(() async {
try {
final file = await _file();
if (file == null || !await file.exists()) return;
// Compare and set: a retry may have failed too and replaced this
// record while the send was in flight.
final current = await _decodeFile(file);
await debugAfterReportedRead?.call();
if (current == null || current.id != failure.id) {
appLogger.d('Startup failure record was superseded before it could be marked reported');
return;
}
final updated = failure.copyWith(reported: true);
if (_pending?.id == failure.id) _pending = updated;
await file.writeAsString(jsonEncode(updated.toJson()), flush: true);
} catch (error, stackTrace) {
appLogger.d('Could not mark the startup failure record as reported', error: error, stackTrace: stackTrace);
}
});
}
/// Test seam: pauses [markReported] between its read and its write so the
/// compare-and-set can be exercised against a concurrent record.
@visibleForTesting
static Future<void> Function()? debugAfterReportedRead;
static Future<StartupFailureRecord?> _decodeFile(File file) async {
final decoded = jsonDecode(await file.readAsString());
if (decoded is! Map) return null;
return StartupFailureRecord.fromJson(decoded.cast<String, Object?>());
}
/// Loads a record written by an earlier launch, deleting it only once it has
/// been reported.
///
/// An unreported record is the crash reporter's only retry: the reporter can
/// be uninitialised, offline, or backed by a no-op hub that accepts events
/// and drops them. Deleting it here would silently end the retry, which is
/// the failure this whole path exists to prevent (#1732). A record that can
/// never be sent — no crash reporting compiled in, or the user opted out —
/// is marked reported by the flush, so nothing lingers indefinitely.
///
/// Either way the value lands in [pending] so the logs screen can show it
/// for the rest of the session.
static Future<StartupFailureRecord?> consumePrevious() async {
await _settleWrite();
final flush = _flushInFlight;
if (flush != null) {
_flushInFlight = null;
// A failed flush must not block the display path.
try {
await flush;
} catch (error, stackTrace) {
appLogger.d('Startup failure flush failed before consumption', error: error, stackTrace: stackTrace);
}
}
try {
final file = await _file();
if (file == null || !await file.exists()) return null;
final record = await _decodeFile(file);
if (record == null) {
// Unreadable: nothing can be retried or shown, so drop it.
await file.delete();
return null;
}
if (record.reported) await file.delete();
_pending = record;
return record;
} catch (error, stackTrace) {
appLogger.d('Could not read a previous startup failure record', error: error, stackTrace: stackTrace);
return null;
}
}
/// Drops a persisted record without surfacing it in [pending].
static Future<void> clear() async {
await _settleWrite();
_pending = null;
try {
final file = await _file();
if (file != null && await file.exists()) await file.delete();
} catch (error, stackTrace) {
appLogger.d('Could not clear the startup failure record', error: error, stackTrace: stackTrace);
}
}
@visibleForTesting
static void resetForTesting() {
_pending = null;
_flushInFlight = null;
_writeInFlight = null;
debugAfterReportedRead = null;
debugDirectoryOverride = null;
}
/// Seeds [pending] without touching disk, for widget tests. The widget-test
/// binding runs in a fake-async zone where a `dart:io` future never
/// completes, so [record] cannot be awaited from one.
@visibleForTesting
static void setPendingForTesting(StartupFailureRecord? failure) => _pending = failure;
}