From fd66a7b42f39b142f12f73a3cc6f4f89b112f7fe Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:31:56 -0400 Subject: [PATCH] fix(#672): end a silent contact stream after 30 s on its own The 30 s staleness rule only ran when a later getContacts call checked it. If the radio drops END (firmware #1283 can refuse it like any frame) and nothing else asks for contacts, the request stayed in flight: the loading state stuck and a queued refresh never ran. A watchdog now fires at the stale mark, re-arms if frames are still arriving, and otherwise ends the request and runs the queued one. It never prunes: an unfinished stream is not a complete sync. Found by the pre-PR Gemini review. Co-Authored-By: Claude Opus 5.5 --- lib/connector/meshcore_connector.dart | 40 +++++++++++++++++++++++++++ test/connector/contact_sync_test.dart | 20 ++++++++++++++ 2 files changed, 60 insertions(+) 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);