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 <noreply@anthropic.com>
docs/750-contact-naming-plan
diffwireauto 1 month ago committed by Benjamin Wiechel
parent a8780facf0
commit fd66a7b42f

@ -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();

@ -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<void>.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);

Loading…
Cancel
Save

Powered by TurnKey Linux.