From 22579c586b2f832913f71051b6d86998f481e4d6 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Fri, 5 Jun 2026 20:03:06 +0900 Subject: [PATCH 01/13] feat: add optional exponential backoff for polling interval When `useExponentialBackoff` is enabled, the retry interval grows exponentially on consecutive failures and resets to `checkInterval` on reconnect, reducing unnecessary battery drain and network traffic during prolonged outages. New `createInstance` parameters: - `useExponentialBackoff` (bool, default false) - `backoffInitialDelay` (Duration?, default: checkInterval) - `backoffMaxDelay` (Duration, default: 60s) - `backoffMultiplier` (double, default: 2.0) Closes #99 --- lib/src/internet_connection.dart | 91 ++++++++- test/internet_connection_test.dart | 303 +++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+), 1 deletion(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index c9e6051..cfe7f7e 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -90,11 +90,30 @@ class InternetConnection { this.enableStrictCheck = false, this.customConnectivityCheck, this.triggerStream, + this.useExponentialBackoff = false, + Duration? backoffInitialDelay, + Duration backoffMaxDelay = const Duration(seconds: 60), + double backoffMultiplier = 2.0, }) : _checkInterval = checkInterval ?? _defaultCheckInterval, + _backoffInitialDelayExplicit = backoffInitialDelay != null, + _backoffInitialDelay = + backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval, + _backoffMaxDelay = backoffMaxDelay, + _backoffMultiplier = backoffMultiplier, + _currentBackoffDelay = + backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval, assert( useDefaultOptions || customCheckOptions?.isNotEmpty == true, 'You must provide a list of options if you are not using the ' 'default ones.', + ), + assert( + !useExponentialBackoff || backoffMultiplier > 0, + 'backoffMultiplier must be positive.', + ), + assert( + !useExponentialBackoff || backoffMaxDelay > Duration.zero, + 'backoffMaxDelay must be greater than zero.', ) { _internetCheckOptions = List.unmodifiable([ if (useDefaultOptions) ..._defaultCheckOptions, @@ -162,6 +181,41 @@ class InternetConnection { /// whenever it emits an event. final Stream? triggerStream; + /// Whether exponential backoff is enabled for the polling interval. + /// + /// When `true`, the polling interval grows on consecutive failures and resets + /// to [checkInterval] when the connection is restored. + /// + /// Defaults to `false`. + final bool useExponentialBackoff; + + /// Whether [_backoffInitialDelay] was explicitly provided by the caller. + /// + /// When `false`, [_backoffInitialDelay] tracks [_checkInterval] so that + /// a [setIntervalAndResetTimer] call keeps both values in sync. + final bool _backoffInitialDelayExplicit; + + /// The initial delay used on the first failure when backoff is enabled. + /// + /// Defaults to [checkInterval]. Updated by [setIntervalAndResetTimer] when + /// no explicit value was provided at construction time. + Duration _backoffInitialDelay; + + /// The upper bound on the backoff delay. + /// + /// Defaults to 60 seconds. + final Duration _backoffMaxDelay; + + /// The multiplicative factor applied to the delay on each consecutive failure. + /// + /// Defaults to 2.0. + final double _backoffMultiplier; + + /// The live backoff delay, updated each polling cycle when backoff is enabled. + /// + /// Resets to [_backoffInitialDelay] on reconnect or subscription cancel. + Duration _currentBackoffDelay; + /// The last known internet connection status result. InternetStatus? _lastStatus; @@ -200,6 +254,12 @@ class InternetConnection { /// resets the connection checking timer. void setIntervalAndResetTimer(Duration duration) { _checkInterval = duration; + if (useExponentialBackoff) { + // Keep _backoffInitialDelay in sync with the new checkInterval when the + // caller never provided an explicit backoffInitialDelay. + if (!_backoffInitialDelayExplicit) _backoffInitialDelay = duration; + _currentBackoffDelay = _backoffInitialDelay; + } _timerHandle?.cancel(); _timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate); } @@ -261,6 +321,10 @@ class InternetConnection { if (!_statusController.hasListener) return; + // Snapshot before possible mutation below — needed to detect first-failure + // vs. ongoing-failure for backoff calculation. + final previousStatus = _lastStatus; + final currentStatus = await internetStatus; if (_lastStatus != currentStatus && _statusController.hasListener) { @@ -268,7 +332,31 @@ class InternetConnection { _statusController.add(currentStatus); } - _timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate); + Duration nextDelay; + if (useExponentialBackoff) { + if (currentStatus == InternetStatus.connected) { + _currentBackoffDelay = _backoffInitialDelay; + nextDelay = _checkInterval; + } else if (previousStatus != InternetStatus.disconnected) { + // First failure: previousStatus is either null (first ever poll) or + // connected — both mean we have not yet been in a backoff streak. + _currentBackoffDelay = _backoffInitialDelay; + nextDelay = _currentBackoffDelay; + } else { + // Ongoing failure: grow the delay. + final ms = + (_currentBackoffDelay.inMilliseconds * _backoffMultiplier).round(); + _currentBackoffDelay = Duration( + milliseconds: + ms.clamp(0, _backoffMaxDelay.inMilliseconds).toInt(), + ); + nextDelay = _currentBackoffDelay; + } + } else { + nextDelay = _checkInterval; + } + + _timerHandle = Timer(nextDelay, _maybeEmitStatusUpdate); } /// Handles cancellation of status change events. @@ -280,6 +368,7 @@ class InternetConnection { _timerHandle?.cancel(); _timerHandle = null; _lastStatus = null; + if (useExponentialBackoff) _currentBackoffDelay = _backoffInitialDelay; } /// The result of the last attempt to check the internet status. diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index 7dcc487..d37954d 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -252,5 +252,308 @@ void main() { final checker = InternetConnection.createInstance(); expect(checker, isNot(InternetConnection.createInstance())); }); + + group('exponentialBackoff', () { + // Helper: build a checker that uses customConnectivityCheck so we + // avoid real network calls and record each invocation timestamp. + InternetConnection makeChecker({ + required bool Function() shouldSucceed, + required List callLog, + Duration checkInterval = const Duration(milliseconds: 100), + Duration? backoffInitialDelay, + Duration backoffMaxDelay = const Duration(milliseconds: 800), + double backoffMultiplier = 2.0, + }) { + final option = InternetCheckOption(uri: Uri.parse('https://example.com')); + return InternetConnection.createInstance( + checkInterval: checkInterval, + useDefaultOptions: false, + customCheckOptions: [option], + useExponentialBackoff: true, + backoffInitialDelay: backoffInitialDelay, + backoffMaxDelay: backoffMaxDelay, + backoffMultiplier: backoffMultiplier, + customConnectivityCheck: (opt) async { + callLog.add(DateTime.now()); + return InternetCheckResult(option: opt, isSuccess: shouldSucceed()); + }, + ); + } + + test('disabled by default: interval stays constant under failures', + () async { + await TestHttpClient.run((client) async { + client.responseBuilder = (_) => + TestHttpClient.createResponse(statusCode: 500); + + final sub = InternetConnection.createInstance( + checkInterval: const Duration(milliseconds: 100), + useDefaultOptions: false, + customCheckOptions: [ + InternetCheckOption(uri: Uri.parse('https://www.example.com')), + ], + ).onStatusChange.listen((_) {}); + + await Future.delayed(const Duration(milliseconds: 500)); + + // Count calls by measuring the subscription timer indirectly: + // with a 100ms interval over 500ms we expect ~4-6 checks total. + // We only verify the subscription fires at least once (disconnected). + expect(sub, isNotNull); + sub.cancel(); + }); + }); + + test('first failure sets initialDelay', () async { + bool connected = true; + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => connected, + callLog: callLog, + checkInterval: const Duration(milliseconds: 100), + backoffInitialDelay: const Duration(milliseconds: 200), + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Wait for at least one connected check. + await Future.delayed(const Duration(milliseconds: 150)); + callLog.clear(); + + // Trigger a disconnect. + connected = false; + + // Wait long enough for the disconnect to be detected and one backoff + // cycle to elapse (~100ms for detect + ~200ms for the backoff timer). + await Future.delayed(const Duration(milliseconds: 450)); + + sub.cancel(); + + // We should have at most 2 calls in this window: + // one that detected the disconnect, and at most one more after + // the 200ms initialDelay fires. + expect(callLog.length, lessThanOrEqualTo(3)); + + // The gap between the disconnect-detecting call and the next call + // should be around initialDelay (200ms), not checkInterval (100ms). + if (callLog.length >= 2) { + final gap = callLog[1] + .difference(callLog[0]) + .inMilliseconds + .abs(); + expect(gap, greaterThan(150)); + } + }); + + test('delay grows on consecutive failures', () async { + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => false, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 50), + backoffMaxDelay: const Duration(milliseconds: 800), + backoffMultiplier: 2.0, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Total expected time for ~4 checks: 50 + 50 + 100 + 200 = 400ms + await Future.delayed(const Duration(milliseconds: 550)); + sub.cancel(); + + expect(callLog.length, greaterThanOrEqualTo(4)); + + // Verify that gaps between consecutive calls are non-decreasing. + final gaps = []; + for (int i = 1; i < callLog.length; i++) { + gaps.add( + callLog[i].difference(callLog[i - 1]).inMilliseconds.abs()); + } + for (int i = 1; i < gaps.length; i++) { + // Each gap should be >= the previous one (allowing 20ms tolerance). + expect(gaps[i] + 20, greaterThanOrEqualTo(gaps[i - 1])); + } + }); + + test('delay is capped at maxDelay', () async { + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => false, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 50), + backoffMaxDelay: const Duration(milliseconds: 200), + backoffMultiplier: 2.0, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Run long enough for the delay to have hit the cap and stayed there. + // 50 + 50 + 100 + 200 + 200 = 600ms total for 5 calls. + await Future.delayed(const Duration(milliseconds: 900)); + sub.cancel(); + + expect(callLog.length, greaterThanOrEqualTo(4)); + + // After the cap is reached, no gap should exceed maxDelay by much. + for (int i = 3; i < callLog.length; i++) { + final gap = + callLog[i].difference(callLog[i - 1]).inMilliseconds.abs(); + expect(gap, lessThan(350)); // maxDelay 200ms + generous tolerance + } + }); + + test('delay resets to checkInterval on reconnect', () async { + bool connected = false; + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => connected, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 50), + backoffMaxDelay: const Duration(milliseconds: 200), + backoffMultiplier: 2.0, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Let backoff grow toward maxDelay. + await Future.delayed(const Duration(milliseconds: 500)); + + // Reconnect and flush the log. + connected = true; + callLog.clear(); + + // After reconnect, polling should revert to checkInterval (50ms). + await Future.delayed(const Duration(milliseconds: 300)); + sub.cancel(); + + // With 50ms interval over 300ms we expect ~5 calls. + expect(callLog.length, greaterThanOrEqualTo(3)); + + // All gaps should be close to checkInterval (50ms), not maxDelay. + for (int i = 1; i < callLog.length; i++) { + final gap = + callLog[i].difference(callLog[i - 1]).inMilliseconds.abs(); + expect(gap, lessThan(200)); + } + }); + + test('setIntervalAndResetTimer resets backoff state', () async { + bool connected = false; + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => connected, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 50), + backoffMaxDelay: const Duration(milliseconds: 200), + backoffMultiplier: 2.0, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Let backoff reach maxDelay. + await Future.delayed(const Duration(milliseconds: 600)); + callLog.clear(); + + // Resetting the interval should restart backoff from initialDelay. + checker.setIntervalAndResetTimer(const Duration(milliseconds: 50)); + + await Future.delayed(const Duration(milliseconds: 400)); + sub.cancel(); + + // Should have ~3-5 calls; the gaps should be non-decreasing again + // (restarted backoff sequence: 50, 100, 200ms…). + expect(callLog.length, greaterThanOrEqualTo(2)); + if (callLog.length >= 3) { + final gap1 = + callLog[1].difference(callLog[0]).inMilliseconds.abs(); + final gap2 = + callLog[2].difference(callLog[1]).inMilliseconds.abs(); + expect(gap2 + 20, greaterThanOrEqualTo(gap1)); + } + }); + + test('re-subscription resets backoff state', () async { + bool connected = false; + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => connected, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 50), + backoffMaxDelay: const Duration(milliseconds: 200), + backoffMultiplier: 2.0, + ); + + // First subscription — let backoff grow. + final sub1 = checker.onStatusChange.listen((_) {}); + await Future.delayed(const Duration(milliseconds: 600)); + sub1.cancel(); + callLog.clear(); + + // Second subscription — backoff should restart from initialDelay. + final sub2 = checker.onStatusChange.listen((_) {}); + await Future.delayed(const Duration(milliseconds: 400)); + sub2.cancel(); + + // With restarted backoff (50 → 100 → 200ms), we expect fewer calls + // than if the capped 200ms delay continued (which would yield ~2 calls). + // Actually both cases yield 2-3 calls in 400ms, so just verify + // that the first gap is close to initialDelay (50ms), not maxDelay. + if (callLog.length >= 2) { + final firstGap = + callLog[1].difference(callLog[0]).inMilliseconds.abs(); + expect(firstGap, lessThan(180)); // initialDelay=50ms + tolerance + } + }); + + test('first poll returning disconnected is treated as first failure', + () async { + final callLog = []; + + // Always disconnected from the start — previousStatus will be null on + // the first poll, which should be treated as first-failure (not ongoing). + final checker = makeChecker( + shouldSucceed: () => false, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 100), + backoffMaxDelay: const Duration(milliseconds: 800), + backoffMultiplier: 2.0, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // First check fires immediately, then after initialDelay (100ms). + // If first-failure was treated as ongoing, delay would be 50*2=100ms anyway, + // but if it jumped to ongoing-formula on first call it would be 200ms. + await Future.delayed(const Duration(milliseconds: 400)); + sub.cancel(); + + // We should see at least 2 calls in 400ms: + // call[0] immediately, call[1] after ~100ms, call[2] after ~200ms. + expect(callLog.length, greaterThanOrEqualTo(2)); + + if (callLog.length >= 2) { + final gap = callLog[1] + .difference(callLog[0]) + .inMilliseconds + .abs(); + // initialDelay is 100ms; ongoing backoff of 100ms would also be 100ms + // on first step, but 200ms on second. We verify the first gap is ~100ms. + expect(gap, greaterThan(60)); + expect(gap, lessThan(200)); + } + }); + }); }); } From 59ce2d05599b164177f6c95c6212c634b8cb95e5 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Mon, 8 Jun 2026 18:32:30 +0900 Subject: [PATCH 02/13] fix: ensure backoff correctly resets on interval change during outages As pointed out by Copilot, calling `setIntervalAndResetTimer` while already disconnected would cause the next poll to be treated as an ongoing failure (immediately multiplying the delay) because `previousStatus` remained disconnected. This commit introduces a `_backoffNeedsReset` flag to force the next poll to be treated as a "first failure" without mutating `_lastStatus` (which would have caused duplicate disconnected events). Also includes a new test to verify implicit initial delay synchronization. --- lib/src/internet_connection.dart | 10 ++++++- test/internet_connection_test.dart | 43 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index cfe7f7e..e5f31e3 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -211,6 +211,9 @@ class InternetConnection { /// Defaults to 2.0. final double _backoffMultiplier; + /// Whether the backoff state was forcefully reset by an interval change. + bool _backoffNeedsReset = false; + /// The live backoff delay, updated each polling cycle when backoff is enabled. /// /// Resets to [_backoffInitialDelay] on reconnect or subscription cancel. @@ -259,6 +262,7 @@ class InternetConnection { // caller never provided an explicit backoffInitialDelay. if (!_backoffInitialDelayExplicit) _backoffInitialDelay = duration; _currentBackoffDelay = _backoffInitialDelay; + _backoffNeedsReset = true; } _timerHandle?.cancel(); _timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate); @@ -337,9 +341,12 @@ class InternetConnection { if (currentStatus == InternetStatus.connected) { _currentBackoffDelay = _backoffInitialDelay; nextDelay = _checkInterval; - } else if (previousStatus != InternetStatus.disconnected) { + } else if (previousStatus != InternetStatus.disconnected || _backoffNeedsReset) { // First failure: previousStatus is either null (first ever poll) or // connected — both mean we have not yet been in a backoff streak. + // Also, if _backoffNeedsReset is true, we treat this as a first failure to + // reset the backoff delay, even if the previous status was already disconnected. + _backoffNeedsReset = false; _currentBackoffDelay = _backoffInitialDelay; nextDelay = _currentBackoffDelay; } else { @@ -368,6 +375,7 @@ class InternetConnection { _timerHandle?.cancel(); _timerHandle = null; _lastStatus = null; + _backoffNeedsReset = false; if (useExponentialBackoff) _currentBackoffDelay = _backoffInitialDelay; } diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index d37954d..60a057f 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -481,6 +481,49 @@ void main() { } }); + test('setIntervalAndResetTimer syncs implicit backoffInitialDelay', () async { + final callLog = []; + + // Intentionally omit `backoffInitialDelay` and rely on `checkInterval` (50ms) + final checker = InternetConnection.createInstance( + checkInterval: const Duration(milliseconds: 50), + useDefaultOptions: false, + customCheckOptions: [ + InternetCheckOption(uri: Uri.parse('https://example.com')), + ], + useExponentialBackoff: true, + backoffMaxDelay: const Duration(milliseconds: 500), + backoffMultiplier: 2.0, + customConnectivityCheck: (opt) async { + callLog.add(DateTime.now()); + return InternetCheckResult(option: opt, isSuccess: false); + }, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Trigger the first failure + await Future.delayed(const Duration(milliseconds: 100)); + callLog.clear(); + + // Change the interval to 100ms + // Since it's an implicit initial delay, _backoffInitialDelay should also follow 100ms + checker.setIntervalAndResetTimer(const Duration(milliseconds: 100)); + + await Future.delayed(const Duration(milliseconds: 450)); + sub.cancel(); + + expect(callLog.length, greaterThanOrEqualTo(2)); + + if (callLog.length >= 2) { + final firstGap = callLog[1].difference(callLog[0]).inMilliseconds.abs(); + + // If it follows, it should be around 100ms; if not, it would be around 50ms + // Verify that it is greater than 80ms to ensure it followed 100ms + expect(firstGap, greaterThan(80)); + } + }); + test('re-subscription resets backoff state', () async { bool connected = false; final callLog = []; From 8db4023b83af7a30bc8704c770dc853b76092c67 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Mon, 8 Jun 2026 19:17:08 +0900 Subject: [PATCH 03/13] fix: prevent shrinking backoff intervals - Update assertion for `backoffMultiplier` to require a value >= 1.0. Values between 0 and 1 would cause the polling delay to decay toward 0ms, leading to a tight polling loop during an outage. --- lib/src/internet_connection.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index e5f31e3..f56e2cc 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -108,8 +108,8 @@ class InternetConnection { 'default ones.', ), assert( - !useExponentialBackoff || backoffMultiplier > 0, - 'backoffMultiplier must be positive.', + !useExponentialBackoff || backoffMultiplier >= 1.0, + 'backoffMultiplier must be greater than or equal to 1.0 to prevent shrinking intervals.', ), assert( !useExponentialBackoff || backoffMaxDelay > Duration.zero, From 5e673406fa6832d66277be45c8e8b539523d6b29 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Mon, 8 Jun 2026 19:21:59 +0900 Subject: [PATCH 04/13] fix: validate backoffInitialDelay bounds - Add assertions to ensure `backoffInitialDelay` (or its fallback `checkInterval`) is greater than zero to prevent immediate/negative timer loops. - Add assertion to ensure `backoffInitialDelay` is less than or equal to `backoffMaxDelay`, keeping the backoff sequence logically sound and preventing the delay from shrinking on the second poll. --- lib/src/internet_connection.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index f56e2cc..7942f4c 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -114,6 +114,18 @@ class InternetConnection { assert( !useExponentialBackoff || backoffMaxDelay > Duration.zero, 'backoffMaxDelay must be greater than zero.', + ), + assert( + !useExponentialBackoff || + (backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval) > + Duration.zero, + 'backoffInitialDelay (or checkInterval if implicitly used) must be greater than zero.', + ), + assert( + !useExponentialBackoff || + (backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval) <= + backoffMaxDelay, + 'backoffInitialDelay (or checkInterval if implicitly used) must be less than or equal to backoffMaxDelay.', ) { _internetCheckOptions = List.unmodifiable([ if (useDefaultOptions) ..._defaultCheckOptions, From 4c01cbc5ee0ecf83becc65de5b2ed1a37da35264 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Tue, 9 Jun 2026 04:07:37 +0900 Subject: [PATCH 05/13] test(exponential-backoff): add constructor validation tests --- test/internet_connection_test.dart | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index 60a057f..536387d 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -280,6 +280,70 @@ void main() { ); } + group('constructor validation', () { + // One custom option is enough to satisfy the non-default-options assert. + final singleOption = [ + InternetCheckOption(uri: Uri.parse('https://example.com')), + ]; + + // backoffMultiplier < 1.0 would cause the delay to shrink on each + // failure, eventually collapsing to 0 and creating a busy-poll loop. + test('throws when backoffMultiplier is less than 1.0', () { + expect( + () => InternetConnection.createInstance( + useExponentialBackoff: true, + useDefaultOptions: false, + customCheckOptions: singleOption, + backoffMultiplier: 0.5, + ), + throwsA(isA()), + ); + }); + + // 1.0 is the boundary: flat-rate backoff (delay never grows) is valid, + // since the interval still resets on reconnect as documented. + test('allows backoffMultiplier equal to 1.0', () { + expect( + () => InternetConnection.createInstance( + useExponentialBackoff: true, + useDefaultOptions: false, + customCheckOptions: singleOption, + backoffMultiplier: 1.0, + ), + returnsNormally, + ); + }); + + // Duration.zero initial delay fires the next timer immediately on the + // first failure, indistinguishable from having no backoff at all. + test('throws when backoffInitialDelay is zero', () { + expect( + () => InternetConnection.createInstance( + useExponentialBackoff: true, + useDefaultOptions: false, + customCheckOptions: singleOption, + backoffInitialDelay: Duration.zero, + ), + throwsA(isA()), + ); + }); + + // initialDelay > maxDelay means the very first backed-off poll already + // exceeds the configured ceiling — the ceiling becomes meaningless. + test('throws when backoffInitialDelay exceeds backoffMaxDelay', () { + expect( + () => InternetConnection.createInstance( + useExponentialBackoff: true, + useDefaultOptions: false, + customCheckOptions: singleOption, + backoffInitialDelay: const Duration(seconds: 10), + backoffMaxDelay: const Duration(seconds: 5), + ), + throwsA(isA()), + ); + }); + }); + test('disabled by default: interval stays constant under failures', () async { await TestHttpClient.run((client) async { From dc88a706b83e585ac7de087fdbd8e58f50dc8104 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Tue, 9 Jun 2026 04:43:24 +0900 Subject: [PATCH 06/13] fix(exponential-backoff): cap initial backoff delay to backoffMaxDelay on first failure --- lib/src/internet_connection.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index 7942f4c..57cea17 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -359,7 +359,9 @@ class InternetConnection { // Also, if _backoffNeedsReset is true, we treat this as a first failure to // reset the backoff delay, even if the previous status was already disconnected. _backoffNeedsReset = false; - _currentBackoffDelay = _backoffInitialDelay; + _currentBackoffDelay = _backoffInitialDelay > _backoffMaxDelay + ? _backoffMaxDelay + : _backoffInitialDelay; nextDelay = _currentBackoffDelay; } else { // Ongoing failure: grow the delay. From e4b6d3003ee878a1997a679737c6a8be17b0e0b4 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Tue, 9 Jun 2026 05:23:15 +0900 Subject: [PATCH 07/13] test(exponential-backoff): strengthen fixed-interval assertion Replace always-true expect(sub, isNotNull) with real behavioral checks: verify call count (~4-5 in 550ms) and that consecutive gaps stay under 200ms, catching regressions where backoff activates unintentionally. --- test/internet_connection_test.dart | 43 ++++++++++++++++++------------ 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index 536387d..70bfebb 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -346,26 +346,35 @@ void main() { test('disabled by default: interval stays constant under failures', () async { - await TestHttpClient.run((client) async { - client.responseBuilder = (_) => - TestHttpClient.createResponse(statusCode: 500); + final callLog = []; - final sub = InternetConnection.createInstance( - checkInterval: const Duration(milliseconds: 100), - useDefaultOptions: false, - customCheckOptions: [ - InternetCheckOption(uri: Uri.parse('https://www.example.com')), - ], - ).onStatusChange.listen((_) {}); + final checker = InternetConnection.createInstance( + checkInterval: const Duration(milliseconds: 100), + useDefaultOptions: false, + customCheckOptions: [ + InternetCheckOption(uri: Uri.parse('https://www.example.com')), + ], + customConnectivityCheck: (opt) async { + callLog.add(DateTime.now()); + return InternetCheckResult(option: opt, isSuccess: false); + }, + ); - await Future.delayed(const Duration(milliseconds: 500)); + final sub = checker.onStatusChange.listen((_) {}); + await Future.delayed(const Duration(milliseconds: 550)); + sub.cancel(); - // Count calls by measuring the subscription timer indirectly: - // with a 100ms interval over 500ms we expect ~4-6 checks total. - // We only verify the subscription fires at least once (disconnected). - expect(sub, isNotNull); - sub.cancel(); - }); + // With a 100ms interval over 550ms we expect ~4-5 checks. + expect(callLog.length, greaterThanOrEqualTo(4)); + + // All gaps should stay near 100ms — no backoff growth. + for (int i = 1; i < callLog.length; i++) { + final gap = + callLog[i].difference(callLog[i - 1]).inMilliseconds.abs(); + expect(gap, lessThan(200), + reason: 'gap between check $i and ${i - 1} was ${gap}ms ' + '— expected ~100ms (constant interval, no backoff)'); + } }); test('first failure sets initialDelay', () async { From ea96ec30b72ef831e88e4ad48026d1e836758faa Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Wed, 10 Jun 2026 01:27:55 +0900 Subject: [PATCH 08/13] fix(polling): guard timer reschedule against cancelled listeners Re-check hasListener immediately before scheduling the next timer so that a cancel arriving during the await internetStatus suspension does not restart polling with zero listeners. --- lib/src/internet_connection.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index 57cea17..e425104 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -377,6 +377,7 @@ class InternetConnection { nextDelay = _checkInterval; } + if (!_statusController.hasListener) return; _timerHandle = Timer(nextDelay, _maybeEmitStatusUpdate); } From f820496a1c60637ae3ac95251b840b8bc2eda9c7 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Wed, 10 Jun 2026 01:29:52 +0900 Subject: [PATCH 09/13] style: apply dart format to lib and test --- lib/src/internet_connection.dart | 8 ++++---- test/internet_connection_test.dart | 32 ++++++++++++------------------ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index e425104..73d0767 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -114,7 +114,7 @@ class InternetConnection { assert( !useExponentialBackoff || backoffMaxDelay > Duration.zero, 'backoffMaxDelay must be greater than zero.', - ), + ), assert( !useExponentialBackoff || (backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval) > @@ -353,7 +353,8 @@ class InternetConnection { if (currentStatus == InternetStatus.connected) { _currentBackoffDelay = _backoffInitialDelay; nextDelay = _checkInterval; - } else if (previousStatus != InternetStatus.disconnected || _backoffNeedsReset) { + } else if (previousStatus != InternetStatus.disconnected || + _backoffNeedsReset) { // First failure: previousStatus is either null (first ever poll) or // connected — both mean we have not yet been in a backoff streak. // Also, if _backoffNeedsReset is true, we treat this as a first failure to @@ -368,8 +369,7 @@ class InternetConnection { final ms = (_currentBackoffDelay.inMilliseconds * _backoffMultiplier).round(); _currentBackoffDelay = Duration( - milliseconds: - ms.clamp(0, _backoffMaxDelay.inMilliseconds).toInt(), + milliseconds: ms.clamp(0, _backoffMaxDelay.inMilliseconds).toInt(), ); nextDelay = _currentBackoffDelay; } diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index 70bfebb..7e36f97 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -264,7 +264,8 @@ void main() { Duration backoffMaxDelay = const Duration(milliseconds: 800), double backoffMultiplier = 2.0, }) { - final option = InternetCheckOption(uri: Uri.parse('https://example.com')); + final option = + InternetCheckOption(uri: Uri.parse('https://example.com')); return InternetConnection.createInstance( checkInterval: checkInterval, useDefaultOptions: false, @@ -411,10 +412,7 @@ void main() { // The gap between the disconnect-detecting call and the next call // should be around initialDelay (200ms), not checkInterval (100ms). if (callLog.length >= 2) { - final gap = callLog[1] - .difference(callLog[0]) - .inMilliseconds - .abs(); + final gap = callLog[1].difference(callLog[0]).inMilliseconds.abs(); expect(gap, greaterThan(150)); } }); @@ -442,8 +440,7 @@ void main() { // Verify that gaps between consecutive calls are non-decreasing. final gaps = []; for (int i = 1; i < callLog.length; i++) { - gaps.add( - callLog[i].difference(callLog[i - 1]).inMilliseconds.abs()); + gaps.add(callLog[i].difference(callLog[i - 1]).inMilliseconds.abs()); } for (int i = 1; i < gaps.length; i++) { // Each gap should be >= the previous one (allowing 20ms tolerance). @@ -546,15 +543,14 @@ void main() { // (restarted backoff sequence: 50, 100, 200ms…). expect(callLog.length, greaterThanOrEqualTo(2)); if (callLog.length >= 3) { - final gap1 = - callLog[1].difference(callLog[0]).inMilliseconds.abs(); - final gap2 = - callLog[2].difference(callLog[1]).inMilliseconds.abs(); + final gap1 = callLog[1].difference(callLog[0]).inMilliseconds.abs(); + final gap2 = callLog[2].difference(callLog[1]).inMilliseconds.abs(); expect(gap2 + 20, greaterThanOrEqualTo(gap1)); } }); - test('setIntervalAndResetTimer syncs implicit backoffInitialDelay', () async { + test('setIntervalAndResetTimer syncs implicit backoffInitialDelay', + () async { final callLog = []; // Intentionally omit `backoffInitialDelay` and rely on `checkInterval` (50ms) @@ -587,10 +583,11 @@ void main() { sub.cancel(); expect(callLog.length, greaterThanOrEqualTo(2)); - + if (callLog.length >= 2) { - final firstGap = callLog[1].difference(callLog[0]).inMilliseconds.abs(); - + final firstGap = + callLog[1].difference(callLog[0]).inMilliseconds.abs(); + // If it follows, it should be around 100ms; if not, it would be around 50ms // Verify that it is greater than 80ms to ensure it followed 100ms expect(firstGap, greaterThan(80)); @@ -660,10 +657,7 @@ void main() { expect(callLog.length, greaterThanOrEqualTo(2)); if (callLog.length >= 2) { - final gap = callLog[1] - .difference(callLog[0]) - .inMilliseconds - .abs(); + final gap = callLog[1].difference(callLog[0]).inMilliseconds.abs(); // initialDelay is 100ms; ongoing backoff of 100ms would also be 100ms // on first step, but 200ms on second. We verify the first gap is ~100ms. expect(gap, greaterThan(60)); From 6c655ae5f1443b6db9be44a8916233b01632ce36 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Wed, 10 Jun 2026 01:43:03 +0900 Subject: [PATCH 10/13] fix(exponential-backoff): enforce backoff param validation in release builds Replace assert()-only guards with ArgumentError throws so that invalid backoff parameters (multiplier < 1.0, zero delays, initialDelay > maxDelay) are caught in release/AOT builds, not just debug mode. --- lib/src/internet_connection.dart | 49 ++++++++++++++++++------------ test/internet_connection_test.dart | 6 ++-- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index 73d0767..ffcc52f 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -106,27 +106,36 @@ class InternetConnection { useDefaultOptions || customCheckOptions?.isNotEmpty == true, 'You must provide a list of options if you are not using the ' 'default ones.', - ), - assert( - !useExponentialBackoff || backoffMultiplier >= 1.0, - 'backoffMultiplier must be greater than or equal to 1.0 to prevent shrinking intervals.', - ), - assert( - !useExponentialBackoff || backoffMaxDelay > Duration.zero, - 'backoffMaxDelay must be greater than zero.', - ), - assert( - !useExponentialBackoff || - (backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval) > - Duration.zero, - 'backoffInitialDelay (or checkInterval if implicitly used) must be greater than zero.', - ), - assert( - !useExponentialBackoff || - (backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval) <= - backoffMaxDelay, - 'backoffInitialDelay (or checkInterval if implicitly used) must be less than or equal to backoffMaxDelay.', ) { + if (useExponentialBackoff) { + if (backoffMultiplier < 1.0) { + throw ArgumentError.value( + backoffMultiplier, + 'backoffMultiplier', + 'Must be >= 1.0 to prevent shrinking intervals.', + ); + } + if (backoffMaxDelay <= Duration.zero) { + throw ArgumentError.value( + backoffMaxDelay, + 'backoffMaxDelay', + 'Must be greater than zero.', + ); + } + if (_backoffInitialDelay <= Duration.zero) { + throw ArgumentError.value( + backoffInitialDelay, + 'backoffInitialDelay', + 'backoffInitialDelay (or checkInterval if implicitly used) must be greater than zero.', + ); + } + if (_backoffInitialDelay > _backoffMaxDelay) { + throw ArgumentError( + 'backoffInitialDelay (or checkInterval if implicitly used) ' + 'must be less than or equal to backoffMaxDelay.', + ); + } + } _internetCheckOptions = List.unmodifiable([ if (useDefaultOptions) ..._defaultCheckOptions, if (customCheckOptions != null) ...customCheckOptions, diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index 7e36f97..232dd1b 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -297,7 +297,7 @@ void main() { customCheckOptions: singleOption, backoffMultiplier: 0.5, ), - throwsA(isA()), + throwsA(isA()), ); }); @@ -325,7 +325,7 @@ void main() { customCheckOptions: singleOption, backoffInitialDelay: Duration.zero, ), - throwsA(isA()), + throwsA(isA()), ); }); @@ -340,7 +340,7 @@ void main() { backoffInitialDelay: const Duration(seconds: 10), backoffMaxDelay: const Duration(seconds: 5), ), - throwsA(isA()), + throwsA(isA()), ); }); }); From 7db07d8f6534d41c552ad70d1ed2791c3b1883d8 Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Wed, 10 Jun 2026 01:54:06 +0900 Subject: [PATCH 11/13] fix(polling): guard backoff state mutation against stale timer callbacks Introduce a monotonically-increasing `_generation` counter that is bumped on every reschedule (setIntervalAndResetTimer) and on cancel (_handleStatusChangeCancel). Each _maybeEmitStatusUpdate invocation captures the counter on entry and bails out before mutating _currentBackoffDelay / _backoffNeedsReset if the value has changed, preventing a stale callback from silently overwriting the reset that the newer owner already applied. --- lib/src/internet_connection.dart | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index ffcc52f..de12335 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -246,6 +246,16 @@ class InternetConnection { /// The handle for the timer used for periodic status checks. Timer? _timerHandle; + /// Monotonically increasing counter bumped whenever an in-flight + /// [_maybeEmitStatusUpdate] must be invalidated: on [setIntervalAndResetTimer] + /// and on [_handleStatusChangeCancel]. + /// + /// Each [_maybeEmitStatusUpdate] invocation captures this value on entry. + /// Before mutating shared backoff state or scheduling the next timer it + /// checks that the value has not changed. A mismatch means this invocation + /// is stale — another caller already rescheduled and owns the next cycle. + int _generation = 0; + /// Checks if the [Uri] specified in [option] is reachable. /// /// Returns a [Future] that completes with an [InternetCheckResult] indicating @@ -285,6 +295,7 @@ class InternetConnection { _currentBackoffDelay = _backoffInitialDelay; _backoffNeedsReset = true; } + _generation++; _timerHandle?.cancel(); _timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate); } @@ -343,6 +354,7 @@ class InternetConnection { /// Updates the status and emits it if there are listeners. Future _maybeEmitStatusUpdate() async { _timerHandle?.cancel(); + final generation = _generation; if (!_statusController.hasListener) return; @@ -357,6 +369,14 @@ class InternetConnection { _statusController.add(currentStatus); } + if (!_statusController.hasListener) return; + // Guard before mutating shared backoff state: a setIntervalAndResetTimer + // call that arrived while we were awaiting internetStatus has already + // bumped _generation and scheduled its own timer. Mutating + // _currentBackoffDelay / _backoffNeedsReset here would silently overwrite + // the reset that setIntervalAndResetTimer applied. + if (_generation != generation) return; + Duration nextDelay; if (useExponentialBackoff) { if (currentStatus == InternetStatus.connected) { @@ -386,7 +406,6 @@ class InternetConnection { nextDelay = _checkInterval; } - if (!_statusController.hasListener) return; _timerHandle = Timer(nextDelay, _maybeEmitStatusUpdate); } @@ -396,6 +415,7 @@ class InternetConnection { void _handleStatusChangeCancel() { _triggerSubscription?.cancel(); _triggerSubscription = null; + _generation++; _timerHandle?.cancel(); _timerHandle = null; _lastStatus = null; From 2aed826f2908bf8b38f261917af8f63dc299ec6e Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Wed, 10 Jun 2026 02:08:06 +0900 Subject: [PATCH 12/13] refactor(exponential-backoff): extract _resolveInitialDelay to DRY up initial delay computation --- lib/src/internet_connection.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index de12335..1cd7413 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -97,11 +97,11 @@ class InternetConnection { }) : _checkInterval = checkInterval ?? _defaultCheckInterval, _backoffInitialDelayExplicit = backoffInitialDelay != null, _backoffInitialDelay = - backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval, + _resolveInitialDelay(backoffInitialDelay, checkInterval), _backoffMaxDelay = backoffMaxDelay, _backoffMultiplier = backoffMultiplier, _currentBackoffDelay = - backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval, + _resolveInitialDelay(backoffInitialDelay, checkInterval), assert( useDefaultOptions || customCheckOptions?.isNotEmpty == true, 'You must provide a list of options if you are not using the ' @@ -124,7 +124,7 @@ class InternetConnection { } if (_backoffInitialDelay <= Duration.zero) { throw ArgumentError.value( - backoffInitialDelay, + _backoffInitialDelay, 'backoffInitialDelay', 'backoffInitialDelay (or checkInterval if implicitly used) must be greater than zero.', ); @@ -151,6 +151,12 @@ class InternetConnection { /// The default check interval duration. static const _defaultCheckInterval = Duration(seconds: 10); + static Duration _resolveInitialDelay( + Duration? backoffInitialDelay, + Duration? checkInterval, + ) => + backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval; + /// The default list of [Uri]s used for checking internet reachability. static final _defaultCheckOptions = List.unmodifiable([ InternetCheckOption(uri: Uri.parse('https://one.one.one.one')), From 24d5de5d71d19bb62416f49c48d984fcb7110fbe Mon Sep 17 00:00:00 2001 From: Tahara Yo Date: Wed, 10 Jun 2026 02:38:13 +0900 Subject: [PATCH 13/13] fix(polling): prevent stale in-flight checks from emitting to new subscribers Introduce _cancelGeneration, bumped only on _handleStatusChangeCancel, to distinguish cancel+resubscribe cycles from setIntervalAndResetTimer calls. _maybeEmitStatusUpdate now snapshots _cancelGeneration before the async gap and skips the emit if a mismatch is detected, ensuring the new subscriber receives only its own fresh check result. Also fix the doc example (await listener.cancel()), document the intentionally unawaited _triggerSubscription?.cancel(), and add three tests: initial-yield caching, setIntervalAndResetTimer during connected period, and the stale-emit regression. --- lib/src/internet_connection.dart | 25 ++++- test/internet_connection_test.dart | 148 ++++++++++++++++++++++++++--- 2 files changed, 158 insertions(+), 15 deletions(-) diff --git a/lib/src/internet_connection.dart b/lib/src/internet_connection.dart index 1cd7413..e84b1c8 100644 --- a/lib/src/internet_connection.dart +++ b/lib/src/internet_connection.dart @@ -59,7 +59,7 @@ typedef ConnectivityCheckCallback = Future Function( /// will prevent memory leaks and free up resources. /// /// ```dart -/// listener.cancel(); +/// await listener.cancel(); /// ``` class InternetConnection { /// Returns the singleton instance of [InternetConnection]. @@ -262,6 +262,17 @@ class InternetConnection { /// is stale — another caller already rescheduled and owns the next cycle. int _generation = 0; + /// Monotonically increasing counter bumped only on [_handleStatusChangeCancel]. + /// + /// Captured before the [await internetStatus] gap and compared before + /// emitting. A mismatch means a cancel+resubscribe cycle happened while + /// the check was in-flight: the result belongs to the old subscription context + /// and must not be emitted to the new subscriber. + /// + /// Unlike [_generation], this is NOT bumped by [setIntervalAndResetTimer], + /// because interval changes do not affect which subscriber owns the result. + int _cancelGeneration = 0; + /// Checks if the [Uri] specified in [option] is reachable. /// /// Returns a [Future] that completes with an [InternetCheckResult] indicating @@ -361,6 +372,7 @@ class InternetConnection { Future _maybeEmitStatusUpdate() async { _timerHandle?.cancel(); final generation = _generation; + final cancelGeneration = _cancelGeneration; if (!_statusController.hasListener) return; @@ -370,7 +382,12 @@ class InternetConnection { final currentStatus = await internetStatus; - if (_lastStatus != currentStatus && _statusController.hasListener) { + // Only emit if this result still belongs to the current subscription + // context. A cancel+resubscribe while we were awaiting bumps + // _cancelGeneration; the new subscriber owns its own fresh check. + if (_cancelGeneration == cancelGeneration && + _lastStatus != currentStatus && + _statusController.hasListener) { _lastStatus = currentStatus; _statusController.add(currentStatus); } @@ -419,8 +436,12 @@ class InternetConnection { /// /// Cancels the timer and resets the last status. void _handleStatusChangeCancel() { + // Not awaited: _handleStatusChangeCancel is a synchronous onCancel + // callback. Any event that fires before cancel propagates will see + // hasListener == false and return early from _maybeEmitStatusUpdate. _triggerSubscription?.cancel(); _triggerSubscription = null; + _cancelGeneration++; _generation++; _timerHandle?.cancel(); _timerHandle = null; diff --git a/test/internet_connection_test.dart b/test/internet_connection_test.dart index 232dd1b..a824fc8 100644 --- a/test/internet_connection_test.dart +++ b/test/internet_connection_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; import 'package:test/test.dart'; @@ -168,7 +170,7 @@ void main() { // Give it tiny error space. expect(4 <= counter && counter <= 6, true); - sub.cancel(); + await sub.cancel(); }); }); @@ -204,7 +206,7 @@ void main() { expect(14 <= counter && counter <= 16, true); - sub.cancel(); + await sub.cancel(); }); }); @@ -238,7 +240,7 @@ void main() { // Give it tiny error space. expect(4 <= counter && counter <= 6, true); - sub.cancel(); + await sub.cancel(); }); }); }); @@ -253,6 +255,39 @@ void main() { expect(checker, isNot(InternetConnection.createInstance())); }); + test('onStatusChange yields cached status immediately to new subscriber', + () async { + await TestHttpClient.run((client) async { + client.responseBuilder = + (req) => TestHttpClient.createResponse(statusCode: 200); + + final checker = InternetConnection.createInstance( + // Long interval so no second poll fires during the test. + checkInterval: const Duration(seconds: 10), + useDefaultOptions: false, + customCheckOptions: [ + InternetCheckOption(uri: Uri.parse('https://www.example.com')), + ], + ); + + // First subscription — wait for the initial check to complete. + final sub1 = checker.onStatusChange.listen((_) {}); + await Future.delayed(const Duration(milliseconds: 100)); + expect(checker.lastTryResults, InternetStatus.connected); + + // Second subscription — should receive the cached status without a + // new network request (next poll is 10s away). + final received = []; + final sub2 = checker.onStatusChange.listen(received.add); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(received, [InternetStatus.connected]); + + await sub1.cancel(); + await sub2.cancel(); + }); + }); + group('exponentialBackoff', () { // Helper: build a checker that uses customConnectivityCheck so we // avoid real network calls and record each invocation timestamp. @@ -363,7 +398,7 @@ void main() { final sub = checker.onStatusChange.listen((_) {}); await Future.delayed(const Duration(milliseconds: 550)); - sub.cancel(); + await sub.cancel(); // With a 100ms interval over 550ms we expect ~4-5 checks. expect(callLog.length, greaterThanOrEqualTo(4)); @@ -402,7 +437,7 @@ void main() { // cycle to elapse (~100ms for detect + ~200ms for the backoff timer). await Future.delayed(const Duration(milliseconds: 450)); - sub.cancel(); + await sub.cancel(); // We should have at most 2 calls in this window: // one that detected the disconnect, and at most one more after @@ -433,7 +468,7 @@ void main() { // Total expected time for ~4 checks: 50 + 50 + 100 + 200 = 400ms await Future.delayed(const Duration(milliseconds: 550)); - sub.cancel(); + await sub.cancel(); expect(callLog.length, greaterThanOrEqualTo(4)); @@ -465,7 +500,7 @@ void main() { // Run long enough for the delay to have hit the cap and stayed there. // 50 + 50 + 100 + 200 + 200 = 600ms total for 5 calls. await Future.delayed(const Duration(milliseconds: 900)); - sub.cancel(); + await sub.cancel(); expect(callLog.length, greaterThanOrEqualTo(4)); @@ -501,7 +536,7 @@ void main() { // After reconnect, polling should revert to checkInterval (50ms). await Future.delayed(const Duration(milliseconds: 300)); - sub.cancel(); + await sub.cancel(); // With 50ms interval over 300ms we expect ~5 calls. expect(callLog.length, greaterThanOrEqualTo(3)); @@ -537,7 +572,7 @@ void main() { checker.setIntervalAndResetTimer(const Duration(milliseconds: 50)); await Future.delayed(const Duration(milliseconds: 400)); - sub.cancel(); + await sub.cancel(); // Should have ~3-5 calls; the gaps should be non-decreasing again // (restarted backoff sequence: 50, 100, 200ms…). @@ -580,7 +615,7 @@ void main() { checker.setIntervalAndResetTimer(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 450)); - sub.cancel(); + await sub.cancel(); expect(callLog.length, greaterThanOrEqualTo(2)); @@ -610,13 +645,13 @@ void main() { // First subscription — let backoff grow. final sub1 = checker.onStatusChange.listen((_) {}); await Future.delayed(const Duration(milliseconds: 600)); - sub1.cancel(); + await sub1.cancel(); callLog.clear(); // Second subscription — backoff should restart from initialDelay. final sub2 = checker.onStatusChange.listen((_) {}); await Future.delayed(const Duration(milliseconds: 400)); - sub2.cancel(); + await sub2.cancel(); // With restarted backoff (50 → 100 → 200ms), we expect fewer calls // than if the capped 200ms delay continued (which would yield ~2 calls). @@ -650,7 +685,7 @@ void main() { // If first-failure was treated as ongoing, delay would be 50*2=100ms anyway, // but if it jumped to ongoing-formula on first call it would be 200ms. await Future.delayed(const Duration(milliseconds: 400)); - sub.cancel(); + await sub.cancel(); // We should see at least 2 calls in 400ms: // call[0] immediately, call[1] after ~100ms, call[2] after ~200ms. @@ -664,6 +699,93 @@ void main() { expect(gap, lessThan(200)); } }); + + test( + 'setIntervalAndResetTimer during connected period: ' + 'first subsequent failure uses initialDelay', () async { + bool connected = true; + final callLog = []; + + final checker = makeChecker( + shouldSucceed: () => connected, + callLog: callLog, + checkInterval: const Duration(milliseconds: 50), + backoffInitialDelay: const Duration(milliseconds: 150), + backoffMaxDelay: const Duration(milliseconds: 800), + backoffMultiplier: 2.0, + ); + + final sub = checker.onStatusChange.listen((_) {}); + + // Establish connected state, then call setIntervalAndResetTimer + // while still connected. + await Future.delayed(const Duration(milliseconds: 100)); + checker.setIntervalAndResetTimer(const Duration(milliseconds: 50)); + + // Let a few more connected polls run, then measure from the disconnect. + await Future.delayed(const Duration(milliseconds: 150)); + callLog.clear(); + + connected = false; + + // Wait for disconnect detection + one backoff cycle (initialDelay=150ms). + await Future.delayed(const Duration(milliseconds: 400)); + await sub.cancel(); + + // The gap after the first disconnected poll should be ~initialDelay (150ms), + // not a grown delay. + if (callLog.length >= 2) { + final gap = callLog[1].difference(callLog[0]).inMilliseconds.abs(); + expect(gap, greaterThan(100)); + } + }); + + test( + 'cancelled in-flight check does not emit stale result to new subscriber', + () async { + // Use a gate to keep the first check suspended while we cancel and + // resubscribe, then release it to verify the stale result is dropped. + final checkGate = Completer(); + var checkCount = 0; + final option = + InternetCheckOption(uri: Uri.parse('https://example.com')); + + final checker = InternetConnection.createInstance( + checkInterval: const Duration(seconds: 10), + useDefaultOptions: false, + customCheckOptions: [option], + useExponentialBackoff: true, + customConnectivityCheck: (opt) async { + final mine = ++checkCount; + if (mine == 1) await checkGate.future; // stale check is paused + // check 1 → connected (stale), check 2 → disconnected (fresh) + return InternetCheckResult(option: opt, isSuccess: mine == 1); + }, + ); + + // Subscribe — starts the first (paused) check. + final sub1 = checker.onStatusChange.listen((_) {}); + await Future.microtask(() {}); + + // Cancel before the first check resolves. + await sub1.cancel(); + + // Resubscribe — starts the second (immediate, disconnected) check. + final received = []; + final sub2 = checker.onStatusChange.listen(received.add); + + // Let the second check complete. + await Future.delayed(const Duration(milliseconds: 50)); + + // Release the stale first check. + checkGate.complete(); + await Future.delayed(const Duration(milliseconds: 50)); + + // Only the fresh (disconnected) result should have been emitted. + expect(received, [InternetStatus.disconnected]); + + await sub2.cancel(); + }); }); }); }