fix(#306): surface silent storage failures per SAFELANE 6

The stores violated the no-silent-failures rule, and that is why tonight's
diagnosis took as long as it did. Two classes:

1. Ten catch blocks swallowed decode errors and returned an empty collection.
   `catch (_) { return []; }` means a corrupt or unreadable store presents to
   the caller as "no data", so the user sees no contacts, no channels or no
   history with nothing anywhere explaining why. Indistinguishable from data
   loss. Every one now logs what failed, with the store named, before
   returning.

2. ChannelMessageStore._storageKey fell back from the PSK-identity key to the
   legacy slot-index key without a word. That fallback is correct before a
   channel list exists, but once history has been migrated to the PSK key it
   reads a DIFFERENT key and returns empty. That is exactly #333: a user's 566
   Public messages were intact on disk while the app showed nothing, with no
   error, no parse failure and no log line to follow.

   It now warns when it falls back while a resolver is installed, since that
   combination specifically means the channel list was not loaded first.

Had either of these been loud, #333 would have been a one-line log read
instead of a multi-hour investigation that looked like data loss.

flutter analyze clean, dart format clean, 494 tests pass.
integration/290-306-335
Strycher 2 days ago
parent a7ef7b3325
commit 30ab360d66

@ -32,11 +32,25 @@ class ChannelMessageStore {
String _indexKey(int channelIndex) => '$keyFor$channelIndex'; String _indexKey(int channelIndex) => '$keyFor$channelIndex';
/// Active storage key: PSK identity when known, else the slot index. /// Active storage key: PSK identity when known, else the slot index.
///
/// The index fallback is legitimate before a channel list has loaded, but it
/// reads a DIFFERENT key: if history was already migrated to the PSK key, the
/// caller gets an empty list that is indistinguishable from data loss.
/// SAFELANE 6 - this must never be silent. Falling back where a resolver
/// exists means the channel list was not ready, which is the #333 race.
String _storageKey(int channelIndex) { String _storageKey(int channelIndex) {
final pskHex = channelPskResolver?.call(channelIndex); final pskHex = channelPskResolver?.call(channelIndex);
if (pskHex != null && pskHex.isNotEmpty) { if (pskHex != null && pskHex.isNotEmpty) {
return '$keyFor$_pskMarker$pskHex'; return '$keyFor$_pskMarker$pskHex';
} }
if (channelPskResolver != null) {
appLogger.warn(
'Channel $channelIndex has no PSK yet; falling back to the slot-index '
'key. Any history already migrated to the PSK key will read as EMPTY. '
'This means the channel list was not loaded first (#333).',
tag: 'Storage',
);
}
return _indexKey(channelIndex); return _indexKey(channelIndex);
} }

@ -47,7 +47,11 @@ class ChannelStore {
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode channels: $e', tag: 'Storage');
return []; return [];
} }
} }

@ -107,7 +107,11 @@ class CommunityStore {
final communities = await loadCommunities(); final communities = await loadCommunities();
try { try {
return communities.firstWhere((c) => c.id == communityId); return communities.firstWhere((c) => c.id == communityId);
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode communities: $e', tag: 'Storage');
return null; return null;
} }
} }
@ -118,7 +122,11 @@ class CommunityStore {
final communities = await loadCommunities(); final communities = await loadCommunities();
try { try {
return communities.firstWhere((c) => c.communityId == cid); return communities.firstWhere((c) => c.communityId == cid);
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode communities: $e', tag: 'Storage');
return null; return null;
} }
} }

@ -3,6 +3,7 @@ import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
import '../utils/app_logger.dart';
class ContactDiscoveryStore { class ContactDiscoveryStore {
static const String _keyPrefix = 'discovered_contacts'; static const String _keyPrefix = 'discovered_contacts';
@ -17,7 +18,14 @@ class ContactDiscoveryStore {
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error(
'Failed to decode discovered contacts: $e',
tag: 'Storage',
);
return []; return [];
} }
} }

@ -47,7 +47,11 @@ class ContactStore {
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode contacts: $e', tag: 'Storage');
return []; return [];
} }
} }

@ -66,6 +66,13 @@ class MessageStore {
final jsonList = jsonDecode(jsonString) as List<dynamic>; final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList(); return jsonList.map((json) => _messageFromJson(json)).toList();
} catch (e) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error(
'Failed to decode messages for $contactKeyHex: $e',
tag: 'Storage',
);
return []; return [];
} }
} }

@ -57,7 +57,11 @@ class UnreadStore {
try { try {
final json = jsonDecode(jsonString) as Map<String, dynamic>; final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int)); return json.map((key, value) => MapEntry(key, value as int));
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode unread state: $e', tag: 'Storage');
return {}; return {};
} }
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.