diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 634fc998..d5741612 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -453,8 +453,28 @@ class MeshCoreConnector extends ChangeNotifier { bool _contactSyncIsFull = false; int? _contactSyncDeclaredTotal; final Set _contactSyncDeliveredKeys = {}; - bool _contactSyncRetryUsed = false; bool _contactSyncDecisionPending = false; + // Forced resync (#703): after a short full sync the app waits for the link to + // settle, syncs again (at most [contactSyncMaxFullStreams] full syncs per + // connection, owner decision), then asks the radio by key for each saved + // contact still missing. Only a contact the radio answers "not found" for is + // ever removed by it. All of this is per connection: a disconnect or radio + // switch bumps [_recoveryRun] and drops it. + static const int contactSyncMaxFullStreams = 3; + static const Duration defaultContactRecoverySettle = Duration(seconds: 10); + static const Duration defaultContactKeyCheckGap = Duration(milliseconds: 250); + @visibleForTesting + Duration contactRecoverySettle = defaultContactRecoverySettle; + @visibleForTesting + Duration contactKeyCheckGap = defaultContactKeyCheckGap; + int _contactFullStreams = 0; + int _recoveryRun = 0; + String? _recoveryRadioKey; + final Set _recoveryDelivered = {}; + final Set _recoveryGone = {}; + bool _recoveryCheckingKeys = false; + Timer? _recoverySettleTimer; + DateTime _lastRadioFrameAt = DateTime.fromMillisecondsSinceEpoch(0); ContactSyncShortfall? _contactSyncShortfall; // One contact request at a time: the firmware refuses a second // CMD_GET_CONTACTS mid-stream with ERR_CODE_BAD_STATE. A request made while @@ -3164,7 +3184,14 @@ class MeshCoreConnector extends ChangeNotifier { _contactSyncIsFull = false; _contactSyncDeclaredTotal = null; _contactSyncDeliveredKeys.clear(); - _contactSyncRetryUsed = false; + _contactFullStreams = 0; + _recoveryRun++; + _recoveryRadioKey = null; + _recoveryDelivered.clear(); + _recoveryGone.clear(); + _recoveryCheckingKeys = false; + _recoverySettleTimer?.cancel(); + _recoverySettleTimer = null; _contactSyncDecisionPending = false; _contactRequestWatchdog?.cancel(); _contactRequestWatchdog = null; @@ -3758,6 +3785,7 @@ class MeshCoreConnector extends ChangeNotifier { _contactSyncTotal = null; _contactSyncReceived = 0; _contactSyncUsesSinceFilter = since != null; + if (since == null) _recoverySettleTimer?.cancel(); _beginContactSync(full: since == null); if (!preserveExisting) { _hasLoadedContacts = false; @@ -3870,9 +3898,12 @@ class MeshCoreConnector extends ChangeNotifier { /// Contacts the app holds that the last full sync did not deliver, i.e. what /// "use the radio's list" would remove. int get contactSyncUndeliveredCount => _contacts - .where((c) => !_contactSyncDeliveredKeys.contains(c.publicKeyHex)) + .where((c) => !_recoveryDelivered.contains(c.publicKeyHex)) .length; + /// Saved contacts the radio answered "not found" for during recovery. + int get contactSyncConfirmedGoneCount => _recoveryGone.length; + /// A full sync is complete only when the radio declared a total and at /// least that many contacts arrived. No declared total (older firmware) is /// unverifiable, so it is never treated as complete. @@ -3892,6 +3923,13 @@ class MeshCoreConnector extends ChangeNotifier { void _finishFullContactSync() { final declared = _contactSyncDeclaredTotal; final received = _contactSyncReceived; + _contactFullStreams++; + if (_recoveryRadioKey != selfPublicKeyHex) { + _recoveryRadioKey = selfPublicKeyHex; + _recoveryDelivered.clear(); + _recoveryGone.clear(); + } + _recoveryDelivered.addAll(_contactSyncDeliveredKeys); if (isContactSyncComplete(declared: declared, received: received)) { final before = _contacts.length; _contacts.removeWhere( @@ -3907,39 +3945,128 @@ class MeshCoreConnector extends ChangeNotifier { } _contactSyncShortfall = null; _contactSyncDecisionPending = false; + _recoveryDelivered.clear(); + _recoveryGone.clear(); + return; + } + + _appDebugLogService?.warn( + 'Contact sync short: radio declared ${declared ?? 'no total'}, sent ' + '$received (full sync $_contactFullStreams of ' + '$contactSyncMaxFullStreams). Nothing removed.', + tag: 'ContactSync', + ); + // A user refresh during the by-key checks only adds what it delivered; + // the checks finish the run. + if (_recoveryCheckingKeys) return; + + final unionComplete = + declared != null && _recoveryDelivered.length >= declared; + if (!unionComplete && _contactFullStreams < contactSyncMaxFullStreams) { + _scheduleRecoveryStream(_recoveryRun); + return; + } + unawaited(_checkMissingContactsByKey(_recoveryRun, declared)); + } + + void _scheduleRecoveryStream(int run) { + _recoverySettleTimer?.cancel(); + _recoverySettleTimer = Timer( + contactRecoverySettle, + () => _runRecoveryStreamWhenSettled(run), + ); + } + + /// Waits until the radio has been quiet for [contactRecoverySettle] and no + /// other startup sync is running, so recovery never hammers the link. + void _runRecoveryStreamWhenSettled(int run) { + if (run != _recoveryRun || !isConnected) return; + final busy = + _contactRequestInFlight || + _isSyncingChannels || + _channelSyncInFlight || + isSyncingQueuedMessages; + final quietFor = contactSyncClock().difference(_lastRadioFrameAt); + if (busy || quietFor < contactRecoverySettle) { + final wait = busy + ? contactRecoverySettle + : contactRecoverySettle - quietFor; + _recoverySettleTimer = Timer( + wait < const Duration(seconds: 1) ? const Duration(seconds: 1) : wait, + () => _runRecoveryStreamWhenSettled(run), + ); + return; + } + _appDebugLogService?.info( + 'Contact recovery: full sync ${_contactFullStreams + 1} of ' + '$contactSyncMaxFullStreams', + tag: 'ContactSync', + ); + unawaited( + getContacts().catchError((Object e) { + _appDebugLogService?.error( + 'Contact recovery sync failed to send: $e', + tag: 'ContactSync', + ); + }), + ); + } + + /// After the last full sync: asks the radio, one key at a time and spaced + /// apart, about each saved contact no sync delivered. Removes only the ones + /// it answers "not found" for, and only when every declared contact has + /// been accounted for; otherwise the owner decides. + Future _checkMissingContactsByKey(int run, int? declared) async { + final missing = _contacts + .where((c) => !_recoveryDelivered.contains(c.publicKeyHex)) + .map((c) => c.publicKey) + .toList(); + _recoveryCheckingKeys = true; + for (final key in missing) { + await Future.delayed(contactKeyCheckGap); + if (run != _recoveryRun || !isConnected) return; + final result = await checkContactOnRadio(key); + if (run != _recoveryRun) return; + final hex = pubKeyToHex(key); + if (result == ContactKeyCheckResult.onRadio) { + _recoveryDelivered.add(hex); + } else if (result == ContactKeyCheckResult.notFound) { + _recoveryGone.add(hex); + } + } + _recoveryCheckingKeys = false; + + final accounted = _recoveryDelivered.length; + if (declared != null && accounted >= declared) { + final gone = Set.of(_recoveryGone); + _contacts.removeWhere((c) => gone.contains(c.publicKeyHex)); + _appDebugLogService?.info( + 'Contact recovery complete ($accounted of $declared): removed ' + '${gone.length} contact(s) the radio no longer holds', + tag: 'ContactSync', + ); + _contactSyncShortfall = null; + _contactSyncDecisionPending = false; + notifyListeners(); + await _persistContacts(); return; } final kept = contactSyncUndeliveredCount; _contactSyncShortfall = ContactSyncShortfall( declared: declared, - received: received, + received: accounted, keptLocally: kept, ); + _contactSyncDecisionPending = true; final message = - 'Contact sync incomplete: radio declared ${declared ?? 'no total'}, ' - 'sent $received. Kept $kept saved contact(s) it did not send; ' - 'nothing removed.'; + 'Contact sync incomplete after $_contactFullStreams full sync(s): ' + 'radio declared ${declared ?? 'no total'}, app has $accounted. Kept ' + '$kept saved contact(s) it did not send (${_recoveryGone.length} ' + 'confirmed gone); nothing removed.'; debugPrint(message); _appDebugLogService?.error(message, tag: 'ContactSync'); - - if (!_contactSyncRetryUsed) { - _contactSyncRetryUsed = true; - _appDebugLogService?.warn( - 'Retrying the contact sync once', - tag: 'ContactSync', - ); - unawaited( - getContacts().catchError((Object e) { - _appDebugLogService?.error( - 'Contact sync retry failed to send: $e', - tag: 'ContactSync', - ); - }), - ); - } else { - _contactSyncDecisionPending = true; - } + notifyListeners(); } /// The owner chose to keep the local list after a short sync. @@ -3952,13 +4079,11 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - /// The owner chose the radio's list after a short sync: drop what it did not - /// deliver. + /// The owner chose the radio's list after a short sync: drop what no sync on + /// this connection delivered (confirmed gone, or never sent by the radio). Future resolveContactSyncUseRadio() async { final removed = contactSyncUndeliveredCount; - _contacts.removeWhere( - (c) => !_contactSyncDeliveredKeys.contains(c.publicKeyHex), - ); + _contacts.removeWhere((c) => !_recoveryDelivered.contains(c.publicKeyHex)); _contactSyncDecisionPending = false; _contactSyncShortfall = null; _appDebugLogService?.warn( @@ -5834,6 +5959,7 @@ class MeshCoreConnector extends ChangeNotifier { void _handleFrameInner(List data) { if (data.isEmpty) return; _lastRxTime = DateTime.now(); + _lastRadioFrameAt = contactSyncClock(); final frame = Uint8List.fromList(data); _receivedFramesController.add(frame); @@ -8935,6 +9061,7 @@ class MeshCoreConnector extends ChangeNotifier { _channelsChangedDebounce?.cancel(); _contactRequestWatchdog?.cancel(); _pendingKeyCheck?.timer?.cancel(); + _recoverySettleTimer?.cancel(); radioStatsNotifier.dispose(); _receivedFramesController.close(); _usbManager.dispose(); diff --git a/test/connector/contact_sync_test.dart b/test/connector/contact_sync_test.dart index 21012f41..6833b1e0 100644 --- a/test/connector/contact_sync_test.dart +++ b/test/connector/contact_sync_test.dart @@ -65,6 +65,9 @@ void main() { connector = MeshCoreConnector(); connector.contactsForTest.clear(); + connector.contactRecoverySettle = Duration.zero; + connector.contactKeyCheckGap = Duration.zero; + connector.contactKeyCheckTimeout = const Duration(milliseconds: 20); sent = []; connector.sendFrameOverrideForTest = sent.add; connector.setConnectedForTest(); @@ -136,65 +139,89 @@ void main() { expect(contactRequests(), 1, reason: 'no retry after a complete sync'); }); - test('a short sync removes nothing, merges, reports, and retries', () async { + Future settle() => + Future.delayed(const Duration(milliseconds: 5)); + + int byKeyRequests() => + sent.where((f) => f.isNotEmpty && f[0] == cmdGetContactByKey).length; + + /// Answers the recovery request the app just sent with [streamed]. + Future answerStream(int? declared, List streamed) async { + connector.handleFrameForTest( + declared == null + ? Uint8List.fromList([respCodeContactsStart]) + : _start(declared), + ); + for (final f in streamed) { + connector.handleFrameForTest(f); + } + connector.handleFrameForTest(_end); + await settle(); + } + + /// Three short full syncs that each deliver only Alpha; the by-key checks + /// that follow get no reply, so every missing contact stays unresolved. + Future threeShortSyncs({int? declared = 350}) async { + seedSaved(); + final alpha = [_contactFrame(0x11, 'Alpha')]; + await connector.getContacts(); + await answerStream(declared, alpha); + await answerStream(declared, alpha); + await answerStream(declared, alpha); + await Future.delayed(const Duration(milliseconds: 100)); + } + + test('a short sync removes nothing, merges, and recovers quietly', () async { seedSaved(); await fullSync( declared: 350, streamed: [_contactFrame(0x11, 'Alpha'), _contactFrame(0x44, 'Delta')], ); + await settle(); expect(names(), {'Alpha', 'Bravo', 'Charlie', 'Delta'}); - final shortfall = connector.contactSyncShortfall!; - expect(shortfall.declared, 350); - expect(shortfall.received, 2); - expect(shortfall.keptLocally, 2); + expect( + connector.contactSyncShortfall, + isNull, + reason: 'no banner while recovery is still running', + ); expect(connector.contactSyncDecisionPending, isFalse); - expect(contactRequests(), 2, reason: 'one automatic retry'); + expect(contactRequests(), 2, reason: 'recovery sync after the settle'); }); test('no declared total is never treated as complete', () async { - seedSaved(); - await connector.getContacts(); - connector.handleFrameForTest(Uint8List.fromList([respCodeContactsStart])); - connector.handleFrameForTest(_contactFrame(0x11, 'Alpha')); - connector.handleFrameForTest(_end); - await Future.delayed(Duration.zero); + await threeShortSyncs(declared: null); expect(names(), {'Alpha', 'Bravo', 'Charlie'}); expect(connector.contactSyncShortfall!.declared, isNull); + expect(connector.contactSyncDecisionPending, isTrue); }); - group('after the retry is also short', () { - Future twoShortSyncs() async { - seedSaved(); - final streamed = [_contactFrame(0x11, 'Alpha')]; - await fullSync(declared: 350, streamed: streamed); - // The retry was sent by the first END; the radio answers it short too. - connector.handleFrameForTest(_start(350)); - connector.handleFrameForTest(streamed.first); - connector.handleFrameForTest(_end); - await Future.delayed(Duration.zero); - } - - test('the owner decision is raised and nothing is removed', () async { - await twoShortSyncs(); + group('still short after three full syncs', () { + test('stops at three, checks the rest by key, removes nothing', () async { + await threeShortSyncs(); + expect(contactRequests(), 3, reason: 'owner cap: 3 per connection'); + expect(byKeyRequests(), 2, reason: 'Bravo and Charlie, one each'); expect(connector.contactSyncDecisionPending, isTrue); expect(connector.contactSyncUndeliveredCount, 2); expect(names(), {'Alpha', 'Bravo', 'Charlie'}); - expect(contactRequests(), 2, reason: 'retry is bounded to one'); + final shortfall = connector.contactSyncShortfall!; + expect(shortfall.declared, 350); + expect(shortfall.received, 1); + expect(shortfall.keptLocally, 2); }); test('keep my contacts leaves the list as is', () async { - await twoShortSyncs(); + await threeShortSyncs(); connector.resolveContactSyncKeepLocal(); expect(connector.contactSyncDecisionPending, isFalse); expect(names(), {'Alpha', 'Bravo', 'Charlie'}); }); - test('use the radio list drops what it did not send', () async { - await twoShortSyncs(); + test('use the radio list drops what no sync delivered', () async { + await threeShortSyncs(); await connector.resolveContactSyncUseRadio(); expect(connector.contactSyncDecisionPending, isFalse); @@ -203,6 +230,148 @@ void main() { }); }); + group('forced resync (#703)', () { + test('the cap and pacing defaults are pinned', () { + expect(MeshCoreConnector.contactSyncMaxFullStreams, 3); + expect( + MeshCoreConnector.defaultContactRecoverySettle, + const Duration(seconds: 10), + ); + expect( + MeshCoreConnector.defaultContactKeyCheckGap, + const Duration(milliseconds: 250), + ); + expect( + MeshCoreConnector().contactKeyCheckTimeout, + const Duration(seconds: 5), + ); + }); + + test( + 'syncs that each miss different contacts add up to complete', + () async { + seedSaved(); + await connector.getContacts(); + await answerStream(3, [ + _contactFrame(0x11, 'Alpha'), + _contactFrame(0x22, 'Bravo'), + ]); + await answerStream(3, [ + _contactFrame(0x22, 'Bravo'), + _contactFrame(0x33, 'Charlie'), + ]); + await settle(); + + expect(contactRequests(), 2, reason: 'no third sync once complete'); + expect(byKeyRequests(), 0); + expect(connector.contactSyncShortfall, isNull); + expect(connector.contactSyncDecisionPending, isFalse); + expect(names(), {'Alpha', 'Bravo', 'Charlie'}); + }, + ); + + test('only a contact the radio says is gone is removed', () async { + seedSaved(); + connector.contactsForTest.add(_saved(0x44, 'Delta')); + await connector.getContacts(); + await answerStream(3, [ + _contactFrame(0x11, 'Alpha'), + _contactFrame(0x22, 'Bravo'), + ]); + await answerStream(3, [ + _contactFrame(0x11, 'Alpha'), + _contactFrame(0x33, 'Charlie'), + ]); + expect(byKeyRequests(), 1, reason: 'Delta, the only one missing'); + connector.handleFrameForTest( + Uint8List.fromList([respCodeErr, errCodeNotFound]), + ); + await settle(); + + expect(names(), {'Alpha', 'Bravo', 'Charlie'}); + expect(connector.contactSyncShortfall, isNull); + }); + + test( + 'a contact with no answer is kept even when the rest add up', + () async { + seedSaved(); + connector.contactsForTest.add(_saved(0x44, 'Delta')); + await connector.getContacts(); + await answerStream(3, [ + _contactFrame(0x11, 'Alpha'), + _contactFrame(0x22, 'Bravo'), + ]); + await answerStream(3, [_contactFrame(0x33, 'Charlie')]); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(names(), {'Alpha', 'Bravo', 'Charlie', 'Delta'}); + }, + ); + + test('a by-key answer counts toward complete', () async { + seedSaved(); + await connector.getContacts(); + final alpha = [_contactFrame(0x11, 'Alpha')]; + await answerStream(3, alpha); + await answerStream(3, alpha); + await answerStream(3, alpha); + connector.handleFrameForTest(_contactFrame(0x22, 'Bravo')); + await settle(); + connector.handleFrameForTest(_contactFrame(0x33, 'Charlie')); + await settle(); + + expect(connector.contactSyncShortfall, isNull); + expect(connector.contactSyncDecisionPending, isFalse); + expect(names(), {'Alpha', 'Bravo', 'Charlie'}); + }); + + test('a disconnect mid-recovery stops it and removes nothing', () async { + seedSaved(); + connector.contactRecoverySettle = const Duration(milliseconds: 30); + await fullSync(declared: 350, streamed: [_contactFrame(0x11, 'Alpha')]); + connector.resetConnectionHandshakeStateForTest(); + await Future.delayed(const Duration(milliseconds: 80)); + + expect(contactRequests(), 1, reason: 'no recovery after disconnect'); + expect(byKeyRequests(), 0); + expect(names(), {'Alpha', 'Bravo', 'Charlie'}); + }); + + test('a new connection starts with no recovery state', () async { + seedSaved(); + await connector.getContacts(); + await answerStream(3, [ + _contactFrame(0x11, 'Alpha'), + _contactFrame(0x22, 'Bravo'), + ]); + connector.resetConnectionHandshakeStateForTest(); + + await connector.getContacts(); + await answerStream(3, [_contactFrame(0x11, 'Alpha')]); + + expect( + connector.contactSyncUndeliveredCount, + 2, + reason: 'Bravo from the old connection must not count', + ); + }); + + test('recovery waits for the radio to go quiet', () async { + var now = DateTime.utc(2026, 10, 2, 6); + connector.contactSyncClock = () => now; + connector.contactRecoverySettle = const Duration(milliseconds: 10); + seedSaved(); + await fullSync(declared: 350, streamed: [_contactFrame(0x11, 'Alpha')]); + await Future.delayed(const Duration(milliseconds: 50)); + expect(contactRequests(), 1, reason: 'radio not quiet yet'); + + now = now.add(const Duration(seconds: 1)); + await Future.delayed(const Duration(milliseconds: 1100)); + expect(contactRequests(), 2); + }); + }); + test('an advert heard mid-sync survives a complete sync', () async { seedSaved(); await connector.getContacts();