From 4f919885f6a15bf4af32dfdd8a2c5659d0103b6c Mon Sep 17 00:00:00 2001 From: Strycher Date: Tue, 23 Jun 2026 03:18:50 -0400 Subject: [PATCH] fix(#88): cap the BLE APP_START handshake retry with backoff + keep-alive The BLE/USB self-info retry was an unbounded 3.5s hammer -- the Web path caps at 3 and skips while syncing; BLE had neither. On a slow device handshake it churned the connect and starved the contact sync, hanging the contact-load bar. Inherited from upstream zjs81/meshcore-open (git blame; zero Offband commits). - Replace the unbounded 3.5s Timer.periodic with a self-rescheduling backoff: 3.5s -> 7 -> 14 -> 28 -> 56s, then a steady ~60s keep-alive. Never hard-stops -- a slow/recovering device still gets prompted. - Add the Web path's skip-while-syncing guard on BLE (per-tick skip, never permanent: the timer keeps rescheduling, so a settled sync lets the next tick send). - Decisions extracted as pure, tested helpers: nextAppStartRetryDelay(attempt) and shouldSendAppStartRetry({connected, awaitingSelfInfo, syncing}). - Disconnect teardown (Gemini's amendment) was already present (:2501 / :2374). Gemini plan-review: Proceed, no blockers. 6 new helper tests; full suite 299 green. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 61 +++++++++--- ...shcore_connector_app_start_retry_test.dart | 95 +++++++++++++++++++ 2 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 test/connector/meshcore_connector_app_start_retry_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0d2ff1f..1f90f7e 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -169,6 +169,7 @@ class MeshCoreConnector extends ChangeNotifier { StreamSubscription>? _notifySubscription; Timer? _notifyListenersTimer; Timer? _selfInfoRetryTimer; + int _appStartRetryAttempt = 0; Timer? _reconnectTimer; Timer? _batteryPollTimer; Timer? _gpsLocationPollTimer; @@ -2747,6 +2748,25 @@ class MeshCoreConnector extends ChangeNotifier { _scheduleSelfInfoRetry(); } + /// Backoff for the BLE/USB APP_START handshake retry: 3.5s, 7s, 14s, 28s, + /// 56s, then a steady ~60s keep-alive. Replaces the old unbounded 3.5s hammer + /// that churned a slow connect and starved the contact sync (#88). + static Duration nextAppStartRetryDelay(int attempt) { + final ms = (3500 * (1 << attempt.clamp(0, 5))).clamp(3500, 60000); + return Duration(milliseconds: ms); + } + + /// Whether to send an APP_START retry this tick: only while still connected + /// and awaiting SELF_INFO, and never while an initial sync is in flight (the + /// Web-path guard, so the retry doesn't contend with the contact/channel + /// sync). The timer keeps rescheduling, so the skip is per-tick, not + /// permanent — once a sync settles, the next tick sends. (#88) + static bool shouldSendAppStartRetry({ + required bool connected, + required bool awaitingSelfInfo, + required bool syncing, + }) => connected && awaitingSelfInfo && !syncing; + void _scheduleSelfInfoRetry() { _selfInfoRetryTimer?.cancel(); if (PlatformInfo.isWeb && @@ -2771,19 +2791,36 @@ class MeshCoreConnector extends ChangeNotifier { }); return; } - _selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), ( - timer, - ) { - if (!isConnected) { - timer.cancel(); - return; - } - if (!_awaitingSelfInfo) { - timer.cancel(); - return; - } + // BLE/USB: bounded backoff -> slow keep-alive (was an unbounded 3.5s + // hammer). A slow handshake no longer churns the connect or contends with + // the initial sync, but the retry never hard-stops — a slow/recovering + // device still gets prompted (#88). + _appStartRetryAttempt = 0; + _armAppStartRetry(); + } + + void _armAppStartRetry() { + _selfInfoRetryTimer?.cancel(); + if (!isConnected || !_awaitingSelfInfo) return; + _selfInfoRetryTimer = Timer( + nextAppStartRetryDelay(_appStartRetryAttempt), + _onAppStartRetryTick, + ); + } + + void _onAppStartRetryTick() { + if (!isConnected || !_awaitingSelfInfo) return; + final syncing = + _isLoadingContacts || _isSyncingChannels || _channelSyncInFlight; + if (shouldSendAppStartRetry( + connected: isConnected, + awaitingSelfInfo: _awaitingSelfInfo, + syncing: syncing, + )) { unawaited(sendFrame(buildAppStartFrame())); - }); + if (_appStartRetryAttempt < 5) _appStartRetryAttempt++; + } + _armAppStartRetry(); } Contact getFromDiscovered(Contact contact) { diff --git a/test/connector/meshcore_connector_app_start_retry_test.dart b/test/connector/meshcore_connector_app_start_retry_test.dart new file mode 100644 index 0000000..fbef5e0 --- /dev/null +++ b/test/connector/meshcore_connector_app_start_retry_test.dart @@ -0,0 +1,95 @@ +// APP_START handshake-retry policy helpers (#88). +// +// The BLE/USB handshake retry was an unbounded 3.5s hammer; on a slow device it +// churned the connect and starved the contact sync (stuck red bar). These pin +// the replacement policy: an exponential backoff that settles into a slow +// keep-alive (never a hard stop), and a send-gate that skips while an initial +// sync is in flight (mirroring the Web path). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; + +void main() { + group('nextAppStartRetryDelay', () { + test('ramps 3.5 -> 7 -> 14 -> 28 -> 56s then holds at 60s', () { + expect( + MeshCoreConnector.nextAppStartRetryDelay(0), + const Duration(milliseconds: 3500), + ); + expect( + MeshCoreConnector.nextAppStartRetryDelay(1), + const Duration(seconds: 7), + ); + expect( + MeshCoreConnector.nextAppStartRetryDelay(2), + const Duration(seconds: 14), + ); + expect( + MeshCoreConnector.nextAppStartRetryDelay(3), + const Duration(seconds: 28), + ); + expect( + MeshCoreConnector.nextAppStartRetryDelay(4), + const Duration(seconds: 56), + ); + expect( + MeshCoreConnector.nextAppStartRetryDelay(5), + const Duration(seconds: 60), + ); + }); + + test('never hammers (>= 3.5s) and never exceeds the 60s keep-alive', () { + for (var attempt = 0; attempt < 64; attempt++) { + final d = MeshCoreConnector.nextAppStartRetryDelay(attempt); + expect(d.inMilliseconds, greaterThanOrEqualTo(3500)); + expect(d.inMilliseconds, lessThanOrEqualTo(60000)); + } + }); + }); + + group('shouldSendAppStartRetry', () { + test('sends only while connected, awaiting self-info, and not syncing', () { + expect( + MeshCoreConnector.shouldSendAppStartRetry( + connected: true, + awaitingSelfInfo: true, + syncing: false, + ), + isTrue, + ); + }); + + test('does not send when disconnected', () { + expect( + MeshCoreConnector.shouldSendAppStartRetry( + connected: false, + awaitingSelfInfo: true, + syncing: false, + ), + isFalse, + ); + }); + + test('does not send once the handshake is no longer awaited', () { + expect( + MeshCoreConnector.shouldSendAppStartRetry( + connected: true, + awaitingSelfInfo: false, + syncing: false, + ), + isFalse, + ); + }); + + test('skips while an initial sync is in flight', () { + expect( + MeshCoreConnector.shouldSendAppStartRetry( + connected: true, + awaitingSelfInfo: true, + syncing: true, + ), + isFalse, + ); + }); + }); +}