8000 fix(session): bound automatic reconnect and stop retrying a refused credential by shroominic · Pull Request #182 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
7FFF
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion openhealth/lib/src/app_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import 'session_presentation.dart';
typedef LiveActivityPrivacySetter =
Future<void> Function({required bool enabled});

/// Closed diagnostic for a retry run the controller stopped on its own.
const String automaticReconnectExhaustedCode = 'cgm.session.reconnectExhausted';

void _debugAppSessionTrace(String milestone) {
assert(() {
// Debug-only closed milestones. No device identifiers, sensor values,
Expand Down Expand Up @@ -64,9 +67,17 @@ class CgmAppController extends ChangeNotifier {
static const _resumeCountMetadataKey = 'resumeCount';
static const _resumeHistoryMetadataKey = 'resumeHistory';

/// How many automatic reconnect attempts one run may spend without the user.
///
/// With the default delay this backoff reaches 3, 6, 12, 24, 48 seconds and
/// then stops - about 93 seconds of trying - after which the host asks for a
/// decision instead of retrying a dead link every three seconds forever.
static const int _maxAutomaticReconnectAttempts = 5;

final SharedPreferences _preferences;
final HealthStateStore _healthStateStore;
final Duration _reconnectDelay;
int _reconnectAttempts = 0;
final CgmDriver _driver;
final LiveActivityPrivacySetter? _liveActivityPrivacySetter;
final Future<void> Function()? _liveActivityPrivacyRefresh;
Expand Down Expand Up @@ -673,6 +684,10 @@ class CgmAppController extends ChangeNotifier {
} else {
_cancelReconnect();
}
if (nextSnapshot.stage == CgmSyncStage.ready) {
// The link proved itself, so the next run starts with a full budget.
_reconnectAttempts = 0;
}
_startPlatformTask(
_pushLiveActivity(),
'Updating private lock-screen state',
Expand Down Expand Up @@ -2195,6 +2210,10 @@ class CgmAppController extends ChangeNotifier {
!_canAutomaticallyReconnect(currentSnapshot)) {
return;
}
if (_reconnectAttempts >= _maxAutomaticReconnectAttempts) {
_stopAutomaticReconnect(currentSnapshot);
return;
}
if (currentSnapshot != null &&
(currentSnapshot.latestReading != null ||
currentSnapshot.history.isNotEmpty) &&
Expand All @@ -2207,8 +2226,9 @@ class CgmAppController extends ChangeNotifier {
);
notifyListeners();
}
_reconnectTimer = Timer(_reconnectDelay, () {
_reconnectTimer = Timer(_reconnectBackoff, () {
_reconnectTimer = null;
_reconnectAttempts += 1;
final sensor = _selectedSensor;
if (sensor == null) {
return;
Expand All @@ -2229,6 +2249,44 @@ class CgmAppController extends ChangeNotifier {
});
}

/// Doubling backoff, so a sensor that stays away costs the radio less each
/// round instead of a flat delay repeated without limit.
Duration get _reconnectBackoff =>
_reconnectDelay * (1 << _reconnectAttempts.clamp(0, 8));

/// Ends the retry run with the state a user can act on.
///
/// The failure carries a closed diagnostic and blocks further automatic
/// reconnect, so the connect screen offers Try again / Choose another sensor
/// instead of the app retrying in the background forever.
void _stopAutomaticReconnect(CgmSessionSnapshot? currentSnapshot) {
_cancelReconnect();
if (currentSnapshot == null) {
return;
}
final failure = BleFailure(
kind: BleFailureKind.deviceDisconnected,
operation: BleOperation.connect,
diagnosticCode: automaticReconnectExhaustedCode,
);
_snapshot = currentSnapshot.copyWith(
stage: CgmSyncStage.error,
statusText: 'Reconnect stopped',
lastError: automaticReconnectExhaustedCode,
metadata: <String, String>{
...currentSnapshot.metadata,
...failure.toMetadata(),
cgmAutomaticReconnectAllowedMetadataKey: 'false',
},
);
_lastError = 'Reconnect stopped. Try again when the sensor is close.';
_startPlatformTask(
_pushLiveActivity(),
'Updating private lock-screen state',
);
notifyListeners();
}

void _cancelReconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
Expand Down
58 changes: 58 additions & 0 deletions openhealth/test/app_controller_persistence_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2643,6 +2643,63 @@ void main() {
},
);

test('a sensor that keeps dropping exhausts the retry budget', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final preferences = await SharedPreferences.getInstance();
final sensor = _testSensor();
final first = _ControlledSession(
_testSnapshot(sensor, stage: CgmSyncStage.ready),
);
final driver = _ControlledDriver(<_ControlledSession>[
first,
for (var index = 0; index < 40; index += 1)
_ControlledSession(
_testSnapshot(sensor, stage: CgmSyncStage.disconnected),
),
]);
final controller = CgmAppController(
preferences: preferences,
driver: driver,
healthStateStore: _ControllableHealthStateStore(),
reconnectDelay: const Duration(milliseconds: 1),
);

await controller.initialize();
await controller.connect(sensor);
await _drainEventQueue();
first.emit(_testSnapshot(sensor, stage: CgmSyncStage.disconnected));
await _drainEventQueue();

// Far more rounds than any honest retry budget, and every round drops the
// link again, so only the budget itself can stop the controller.
for (var round = 0; round < 12; round += 1) {
await Future<void>.delayed(const Duration(milliseconds: 40));
await _drainEventQueue();
if (driver.connectedSensors.length <= 1) {
continue;
}
driver.sessions[driver.connectedSensors.length - 1].emit(
_testSnapshot(sensor, stage: CgmSyncStage.disconnected),
);
await _drainEventQueue();
}

expect(
driver.connectedSensors,
hasLength(6),
reason: 'one attempt plus a bounded retry run',
);
final stopped = controller.snapshot;
expect(stopped?.stage, CgmSyncStage.error);
expect(stopped?.metadata[cgmAutomaticReconnectAllowedMetadataKey], 'false');
expect(controller.connectionRequiresUserAction, isTrue);
expect(stopped?.lastError, 'cgm.session.reconnectExhausted');

await controller.disconnect();
controller.dispose();
await driver.close();
});

test('initial ready snapshot disables activation on reconnect', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final preferences = await SharedPreferences.getInstance();
Expand Down Expand Up @@ -3046,6 +3103,7 @@ class _ControlledDriver implements CgmDriver {
_ControlledDriver(this._sessions, {this.driverId = 'controlled'});

final List<_ControlledSession> _sessions;
List<_ControlledSession> get sessions => _sessions;
final List<DiscoveredSensor> connectedSensors = <DiscoveredSensor>[];
int _nextSession = 0;

Expand Down
20 changes: 20 additions & 0 deletions packages/cgm_cbio/lib/src/cbio_glucose_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ abstract final class CbioSessionFailure {
static const String disconnected = 'cbio.disconnected';
}

/// Whether a closed failure code is worth another automatic attempt.
///
/// The host will reconnect a link that dropped, timed out, or never came up.
/// It must not reconnect a failure that is reproduced byte for byte: a
/// credential this build cannot read, a credential the sensor already refused,
/// and a firmware that does not present the GS1 link surface this build
/// expects. Each of those is a decision for the user, not for a timer.
bool cbioFailureAllowsAutomaticReconnect(String code) => switch (code) {
CbioSessionFailure.authMaterial ||
CbioSessionFailure.authRejected ||
CbioSessionFailure.topology => false,
_ => true,
};

/// Bounded timings for one session. Every window has a deadline.
final class CbioSessionTiming {
const CbioSessionTiming({
Expand Down Expand Up @@ -237,6 +251,7 @@ final class CbioGlucoseSession implements CgmSession {
bool _anchorLogged = false;
bool _catchUpOpen = false;
bool _budgetExhausted = false;
bool _automaticReconnectAllowed = true;
bool _closing = false;
bool _linkDropped = false;

Expand Down Expand Up @@ -837,6 +852,8 @@ final class CbioGlucoseSession implements CgmSession {
cbioPhaseMetadataKey: _phase,
'cgm.cbio.unit': 'provisional',
...?anchor?.toMetadata(),
if (!_automaticReconnectAllowed)
cgmAutomaticReconnectAllowedMetadataKey: 'false',
},
lastError: _lastError,
clearLastError: _lastError == null,
Expand Down Expand Up @@ -875,6 +892,9 @@ final class CbioGlucoseSession implements CgmSession {

void _fail(String code, {String statusText = 'Connection failed'}) {
_cancelTimers();
if (!cbioFailureAllowsAutomaticReconnect(code)) {
_automaticReconnectAllowed = false;
}
_setPhase(
CbioSessionPhase.failed,
CgmSyncStage.error,
Expand Down
52 changes: 52 additions & 0 deletions packages/cgm_cbio/test/cbio_glucose_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,33 @@ void main() {
await session.disconnect();
});

test('a refused credential is not worth an automatic retry', () async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
await _defaultResponder(connection, authReply: _authRejected);

final session = CbioGlucoseSession(
sensor: _sensor,
transport: transport,
credentials: _syntheticSource,
timing: _fastTiming,
);
await session.initialize();
await _pumpUntil(
() => session.currentSnapshot.stage == CgmSyncStage.error,
);

expect(session.currentSnapshot.lastError, 'cbio.auth.rejected');
expect(
session
.currentSnapshot
.metadata[cgmAutomaticReconnectAllowedMetadataKey],
'false',
reason: 'the same credential would be refused byte for byte',
);
await session.disconnect();
});

test('an unanswered authentication times out without reads', () async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
Expand All @@ -458,6 +485,31 @@ void main() {
await session.disconnect();
});

test('a link that never came up stays worth a retry', () async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
connection. async {};

final session = CbioGlucoseSession(
sensor: _sensor,
transport: transport,
credentials: _syntheticSource,
timing: _fastTiming,
);
await session.initialize();
await _pumpUntil(
() => session.currentSnapshot.stage == CgmSyncStage.error,
);

expect(session.currentSnapshot.lastError, 'cbio.auth.timeout');
expect(
session.currentSnapshot.metadata,
isNot(contains(cgmAutomaticReconnectAllowedMetadataKey)),
reason: 'an unanswered link is a transient radio state',
);
await session.disconnect();
});

test('never logs the link credential', () async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
Expand Down
Loading
0