diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c2386246..8fc984f8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1373,7 +1373,10 @@ the app shows as "This region does not support claiming yet." once per BLE connection and cached on the `MeshCoreConnection`, and every later `getContacts` sends the firmware's `since` filter (the newest `lastmod` from the END_OF_CONTACTS frame) and merges what changed, so the 52 KB stream happens once, not per - login; a learned route bumps `lastmod` so it syncs, `resetPath` does not so the cached + login; the read times out on 5 s of silence (120 s overall), never a fixed total, because + a 350-contact list takes about 21 s and a fixed 20 s timeout cut it off on every try, and that + timeout reads "Your radio didn't finish sending its contact list", never the password + sentence; a learned route bumps `lastmod` so it syncs, `resetPath` does not so the cached record is patched, `addContact` inserts its record, and the cache dies with the connection object (auto-reconnect builds a new one); `ERR 3` there is "Your radio's contact list is full."). Step 2 logs in (`login`, `CMD_SEND_LOGIN` with the raw UTF-8 diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 59524b18..04359bf4 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -599,6 +599,9 @@ class MeshCoreConnection { Timer? _adminLateResponseTimer; Completer>? _contactsCompleter; List _contactsBuffer = []; + // Re-arms the running getContacts call's silence timer. Called on every + // CONTACTS_START and CONTACT frame, so a long list keeps its read alive. + void Function()? _contactsProgress; /// The radio's contact list as this connection last saw it, keyed by the /// full public key hex, in the radio's order. Lives exactly as long as @@ -1288,12 +1291,14 @@ class MeshCoreConnection { break; } _contactsBuffer = []; + _contactsProgress?.call(); final count = reader.remainingBytesCount >= 4 ? reader.readUInt32LE() : -1; debugLog('[CONN] Contact list starting ($count contacts)'); break; case ResponseCodes.contact: if (_contactsCompleter == null) break; + _contactsProgress?.call(); try { _contactsBuffer.add(ContactRecord.parse(reader)); } on FormatException catch (e) { @@ -2983,10 +2988,29 @@ class MeshCoreConnection { } /// Read the radio's whole contact list (CMD_GET_CONTACTS with since = 0). - Future> getContacts( - {Duration timeout = const Duration(seconds: 20)}) async { + /// + /// The wait follows the stream rather than a fixed total: it times out + /// after [silenceTimeout] with no frame of the list, or at [timeout] + /// overall. The radio streams about one contact per 60 ms, so a 350 entry + /// table takes over 21 s, and a fixed 20 s cut it off every time. + Future> getContacts({ + Duration silenceTimeout = const Duration(seconds: 5), + Duration timeout = const Duration(seconds: 120), + }) async { final token = _beginAdminCommand('getContacts'); final completer = Completer>(); + Timer? silence; + void armSilence() { + silence?.cancel(); + silence = Timer(silenceTimeout, () { + if (completer.isCompleted) return; + debugWarn('[CONN] getContacts: radio silent for ' + '${silenceTimeout.inMilliseconds}ms ' + '(${_contactsBuffer.length} contacts received)'); + completer.completeError(TimeoutException('getContacts timed out')); + }); + } + try { await _drainPollsForAdminCommand('getContacts'); _contactsCompleter = completer; @@ -2997,12 +3021,16 @@ class MeshCoreConnection { ..writeByte(CommandCodes.getContacts) ..writeUInt32LE(since); await _sendToRadio(data); + _contactsProgress = armSilence; + armSilence(); return await completer.future.timeout(timeout, onTimeout: () { _contactsCompleter = null; _contactsBuffer = []; throw TimeoutException('getContacts timed out'); }); } finally { + silence?.cancel(); + if (identical(_contactsProgress, armSilence)) _contactsProgress = null; // A write that threw before completer.future was ever awaited leaves // this call's completer registered with no listener. Clear it here // (only if it is still the one this call installed - a later call may diff --git a/lib/services/repeater_admin/repeater_admin_session.dart b/lib/services/repeater_admin/repeater_admin_session.dart index ffa25448..8a7e6570 100644 --- a/lib/services/repeater_admin/repeater_admin_session.dart +++ b/lib/services/repeater_admin/repeater_admin_session.dart @@ -26,6 +26,8 @@ const String kNeighboursUnansweredSentence = "This repeater's firmware cannot report neighbours."; const String kNeighboursTimeoutSentence = 'No reply from the repeater. Try again.'; +const String kContactListTimeoutSentence = + "Your radio didn't finish sending its contact list. Try again."; const String kAdminCommandPendingSentence = 'The radio is still finishing the previous request. ' 'Wait for its reply, or close Manage and try again.'; @@ -531,13 +533,22 @@ class RepeaterAdminSession extends ChangeNotifier { } void _fail(String message) { + debugLog('[RADMIN] Failed: $message'); _lastError = message; _state = RepeaterAdminState.failed; _notify(); } Future _ensureContact() async { - final contacts = await _connection.getContacts(); + final List contacts; + try { + contacts = await _connection.getContacts(); + } on TimeoutException { + // A local read, not the mesh: it must not read as a wrong password or + // count towards the automatic route reset. + debugWarn('[RADMIN] Contact list read timed out'); + throw const RepeaterAdminFailure(kContactListTimeoutSentence); + } final existing = _findContact(contacts); if (existing != null) { // Leave it alone: an add would wipe the learned route. diff --git a/test/services/meshcore/repeater_admin_frames_test.dart b/test/services/meshcore/repeater_admin_frames_test.dart index 529e9c6c..e2a4fca2 100644 --- a/test/services/meshcore/repeater_admin_frames_test.dart +++ b/test/services/meshcore/repeater_admin_frames_test.dart @@ -350,6 +350,63 @@ void main() { expect((await second).single.publicKeyHex, '09' * 32); }); + test('a list that streams longer than the silence window still completes', + () async { + // The radio streams about one contact per 60 ms, so a 350 entry table + // runs past any fixed total. Each frame keeps the read alive. + final future = connection.getContacts( + silenceTimeout: const Duration(milliseconds: 80), + timeout: const Duration(seconds: 5)); + await transport.settle(); + transport.emit([ResponseCodes.contactsStart, 8, 0, 0, 0]); + for (var i = 1; i <= 8; i++) { + await Future.delayed(const Duration(milliseconds: 40)); + transport.emit( + [ResponseCodes.contact, ...contactPayload(pubkey: key(i))]); + } + await Future.delayed(const Duration(milliseconds: 40)); + transport.emit([ResponseCodes.endOfContacts, 0, 0, 0, 0]); + expect((await future).length, 8); + }); + + test('a stream that goes quiet times out after the silence window', + () async { + final future = connection.getContacts( + silenceTimeout: const Duration(milliseconds: 80), + timeout: const Duration(seconds: 5)); + await transport.settle(); + transport.emit([ResponseCodes.contactsStart, 2, 0, 0, 0]); + transport.emit( + [ResponseCodes.contact, ...contactPayload(pubkey: key(1))]); + await expectLater(future, throwsA(isA())); + expect(connection.hasPendingAdminCommand, isFalse); + }); + + test('no reply at all times out after the silence window', () async { + final future = connection.getContacts( + silenceTimeout: const Duration(milliseconds: 80), + timeout: const Duration(seconds: 5)); + await expectLater(future, throwsA(isA())); + }); + + test('a stream that never ends hits the overall ceiling', () async { + final future = connection.getContacts( + silenceTimeout: const Duration(milliseconds: 80), + timeout: const Duration(milliseconds: 300)); + await transport.settle(); + transport.emit([ResponseCodes.contactsStart, 99, 0, 0, 0]); + var done = false; + unawaited(future.then((_) {}, onError: (_) {}).whenComplete(() { + done = true; + })); + for (var i = 1; i <= 20 && !done; i++) { + await Future.delayed(const Duration(milliseconds: 30)); + transport.emit( + [ResponseCodes.contact, ...contactPayload(pubkey: key(i))]); + } + await expectLater(future, throwsA(isA())); + }); + test('ERR while iterating fails with the radio code', () async { final future = connection.getContacts(); await transport.settle(); diff --git a/test/services/repeater_admin/repeater_admin_session_test.dart b/test/services/repeater_admin/repeater_admin_session_test.dart index 48d9a63c..e71bf79f 100644 --- a/test/services/repeater_admin/repeater_admin_session_test.dart +++ b/test/services/repeater_admin/repeater_admin_session_test.dart @@ -192,6 +192,22 @@ void main() { expect(session.busy, isFalse); }); + test('a contact list that never finishes is not a password problem', + () async { + final future = session.login('admin-pw'); + await transport.settle(); + expect(commands(), [CommandCodes.getContacts]); + transport.emit([ResponseCodes.contactsStart, 2, 0, 0, 0]); + transport.emit([ResponseCodes.contact, ...contactPayload(otherKey)]); + // The radio goes quiet before END_OF_CONTACTS: the silence window + // (5 s by default) runs out and no login is ever sent. + expect(await future, isFalse); + expect(session.state, RepeaterAdminState.failed); + expect(session.lastError, kContactListTimeoutSentence); + expect(commands(), [CommandCodes.getContacts]); + expect(session.busy, isFalse); + }, timeout: const Timeout(Duration(seconds: 15))); + test('a zero-hop contact reads as a direct route', () async { final future = session.login('admin-pw'); await answerContacts([contactPayload(repeaterKey, outPathLen: 0x80)]);