From 3315a4864c74453697511e0fd2e1254ac94ca36c Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 16:50:53 -0400 Subject: [PATCH 1/3] feat(SDK-2333): Debounce lifecycle, network, and setMode signals Implements CSFDV2 CONNMODE section 3.5 state-change debouncing. Lifecycle, network, and user-requested-mode signals are accumulated over a configurable window (default one second) before driving automatic mode resolution. Per spec, identify calls do not participate. StateDebounceManager (common_client/data_sources/fdv2): - DebouncedState holds networkAvailable, inForeground, requestedMode. - Per-setter early-return on unchanged value. - Duration.zero bypasses the timer (synchronous fire) for tests and FDv1-style immediate behavior. - Injectable DebounceTimerFactory for fake_async-based testing. - close() cancels any pending timer; setters after close are no-ops. Flutter ConnectionManager integration: - ConnectionManagerConfig.debounceWindow (default 1s). - Lifecycle and network listeners feed the debouncer instead of calling _handleState() directly. The debouncer's reconcile callback invokes _handleState() once the window closes. - setMode(ConnectionMode? mode) sets _modeOverride synchronously (CONNMODE 2.0.3 - automatic transitions suppressed immediately) and pushes through the debouncer (CONNMODE 3.5.5 - mode application is debounced). - Foreground -> background transition flushes synchronously per CONNMODE 3.3.1 (the process may be killed before the window closes); the debouncer still handles the resulting mode change. - Network-availability propagation to the destination remains synchronous; only the mode-resolution outcome is debounced. Tests: state_debounce_manager_test covers single-fire, flap-and-return, multi-axis change, requested-mode, close cancellation, immediate mode. connection_manager_test pins existing assertions to Duration.zero (preserving FDv1-style synchronous semantics) and adds three new debounce-window tests using fake_async. Resolves the two TODO sites in connection_manager.dart that were previously tagged SDK-2187. --- .../lib/launchdarkly_common_client.dart | 6 + .../fdv2/state_debounce_manager.dart | 132 +++++++++++ packages/common_client/pubspec.yaml | 1 + .../fdv2/state_debounce_manager_test.dart | 214 ++++++++++++++++++ .../lib/src/connection_manager.dart | 70 ++++-- packages/flutter_client_sdk/pubspec.yaml | 1 + .../persistence/connection_manager_test.dart | 148 ++++++++++-- 7 files changed, 542 insertions(+), 30 deletions(-) create mode 100644 packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart create mode 100644 packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index 3bd6cbb..323cf0c 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -75,6 +75,12 @@ export 'src/data_sources/fdv2/mode_resolution.dart' ModeResolutionEntry, resolveMode, flutterDefaultResolutionTable; +export 'src/data_sources/fdv2/state_debounce_manager.dart' + show + DebouncedState, + OnDebounceReconcile, + DebounceTimerFactory, + StateDebounceManager; export 'src/data_sources/data_source_status.dart' show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState; diff --git a/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart b/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart new file mode 100644 index 0000000..447b2c7 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import '../../fdv2_connection_mode.dart'; + +/// Snapshot of the desired state accumulated within a debounce window. +/// +/// Each field is one of the axes that participate in debouncing per the +/// FDv2 connection-mode resolution spec: network availability, application +/// lifecycle, and the user-requested mode (when set via the public +/// `setMode` API). `identify` calls intentionally do not participate. +final class DebouncedState { + final bool networkAvailable; + final bool inForeground; + final FDv2ConnectionMode? requestedMode; + + const DebouncedState({ + required this.networkAvailable, + required this.inForeground, + required this.requestedMode, + }); + + DebouncedState _copyWith({ + bool? networkAvailable, + bool? inForeground, + Object? requestedMode = _sentinel, + }) { + return DebouncedState( + networkAvailable: networkAvailable ?? this.networkAvailable, + inForeground: inForeground ?? this.inForeground, + requestedMode: identical(requestedMode, _sentinel) + ? this.requestedMode + : requestedMode as FDv2ConnectionMode?, + ); + } + + static const _sentinel = Object(); +} + +/// Callback fired when the debounce window closes with the final +/// accumulated [DebouncedState]. +typedef OnDebounceReconcile = void Function(DebouncedState state); + +/// Factory that produces a one-shot timer used to schedule the debounce +/// fire. Exists primarily so tests can substitute a controllable +/// implementation (e.g. via `fake_async`). +typedef DebounceTimerFactory = Timer Function( + Duration duration, void Function() callback); + +Timer _defaultTimerFactory(Duration d, void Function() cb) => Timer(d, cb); + +/// Debounces network availability, lifecycle, and user-requested mode +/// signals into a single reconciliation callback, per CSFDV2 CONNMODE +/// section 3.5. +/// +/// Each `setX` call updates the relevant component of the pending state +/// and resets the debounce timer. When the timer fires, [onReconcile] is +/// invoked with the final [DebouncedState]. Per-setter early-return +/// suppresses unchanged values; the consumer is responsible for deciding +/// whether the resolved state requires action. +/// +/// A [debounceWindow] of [Duration.zero] bypasses the timer entirely: +/// state changes fire [onReconcile] synchronously. Useful for tests and +/// FDv1-style immediate-application paths. +final class StateDebounceManager { + final Duration _debounceWindow; + final OnDebounceReconcile _onReconcile; + final DebounceTimerFactory _timerFactory; + + DebouncedState _pending; + Timer? _timer; + bool _closed = false; + + StateDebounceManager({ + required DebouncedState initialState, + required Duration debounceWindow, + required OnDebounceReconcile onReconcile, + DebounceTimerFactory? timerFactory, + }) : _pending = initialState, + _debounceWindow = debounceWindow, + _onReconcile = onReconcile, + _timerFactory = timerFactory ?? _defaultTimerFactory; + + void setNetworkAvailable(bool available) { + if (_pending.networkAvailable == available) { + return; + } + _pending = _pending._copyWith(networkAvailable: available); + _scheduleOrFire(); + } + + void setInForeground(bool inForeground) { + if (_pending.inForeground == inForeground) { + return; + } + _pending = _pending._copyWith(inForeground: inForeground); + _scheduleOrFire(); + } + + void setRequestedMode(FDv2ConnectionMode? mode) { + if (_pending.requestedMode == mode) { + return; + } + _pending = _pending._copyWith(requestedMode: mode); + _scheduleOrFire(); + } + + void close() { + _closed = true; + _timer?.cancel(); + _timer = null; + } + + void _scheduleOrFire() { + if (_closed) { + return; + } + if (_debounceWindow == Duration.zero) { + _onReconcile(_pending); + return; + } + _timer?.cancel(); + _timer = _timerFactory(_debounceWindow, _onTimer); + } + + void _onTimer() { + _timer = null; + if (_closed) { + return; + } + _onReconcile(_pending); + } +} diff --git a/packages/common_client/pubspec.yaml b/packages/common_client/pubspec.yaml index 1780929..42f03ed 100644 --- a/packages/common_client/pubspec.yaml +++ b/packages/common_client/pubspec.yaml @@ -18,3 +18,4 @@ dev_dependencies: test: ^1.24.3 lints: ^3.0.0 mocktail: ^1.0.1 + fake_async: ^1.3.1 diff --git a/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart b/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart new file mode 100644 index 0000000..86ec359 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart @@ -0,0 +1,214 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/state_debounce_manager.dart'; +import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart'; +import 'package:test/test.dart'; + +const _initial = DebouncedState( + networkAvailable: true, + inForeground: true, + requestedMode: null, +); + +const _debounceWindow = Duration(seconds: 1); + +void main() { + group('default window', () { + test('does not fire when state never changes', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(true); + manager.setInForeground(true); + + async.elapse(const Duration(seconds: 5)); + expect(calls, isEmpty); + manager.close(); + }); + }); + + test('fires once after the window closes following a single change', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + async.elapse(_debounceWindow); + + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + manager.close(); + }); + }); + + test('rapid changes reset the timer; one fire after final quiet', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + async.elapse(const Duration(milliseconds: 500)); + manager.setNetworkAvailable(true); + async.elapse(const Duration(milliseconds: 500)); + manager.setNetworkAvailable(false); + async.elapse(const Duration(milliseconds: 500)); + + expect(calls, isEmpty); + + async.elapse(_debounceWindow); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + manager.close(); + }); + }); + + test('flap-and-return fires the resolved (matching-actual) state', () { + // Spec 3.5 example 1: starting from {online,...}, network flaps offline + // and back to online; resolved state matches the starting actual state. + // The debouncer still fires (its job is to deliver the resolved tuple); + // the consumer is responsible for the no-op-if-no-change check. + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + manager.setNetworkAvailable(true); + manager.setNetworkAvailable(false); + manager.setNetworkAvailable(true); + + async.elapse(_debounceWindow); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isTrue); + manager.close(); + }); + }); + + test('combined lifecycle + network change fires a single reconcile', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + async.elapse(const Duration(milliseconds: 100)); + manager.setInForeground(false); + + async.elapse(_debounceWindow); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + expect(calls.single.inForeground, isFalse); + manager.close(); + }); + }); + + test('requested mode change debounces along with the other axes', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setRequestedMode(const FDv2Polling()); + async.elapse(_debounceWindow); + + expect(calls, hasLength(1)); + expect(calls.single.requestedMode, const FDv2Polling()); + manager.close(); + }); + }); + + test('close cancels a pending timer', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + manager.close(); + + async.elapse(const Duration(seconds: 5)); + expect(calls, isEmpty); + }); + }); + + test('setters after close do not schedule a new fire', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.close(); + manager.setNetworkAvailable(false); + manager.setInForeground(false); + manager.setRequestedMode(const FDv2Offline()); + + async.elapse(const Duration(seconds: 5)); + expect(calls, isEmpty); + }); + }); + }); + + group('zero window (immediate mode)', () { + test('changes fire synchronously without a timer', () { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: Duration.zero, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + + manager.setInForeground(false); + expect(calls, hasLength(2)); + expect(calls.last.inForeground, isFalse); + + manager.close(); + }); + + test('unchanged setters do not fire even in immediate mode', () { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: Duration.zero, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(true); + manager.setInForeground(true); + manager.setRequestedMode(null); + + expect(calls, isEmpty); + manager.close(); + }); + }); +} diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 142fa48..fd64e93 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -101,12 +101,19 @@ final class ConnectionManagerConfig { /// retry logic. final bool disableAutomaticBackgroundHandling; + /// Window across which lifecycle, network, and user-mode-override signals + /// are debounced before automatic resolution runs. A value of + /// [Duration.zero] disables debouncing (signals apply synchronously). + /// Defaults to one second, per CSFDV2 CONNMODE specification. + final Duration debounceWindow; + ConnectionManagerConfig({ this.initialConnectionMode = ConnectionMode.streaming, this.backgroundConnectionMode = const FDv2Offline(), this.runInBackground = true, this.disableAutomaticBackgroundHandling = false, this.disableAutomaticNetworkHandling = false, + this.debounceWindow = const Duration(seconds: 1), }); } @@ -127,6 +134,7 @@ final class ConnectionManager { final StateDetector _detector; final ConnectionDestination _destination; final List _resolutionTable; + late final StateDebounceManager _debouncer; StreamSubscription? _applicationStateSub; StreamSubscription? _networkStateSub; @@ -160,26 +168,51 @@ final class ConnectionManager { _applicationState = ApplicationState.foreground, _networkState = NetworkState.available, _detector = detector { + _debouncer = StateDebounceManager( + initialState: const DebouncedState( + networkAvailable: true, + inForeground: true, + requestedMode: null, + ), + debounceWindow: config.debounceWindow, + onReconcile: _onDebounceReconcile, + ); + if (!_config.disableAutomaticBackgroundHandling) { _applicationStateSub = - detector.applicationState.listen((applicationState) { - // TODO (SDK-2187): plumb in debouncer here - - _applicationState = applicationState; - _handleState(); - }); + detector.applicationState.listen(_onApplicationStateChanged); } if (!_config.disableAutomaticNetworkHandling) { - _networkStateSub = detector.networkState.listen((networkState) { - // TODO (SDK-2187): plumb in debouncer here - - _networkState = networkState; - _destination - .setNetworkAvailability(networkState == NetworkState.available); - _handleState(); - }); + _networkStateSub = detector.networkState.listen(_onNetworkStateChanged); + } + } + + void _onApplicationStateChanged(ApplicationState newState) { + // Spec CONNMODE 3.3.1: flushing on transition to background must not be + // debounced -- the process may be killed before the window closes. + if (newState == ApplicationState.background && + _applicationState == ApplicationState.foreground && + !_offline) { + _destination.flush(); } + _applicationState = newState; + _debouncer.setInForeground(newState == ApplicationState.foreground); + } + + void _onNetworkStateChanged(NetworkState newState) { + _networkState = newState; + // Network-availability propagation to the destination is not debounced. + // It informs the underlying client's analytics-sending state, separate + // from the mode-resolution decision that the debouncer governs. + _destination.setNetworkAvailability(newState == NetworkState.available); + _debouncer.setNetworkAvailable(newState == NetworkState.available); + } + + void _onDebounceReconcile(DebouncedState _) { + // The debouncer's snapshot is intentionally ignored; this manager owns + // the canonical view of lifecycle, network, override, and offline state. + _handleState(); } void _handleState() { @@ -228,14 +261,17 @@ final class ConnectionManager { void dispose() { _applicationStateSub?.cancel(); _networkStateSub?.cancel(); + _debouncer.close(); _detector.dispose(); } - /// Set the desired connection mode for the SDK. Passing null clears the - /// override and resumes automatic mode resolution. + /// Set the desired connection mode for the SDK. Setting an override takes + /// effect synchronously so subsequent automatic transitions are suppressed + /// immediately; applying the resolved mode is debounced. Passing null + /// clears the override and resumes automatic mode resolution. void setMode(FDv2ConnectionMode? mode) { _modeOverride = mode; - _handleState(); + _debouncer.setRequestedMode(mode); } } diff --git a/packages/flutter_client_sdk/pubspec.yaml b/packages/flutter_client_sdk/pubspec.yaml index 7354b7e..11e2506 100644 --- a/packages/flutter_client_sdk/pubspec.yaml +++ b/packages/flutter_client_sdk/pubspec.yaml @@ -24,6 +24,7 @@ dev_dependencies: test: ^1.24.3 lints: ^3.0.0 mocktail: ^1.0.1 + fake_async: ^1.3.1 # The following section is specific to Flutter. flutter: diff --git a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart index 2b77d3b..0914ef5 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; import 'package:launchdarkly_flutter_client_sdk/src/connection_manager.dart'; import 'package:test/test.dart'; @@ -69,7 +70,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: false); + final config = ConnectionManagerConfig( + runInBackground: false, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -102,7 +104,9 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, initialConnectionMode: initialMode); + runInBackground: false, + initialConnectionMode: initialMode, + debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -145,7 +149,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -177,6 +182,7 @@ void main() { final config = ConnectionManagerConfig( runInBackground: true, backgroundConnectionMode: const FDv2Background(), + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -206,6 +212,7 @@ void main() { final config = ConnectionManagerConfig( runInBackground: true, backgroundConnectionMode: const FDv2Streaming(), + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -232,7 +239,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -260,7 +268,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -299,6 +308,7 @@ void main() { final config = ConnectionManagerConfig( runInBackground: true, backgroundConnectionMode: const FDv2Background(), + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -336,6 +346,7 @@ void main() { final config = ConnectionManagerConfig( initialConnectionMode: ConnectionMode.polling, runInBackground: true, + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -373,6 +384,7 @@ void main() { final config = ConnectionManagerConfig( initialConnectionMode: ConnectionMode.polling, runInBackground: true, + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -417,6 +429,7 @@ void main() { final config = ConnectionManagerConfig( initialConnectionMode: ConnectionMode.polling, runInBackground: true, + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -443,7 +456,7 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(); + final config = ConnectionManagerConfig(debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -459,8 +472,11 @@ void main() { verify(() => destination.setEventSendingEnabled(false, flush: false)); reset(destination); - mockDetector.setApplicationState(ApplicationState.foreground); - mockDetector.setNetworkAvailable(true); + // Push genuine state changes (defaults are foreground+available); the + // SDK should remain offline because the offline flag overrides automatic + // resolution. + mockDetector.setApplicationState(ApplicationState.background); + mockDetector.setNetworkAvailable(false); // Wait for the state to propagate. await mockDetector.applicationState.first; @@ -468,7 +484,7 @@ void main() { verify( () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); - verify(() => destination.setNetworkAvailability(true)); + verify(() => destination.setNetworkAvailability(false)); verify(() => destination.setEventSendingEnabled(false, flush: false)); connectionManager.dispose(); }); @@ -482,7 +498,9 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, disableAutomaticBackgroundHandling: true); + runInBackground: false, + disableAutomaticBackgroundHandling: true, + debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -511,7 +529,9 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, disableAutomaticNetworkHandling: true); + runInBackground: false, + disableAutomaticNetworkHandling: true, + debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -539,7 +559,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -579,7 +600,10 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: false); + final config = ConnectionManagerConfig( + runInBackground: false, + debounceWindow: Duration.zero, + ); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -598,4 +622,102 @@ void main() { }); } }); + + group('debounce window', () { + test('rapid network changes settle to one reconcile', () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(const FDv2Streaming()); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setNetworkAvailable(false); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 200)); + mockDetector.setNetworkAvailable(true); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 200)); + mockDetector.setNetworkAvailable(false); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 200)); + + verifyNever(() => destination.setMode(any())); + + async.elapse(const Duration(seconds: 1)); + verify(() => destination.setMode(any())).called(1); + + connectionManager.dispose(); + }); + }); + + test('background transition flushes immediately, not debounced', () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: false, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setApplicationState(ApplicationState.background); + async.flushMicrotasks(); + // Flush is synchronous on foreground->background transition. + verify(() => destination.flush()).called(1); + + connectionManager.dispose(); + }); + }); + + test('setMode debounces the resolved-mode application', () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + connectionManager.setMode(const FDv2Polling()); + verifyNever(() => destination.setMode(any())); + + async.elapse(const Duration(seconds: 1)); + verify(() => destination.setMode(const ResolvedPolling())).called(1); + + connectionManager.dispose(); + }); + }); + }); } From 77c286b9dabad8e00fe11877c7d2c5f8b49a6f4f Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 1 Jun 2026 15:43:23 -0400 Subject: [PATCH 2/3] fix(SDK-2333): Debouncer review-round-1 fixes Addresses four findings from the multi-agent review of PR #281: - Wrap onReconcile in try/catch. An exception in the reconcile callback previously left _pending advanced to the new value but the destination uncalled; subsequent setters with the same value would dedupe and the failed reconcile never retried. Now exceptions are caught and (when an LDLogger is provided) logged at error level. The Flutter ConnectionManager passes its logger through. - Document the Duration.zero reentry contract. Class-level docstring now states that with a zero window, onReconcile fires synchronously inside the setter and must not call back into another setter on the same manager instance. - Skip the redundant background-mode flush in _handleState when _onApplicationStateChanged already performed a synchronous flush on the foreground->background transition. _pendingSyncFlush is set in the lifecycle listener and cleared on the next _handleState run. - Add a regression test pinning the setMode-override-wins-over- network-event-mid-debounce-window scenario the PR description calls out: setMode(FDv2Streaming) at t=0, network drops at t=500ms, setNetworkAvailability(false) propagates synchronously to the destination, but the resolved-mode application (after the window) is ResolvedStreaming -- the override suppresses the network-driven switch. - Add a regression test that an exception in onReconcile is swallowed and a subsequent state change still drives a reconcile. --- .../fdv2/state_debounce_manager.dart | 44 ++++++++++++----- .../fdv2/state_debounce_manager_test.dart | 30 ++++++++++++ .../lib/src/connection_manager.dart | 16 +++++-- .../persistence/connection_manager_test.dart | 47 +++++++++++++++++++ 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart b/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart index 447b2c7..9293da1 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + show LDLogger; + import '../../fdv2_connection_mode.dart'; /// Snapshot of the desired state accumulated within a debounce window. @@ -13,6 +16,8 @@ final class DebouncedState { final bool inForeground; final FDv2ConnectionMode? requestedMode; + static const _unset = Object(); + const DebouncedState({ required this.networkAvailable, required this.inForeground, @@ -22,18 +27,16 @@ final class DebouncedState { DebouncedState _copyWith({ bool? networkAvailable, bool? inForeground, - Object? requestedMode = _sentinel, + Object? requestedMode = _unset, }) { return DebouncedState( networkAvailable: networkAvailable ?? this.networkAvailable, inForeground: inForeground ?? this.inForeground, - requestedMode: identical(requestedMode, _sentinel) + requestedMode: identical(requestedMode, _unset) ? this.requestedMode : requestedMode as FDv2ConnectionMode?, ); } - - static const _sentinel = Object(); } /// Callback fired when the debounce window closes with the final @@ -49,8 +52,7 @@ typedef DebounceTimerFactory = Timer Function( Timer _defaultTimerFactory(Duration d, void Function() cb) => Timer(d, cb); /// Debounces network availability, lifecycle, and user-requested mode -/// signals into a single reconciliation callback, per CSFDV2 CONNMODE -/// section 3.5. +/// signals into a single reconciliation callback. /// /// Each `setX` call updates the relevant component of the pending state /// and resets the debounce timer. When the timer fires, [onReconcile] is @@ -59,12 +61,21 @@ Timer _defaultTimerFactory(Duration d, void Function() cb) => Timer(d, cb); /// whether the resolved state requires action. /// /// A [debounceWindow] of [Duration.zero] bypasses the timer entirely: -/// state changes fire [onReconcile] synchronously. Useful for tests and -/// FDv1-style immediate-application paths. +/// state changes fire [onReconcile] synchronously inside the setter that +/// produced them. With this configuration, [onReconcile] must not call +/// back into any [StateDebounceManager] setter on the same instance -- +/// doing so would recurse into [_scheduleOrFire] before the outer call +/// returns. Intended for tests and FDv1-style immediate-application paths. +/// +/// Exceptions thrown from [onReconcile] are caught and (when [logger] is +/// supplied) logged at error level. The [DebouncedState] that was about to +/// be delivered is retained as the new baseline -- subsequent setter calls +/// dedupe against it as if the reconcile had succeeded. final class StateDebounceManager { final Duration _debounceWindow; final OnDebounceReconcile _onReconcile; final DebounceTimerFactory _timerFactory; + final LDLogger? _logger; DebouncedState _pending; Timer? _timer; @@ -75,10 +86,12 @@ final class StateDebounceManager { required Duration debounceWindow, required OnDebounceReconcile onReconcile, DebounceTimerFactory? timerFactory, + LDLogger? logger, }) : _pending = initialState, _debounceWindow = debounceWindow, _onReconcile = onReconcile, - _timerFactory = timerFactory ?? _defaultTimerFactory; + _timerFactory = timerFactory ?? _defaultTimerFactory, + _logger = logger; void setNetworkAvailable(bool available) { if (_pending.networkAvailable == available) { @@ -115,7 +128,7 @@ final class StateDebounceManager { return; } if (_debounceWindow == Duration.zero) { - _onReconcile(_pending); + _invokeReconcile(); return; } _timer?.cancel(); @@ -127,6 +140,15 @@ final class StateDebounceManager { if (_closed) { return; } - _onReconcile(_pending); + _invokeReconcile(); + } + + void _invokeReconcile() { + try { + _onReconcile(_pending); + } catch (error, stackTrace) { + _logger?.error( + 'State debounce reconcile callback threw: $error\n$stackTrace'); + } } } diff --git a/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart b/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart index 86ec359..87ad212 100644 --- a/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart +++ b/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart @@ -211,4 +211,34 @@ void main() { manager.close(); }); }); + + group('reconcile error handling', () { + test('exception in onReconcile is swallowed; subsequent fires continue', + () { + fakeAsync((async) { + var callCount = 0; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: (_) { + callCount++; + if (callCount == 1) { + throw StateError('first reconcile failed'); + } + }, + ); + + manager.setNetworkAvailable(false); + async.elapse(_debounceWindow); + expect(callCount, 1); + + // A second change must still drive a reconcile after a failed one. + manager.setInForeground(false); + async.elapse(_debounceWindow); + expect(callCount, 2); + + manager.close(); + }); + }); + }); } diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index fd64e93..9db8d9c 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -104,7 +104,7 @@ final class ConnectionManagerConfig { /// Window across which lifecycle, network, and user-mode-override signals /// are debounced before automatic resolution runs. A value of /// [Duration.zero] disables debouncing (signals apply synchronously). - /// Defaults to one second, per CSFDV2 CONNMODE specification. + /// Defaults to one second. final Duration debounceWindow; ConnectionManagerConfig({ @@ -148,6 +148,12 @@ final class ConnectionManager { bool _offline = false; + /// Set to true when [_onApplicationStateChanged] performs a synchronous + /// flush on a foreground->background transition. Cleared on the next + /// [_handleState] invocation so the debounced reconcile does not flush a + /// second time for the same transition. + bool _pendingSyncFlush = false; + bool get offline => _offline; set offline(bool offline) { @@ -176,6 +182,7 @@ final class ConnectionManager { ), debounceWindow: config.debounceWindow, onReconcile: _onDebounceReconcile, + logger: _logger, ); if (!_config.disableAutomaticBackgroundHandling) { @@ -189,12 +196,12 @@ final class ConnectionManager { } void _onApplicationStateChanged(ApplicationState newState) { - // Spec CONNMODE 3.3.1: flushing on transition to background must not be - // debounced -- the process may be killed before the window closes. + // Flushing on transition to background must not be debounced if (newState == ApplicationState.background && _applicationState == ApplicationState.foreground && !_offline) { _destination.flush(); + _pendingSyncFlush = true; } _applicationState = newState; _debouncer.setInForeground(newState == ApplicationState.foreground); @@ -242,9 +249,10 @@ final class ConnectionManager { resolved = resolveMode(_resolutionTable, modeState); } - if (!_offline && !inForeground && networkAvailable) { + if (!_offline && !inForeground && networkAvailable && !_pendingSyncFlush) { _destination.flush(); } + _pendingSyncFlush = false; _destination.setMode(resolved); diff --git a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart index 0914ef5..7f7099f 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -719,5 +719,52 @@ void main() { connectionManager.dispose(); }); }); + + test( + 'setMode override wins over a network event arriving mid-debounce-window', + () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + // t=0: user sets override. + connectionManager.setMode(const FDv2Streaming()); + + // t=500ms: network drops mid-window. + async.elapse(const Duration(milliseconds: 500)); + mockDetector.setNetworkAvailable(false); + async.flushMicrotasks(); + + // Network availability propagates to the destination synchronously + // (not debounced) so the underlying client knows. + verify(() => destination.setNetworkAvailability(false)).called(1); + + // But the resolved mode has not been applied yet -- still inside the + // debounce window. + verifyNever(() => destination.setMode(any())); + + // After the window closes, the override wins -- ResolvedStreaming is + // applied. (ResolvedOffline would have been applied if the network + // event drove resolution; the override suppresses that.) + async.elapse(const Duration(seconds: 1)); + verify(() => destination.setMode(const ResolvedStreaming())).called(1); + + connectionManager.dispose(); + }); + }); }); } From 8edb1a61dba73cd84555dc85ea8b57cf7a365aa9 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 2 Jun 2026 14:26:05 -0400 Subject: [PATCH 3/3] feat(SDK-2333): Seed lifecycle from SchedulerBinding at construction Closes two follow-ups from the multi-agent review of #281: - Initial-state seeding (review finding 4): the SDK no longer assumes foreground at startup when the host platform has already reported background. FlutterStateDetector now exposes initialApplicationState as an instance field, resolved synchronously in the initializer list from SchedulerBinding.instance.lifecycleState. The read is cached at construction time and does not depend on the lifecycle stream. LDClient hoists FlutterStateDetector into a local, reads the seed, and passes it via the new ConnectionManagerConfig.initialApplicationState field. ConnectionManager seeds both _applicationState and the debouncer's inForeground from the config value. Network state stays optimistic at construction time -- Flutter's connectivity_plus API is async-only, and assuming "available" and paying a debounce window if we're wrong gives the best performance in the common case where the network actually is available. - Offline-setter asymmetry (review finding 6): adds a doc comment on ConnectionManager.offline noting the bypass of the debounce window is intentional. A direct "be offline now" should take effect immediately rather than waiting for the window to close. Tests: new connection_manager_test case exercises the seed path by constructing the manager with initialApplicationState: background and verifying _handleState resolves against background state. --- .../lib/src/connection_manager.dart | 28 +++++++++++-- .../lib/src/flutter_state_detector.dart | 26 ++++++++++++- .../flutter_client_sdk/lib/src/ld_client.dart | 6 ++- .../persistence/connection_manager_test.dart | 39 +++++++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 9db8d9c..4c33dcc 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -107,6 +107,17 @@ final class ConnectionManagerConfig { /// Defaults to one second. final Duration debounceWindow; + /// The application's lifecycle state at construction time. Used to seed + /// the manager's initial lifecycle assumption so the SDK doesn't default + /// to foreground when the host platform already knows the app launched + /// into the background. Callers that can query the platform synchronously + /// (e.g. via [SchedulerBinding.instance.lifecycleState]) should pass the + /// resolved value here; callers without that information should leave + /// the default. + /// + /// Defaults to [ApplicationState.foreground]. + final ApplicationState initialApplicationState; + ConnectionManagerConfig({ this.initialConnectionMode = ConnectionMode.streaming, this.backgroundConnectionMode = const FDv2Offline(), @@ -114,6 +125,7 @@ final class ConnectionManagerConfig { this.disableAutomaticBackgroundHandling = false, this.disableAutomaticNetworkHandling = false, this.debounceWindow = const Duration(seconds: 1), + this.initialApplicationState = ApplicationState.foreground, }); } @@ -154,6 +166,11 @@ final class ConnectionManager { /// second time for the same transition. bool _pendingSyncFlush = false; + /// Whether the SDK has been explicitly placed in the offline state. + /// + /// Assigning this property synchronously drives the resolved mode to + /// offline (or back to automatic resolution when set to `false`). It + /// intentionally bypasses the debounce window. bool get offline => _offline; set offline(bool offline) { @@ -171,13 +188,18 @@ final class ConnectionManager { _config = config, _destination = destination, _resolutionTable = resolutionTable ?? flutterDefaultResolutionTable(), - _applicationState = ApplicationState.foreground, + _applicationState = config.initialApplicationState, + // Network has no synchronous platform API; start optimistic. If + // the network is actually unavailable, the first detector emission + // will trigger a debounced reconcile that flips us to offline. + // The common case (network available) is the best performing default. _networkState = NetworkState.available, _detector = detector { _debouncer = StateDebounceManager( - initialState: const DebouncedState( + initialState: DebouncedState( networkAvailable: true, - inForeground: true, + inForeground: + config.initialApplicationState == ApplicationState.foreground, requestedMode: null, ), debounceWindow: config.debounceWindow, diff --git a/packages/flutter_client_sdk/lib/src/flutter_state_detector.dart b/packages/flutter_client_sdk/lib/src/flutter_state_detector.dart index 37eb0ac..cab3c42 100644 --- a/packages/flutter_client_sdk/lib/src/flutter_state_detector.dart +++ b/packages/flutter_client_sdk/lib/src/flutter_state_detector.dart @@ -22,10 +22,22 @@ final class FlutterStateDetector implements StateDetector { @override Stream get networkState => _networkStateController.stream; + /// The application lifecycle state read synchronously at construction + /// time. Suitable for seeding [ConnectionManagerConfig.initialApplicationState]. + /// + /// [SchedulerBinding.instance.lifecycleState] returns a cached value + /// populated by the framework when the OS pushes lifecycle messages. + /// The read is synchronous and depends only on + /// [WidgetsFlutterBinding.ensureInitialized] having been called -- which + /// the SDK already requires for [FlutterStateDetector] to function. + final ApplicationState initialApplicationState; + late final LDAppLifecycleListener _lifecycleListener; late final StreamSubscription _connectivitySubscription; - FlutterStateDetector() { + FlutterStateDetector() + : initialApplicationState = + _resolveLifecycleState(SchedulerBinding.instance.lifecycleState) { final initialState = SchedulerBinding.instance.lifecycleState; if (initialState != null) { _handleApplicationLifecycle(initialState); @@ -41,6 +53,18 @@ final class FlutterStateDetector implements StateDetector { Connectivity().onConnectivityChanged.listen(_setConnectivity); } + static ApplicationState _resolveLifecycleState(AppLifecycleState? state) => + switch (state) { + AppLifecycleState.resumed => ApplicationState.foreground, + AppLifecycleState.hidden || + AppLifecycleState.paused => + ApplicationState.background, + AppLifecycleState.detached || + AppLifecycleState.inactive || + null => + ApplicationState.foreground, + }; + void _setConnectivity(dynamic connectivityResult) { // TODO: This is a temporary fix to handle the breaking change in // connectivity_plus v6 diff --git a/packages/flutter_client_sdk/lib/src/ld_client.dart b/packages/flutter_client_sdk/lib/src/ld_client.dart index 3419305..3063293 100644 --- a/packages/flutter_client_sdk/lib/src/ld_client.dart +++ b/packages/flutter_client_sdk/lib/src/ld_client.dart @@ -75,6 +75,7 @@ interface class LDClient { _client = LDCommonClient(config, platformImplementation, context, DiagnosticSdkData(name: sdkName, version: sdkVersion), hooks: combined); + final stateDetector = FlutterStateDetector(); _connectionManager = ConnectionManager( logger: _client.logger, config: ConnectionManagerConfig( @@ -87,9 +88,10 @@ interface class LDClient { disableAutomaticNetworkHandling: config.offline || !config.applicationEvents.networkAvailability, runInBackground: - FlutterDefaultConfig.connectionManagerConfig.runInBackground), + FlutterDefaultConfig.connectionManagerConfig.runInBackground, + initialApplicationState: stateDetector.initialApplicationState), destination: DartClientAdapter(_client), - detector: FlutterStateDetector()); + detector: stateDetector); if (config.offline) { _connectionManager.offline = true; diff --git a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart index 7f7099f..7a08177 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -766,5 +766,44 @@ void main() { connectionManager.dispose(); }); }); + + test( + 'initialApplicationState seeds the lifecycle assumption so the SDK ' + 'does not default to foreground when launched in background', () { + // The manager is constructed with `initialApplicationState: background`. + // Toggling `offline` forces a synchronous `_handleState` -- with the + // seed in effect, the resolved automatic mode reflects the + // background + runInBackground=false state and yields + // ResolvedOffline(OfflineBackgroundDisabled). Without the seed + // (i.e., defaulted to foreground) the result would fall through to + // the foreground slot and yield ResolvedStreaming. + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: false, + debounceWindow: const Duration(seconds: 1), + initialApplicationState: ApplicationState.background, + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector, + ); + + connectionManager.offline = true; + reset(destination); + connectionManager.offline = false; + + verify(() => destination.setMode( + const ResolvedOffline(OfflineBackgroundDisabled()))).called(1); + + connectionManager.dispose(); + }); }); }