fix(startup): make the deferred crash report survive its races

The persist-then-flush model had four ways to lose or corrupt the record
it exists to protect.

A no-op hub — which is what a failed or timed-out crash-reporting init
leaves behind, because that phase is best effort — accepts an event and
returns an empty id without throwing. "Did not throw" was treated as
delivery, so the record was marked reported and suppressed forever.
Delivery now requires a non-empty Sentry id, and init completion is
tracked explicitly rather than assumed.

Opting out, and building without a DSN, are deliberate suppression
rather than delivery failure: both mark the record resolved so it is not
rediscovered every launch. Everything else stays pending, and
consumption no longer deletes an unreported record — deleting it ended
the only retry there was, which made "the next launch tries again"
false.

The write path is now a queue. Record writes were launched unawaited
from the failure path, so a fast retry could flush before the file
existed, consume before a late write landed, or run two writers against
one file and let the older one finish last. markReported joins the same
queue and compares record identity before rewriting, because reading and
writing outside it let a concurrent record land in between and be
overwritten by the record it had just superseded. Records carry an id so
that comparison is meaningful.

Consumption also waits on a registered flush, so the success path cannot
delete the file mid-send.

Also routes the tvOS recovery marker through the tolerant read.
reconcile() runs inside AppDatabase.open, a fatal gate step, so a
wrong-typed marker vetoed the launch outright on a first-class TV
target. Both new guards have regression tests verified to fail without
the fix.
This commit is contained in:
edde746
2026-07-31 21:45:33 +02:00
parent 9555937873
commit 6f9edd3e93
4 changed files with 535 additions and 44 deletions
@@ -467,6 +467,26 @@ void main() {
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
});
test('a wrong-typed recovery marker behaves like a missing one', () async {
// `reconcile` runs inside `AppDatabase.open`, a fatal startup step, so
// a mistyped marker used to throw a raw TypeError and veto the launch
// outright on a first-class TV target (#1732). An unreadable marker
// tells us nothing, which is the same position as an absent one.
await prefs.setString(TvosDatabaseRecoveryStore.recoveryRequiredKey, 'yes');
final result = await open();
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh);
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
// The unreadable value is dropped so the next launch starts clean. The
// removal is fire-and-forget, so give it a turn to land.
for (var i = 0; i < 20 && prefs.keys.contains(TvosDatabaseRecoveryStore.recoveryRequiredKey); i++) {
await Future<void>.delayed(Duration.zero);
}
expect(prefs.keys, isNot(contains(TvosDatabaseRecoveryStore.recoveryRequiredKey)));
});
test('missing database with prior-install evidence requires recovery', () async {
await prefs.setString('active_app_profile_id', 'surviving-profile');
final result = await open();