diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 235acb3b..4cb35bdd 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -454,6 +454,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _contactStreamStarted = false; DateTime _contactRequestLastActivity = DateTime.fromMillisecondsSinceEpoch(0); ({int? since, bool preserveExisting})? _queuedContactRequest; + Timer? _contactRequestWatchdog; @visibleForTesting DateTime Function() contactSyncClock = DateTime.now; @@ -3145,6 +3146,8 @@ class MeshCoreConnector extends ChangeNotifier { _contactSyncDeliveredKeys.clear(); _contactSyncRetryUsed = false; _contactSyncDecisionPending = false; + _contactRequestWatchdog?.cancel(); + _contactRequestWatchdog = null; _contactRequestInFlight = false; _contactStreamStarted = false; _queuedContactRequest = null; @@ -3724,6 +3727,7 @@ class MeshCoreConnector extends ChangeNotifier { _contactRequestInFlight = true; _contactStreamStarted = false; _contactRequestLastActivity = contactSyncClock(); + _armContactRequestWatchdog(); _isLoadingContacts = true; _contactSyncTotal = null; @@ -3765,12 +3769,47 @@ class MeshCoreConnector extends ChangeNotifier { } void _endContactRequest() { + _contactRequestWatchdog?.cancel(); + _contactRequestWatchdog = null; _contactRequestInFlight = false; _contactStreamStarted = false; _isLoadingContacts = false; _contactSyncIsFull = false; } + void _armContactRequestWatchdog() { + _contactRequestWatchdog?.cancel(); + final idle = contactSyncClock().difference(_contactRequestLastActivity); + final wait = contactRequestStaleAfter - idle; + _contactRequestWatchdog = Timer( + wait.isNegative ? Duration.zero : wait, + checkStaleContactRequest, + ); + } + + /// Ends a request whose stream went silent for [contactRequestStaleAfter] + /// without END, so it cannot hold the queue or the loading state until some + /// later refresh happens to run. Nothing is pruned: an unfinished stream is + /// never a complete sync. + @visibleForTesting + void checkStaleContactRequest() { + if (!_contactRequestInFlight) return; + final idle = contactSyncClock().difference(_contactRequestLastActivity); + if (idle < contactRequestStaleAfter) { + _armContactRequestWatchdog(); + return; + } + _appDebugLogService?.warn( + 'Contact request had no reply for ${idle.inSeconds}s; ending it, saved ' + 'contacts kept', + tag: 'ContactSync', + ); + _endContactRequest(); + _hasLoadedContacts = true; + notifyListeners(); + _runQueuedContactRequest(); + } + void _runQueuedContactRequest() { final queued = _queuedContactRequest; if (queued == null || _contactRequestInFlight) return; @@ -8802,6 +8841,7 @@ class MeshCoreConnector extends ChangeNotifier { _gpsLocationPollTimer?.cancel(); _radioStatsPollTimer?.cancel(); _channelsChangedDebounce?.cancel(); + _contactRequestWatchdog?.cancel(); radioStatsNotifier.dispose(); _receivedFramesController.close(); _usbManager.dispose(); diff --git a/test/connector/contact_sync_test.dart b/test/connector/contact_sync_test.dart index f77480df..21012f41 100644 --- a/test/connector/contact_sync_test.dart +++ b/test/connector/contact_sync_test.dart @@ -296,6 +296,26 @@ void main() { expect(contactRequests(), 1); }); + test('a stream that never ends is ended at 30 s without pruning', () async { + seedSaved(); + await connector.getContacts(); + connector.handleFrameForTest(_start(3)); + connector.handleFrameForTest(_contactFrame(0x11, 'Alpha')); + await connector.getContacts(since: 5, preserveExisting: true); + + now = now.add(const Duration(seconds: 29)); + connector.checkStaleContactRequest(); + expect(connector.isLoadingContacts, isTrue, reason: 'live at 29 s'); + + now = now.add(const Duration(seconds: 2)); + connector.checkStaleContactRequest(); + await Future.delayed(Duration.zero); + + expect(names(), {'Alpha', 'Bravo', 'Charlie'}); + expect(contactRequests(), 2, reason: 'the queued request ran'); + expect(sent.last.length, 5, reason: 'the queued incremental request'); + }); + test('a failed send does not leave refreshes blocked', () async { connector.sendFrameOverrideForTest = (_) => throw Exception('usb gone'); await expectLater(connector.getContacts(), throwsException);