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 <noreply@anthropic.com>
feat/64-observer-config
Strycher 4 weeks ago
parent 6cd707218d
commit 4f919885f6

@ -169,6 +169,7 @@ class MeshCoreConnector extends ChangeNotifier {
StreamSubscription<List<int>>? _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) {

@ -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,
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.