fix(di): robust all-or-nothing init so the social layer never greys out

The idempotency guard used SocialService as its sentinel, but that was
registered mid-cascade, so a run that crashed part-way could leave a
half-built container the next run would reuse (or hit 'already
registered' on) — intermittently falling back to the inventory-only look
with market/chat/profile greyed out.

- Guard on a dedicated _DepsReady marker registered LAST, so the sentinel
  means the WHOLE container is wired, never a partial one.
- Reset a half-wired container (crashed prior run) before rebuilding,
  instead of reusing it. Safe: main aborts before runApp on a partial run.
- Make the social key derivation non-fatal: on failure the app opens on
  inventory (social hidden) rather than blanking. main tolerates an
  absent SocialService.
This commit is contained in:
vjrj 2026-07-10 16:12:49 +02:00
parent 97b9223cb2
commit 606861bbfa
2 changed files with 46 additions and 9 deletions

View file

@ -40,15 +40,33 @@ import '../services/social_settings.dart';
/// in the tree. /// in the tree.
final GetIt getIt = GetIt.instance; final GetIt getIt = GetIt.instance;
/// End-of-init sentinel: registered last, so `isRegistered<_DepsReady>()` means
/// the container is fully wired (see [configureDependencies]). Distinct from
/// [SocialService], which is optional and absent in inventory-only mode.
class _DepsReady {
const _DepsReady();
}
/// Wires the encrypted DB, keystore and repositories. Call once from `main` /// Wires the encrypted DB, keystore and repositories. Call once from `main`
/// before `runApp`; the DB key must exist before the DB opens. /// before `runApp`; the DB key must exist before the DB opens.
/// ///
/// Idempotent: a hot restart (or an Android Activity restart that keeps the /// Idempotent AND all-or-nothing: a hot restart (or an Android Activity restart
/// isolate) re-runs `main` while GetIt still holds the singletons. Without this /// that keeps the isolate) re-runs `main` while GetIt still holds the singletons.
/// guard the re-registration throws, `main` aborts before rebuilding `TaneApp` ///
/// with the social layer, and the app falls back to the "inventory only" look. /// A dedicated [_DepsReady] marker is registered LAST and used as the "fully
/// wired" sentinel, so `isRegistered<_DepsReady>()` means the WHOLE container is
/// ready never a half-built one. If a prior run crashed part-way through init
/// (leaving some singletons registered but not the marker), we reset the
/// container and rebuild from scratch rather than early-returning on a partial
/// container or throwing "already registered" mid-cascade. That partial state is
/// safe to reset because `main` aborts before `runApp` on a partial run, so no
/// live UI holds these singletons. Without this, the app intermittently fell
/// back to the "inventory only" look (market/chat/profile greyed out).
Future<void> configureDependencies() async { Future<void> configureDependencies() async {
if (getIt.isRegistered<SocialService>()) return; if (getIt.isRegistered<_DepsReady>()) return; // fully wired already
// A prior run half-wired the container (crashed after some registrations but
// before the sentinel). Clear it so we rebuild cleanly.
if (getIt.isRegistered<AppDatabase>()) await getIt.reset();
final secretStore = FlutterSecretStore(); final secretStore = FlutterSecretStore();
final keyStore = SecureKeyStore(store: secretStore); final keyStore = SecureKeyStore(store: secretStore);
@ -66,8 +84,15 @@ Future<void> configureDependencies() async {
// Block 2 social identity: a secp256k1 Nostr key derived (one-way) from the // Block 2 social identity: a secp256k1 Nostr key derived (one-way) from the
// SAME root seed, so it needs no extra backup. Cheap + offline; no relay is // SAME root seed, so it needs no extra backup. Cheap + offline; no relay is
// contacted here (local-first the social layer only enriches). // contacted here (local-first the social layer only enriches). Non-fatal: if
final socialService = await SocialService.fromRootSeedHex(rootSeedHex); // the derivation ever fails, the app still opens on inventory (market/chat/
// profile stay hidden) rather than blanking the whole app.
SocialService? socialService;
try {
socialService = await SocialService.fromRootSeedHex(rootSeedHex);
} catch (e, s) {
debugPrint('Social identity derivation failed; inventory-only mode: $e\n$s');
}
// Seed the bundled species catalog before the UI opens but only once per // Seed the bundled species catalog before the UI opens but only once per
// catalog version. Parsing the ~1.5 MB catalog and scanning the species table // catalog version. Parsing the ~1.5 MB catalog and scanning the species table
@ -108,7 +133,6 @@ Future<void> configureDependencies() async {
..registerSingleton<LabelTextExtractor>(labelExtractor) ..registerSingleton<LabelTextExtractor>(labelExtractor)
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore)) ..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<LocaleStore>(LocaleStore(secretStore)) ..registerSingleton<LocaleStore>(LocaleStore(secretStore))
..registerSingleton<SocialService>(socialService)
..registerSingleton<SocialSettings>(SocialSettings(secretStore)) ..registerSingleton<SocialSettings>(SocialSettings(secretStore))
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore)) ..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
..registerSingleton<MessageStore>(MessageStore(secretStore)) ..registerSingleton<MessageStore>(MessageStore(secretStore))
@ -139,6 +163,17 @@ Future<void> configureDependencies() async {
), ),
); );
} }
// Optional: absent only if the derivation above failed then the app runs
// inventory-only, by design (market/chat/profile hidden).
if (socialService != null) {
getIt.registerSingleton<SocialService>(socialService);
}
// Registered LAST: the "fully wired" sentinel the guard above checks. Anything
// that throws before here leaves the marker unregistered, so the next run
// rebuilds cleanly instead of reusing a half-built container.
getIt.registerSingleton<_DepsReady>(const _DepsReady());
} }
Future<Directory> _backupsDir() async { Future<Directory> _backupsDir() async {

View file

@ -32,7 +32,9 @@ Future<void> main() async {
repository: getIt<VarietyRepository>(), repository: getIt<VarietyRepository>(),
species: getIt<SpeciesRepository>(), species: getIt<SpeciesRepository>(),
onboarding: onboarding, onboarding: onboarding,
social: getIt<SocialService>(), social: getIt.isRegistered<SocialService>()
? getIt<SocialService>()
: null,
socialSettings: getIt<SocialSettings>(), socialSettings: getIt<SocialSettings>(),
location: const GeolocatorCoarseLocation(), location: const GeolocatorCoarseLocation(),
outbox: getIt<OfferOutbox>(), outbox: getIt<OfferOutbox>(),