feat: Debounce lifecycle, network, and setMode signals#281
feat: Debounce lifecycle, network, and setMode signals#281tanderson-ld wants to merge 11 commits into
Conversation
Wires the FDv2 mode-resolution scaffolding (merged via #274) into the common_client runtime path while keeping the FDv1 ConnectionMode enum and all existing public API signatures unchanged. LDCommonClient: - setMode(ConnectionMode) keeps its FDv1 signature and now maps the 3 legacy modes to ResolvedConnectionMode internally before forwarding to DataSourceManager. - New setResolvedMode(ResolvedConnectionMode) is the advanced FDv2 entry point. Documented as EAP / not-stable. No internal caller in this PR; the Flutter SDK's ConnectionManager invokes it in the follow-up flutter PR. - DataSourceFactoriesFn (the public, optional constructor seam) remains typed Map<ConnectionMode, DataSourceFactory> so external callers can still customize streaming + polling. _backgroundFactory is SDK-managed (background is FDv2-only), and _composeFactoriesForManager translates the FDv1 map into the FDv2-keyed Map<FDv2ConnectionMode, DataSourceFactory> consumed by DataSourceManager. DataSourceManager (internal, not publicly exported): - Active mode held as ResolvedConnectionMode. - Factory map is keyed by FDv2ConnectionMode. - ResolvedOffline branches dispatch status as setOffline / networkUnavailable / backgroundDisabled depending on OfflineDetail. Tests updated to match.
Wires the FDv2 mode-resolution machinery (from common_client) into the Flutter SDK's ConnectionManager while preserving all existing public API signatures. ConnectionManager: - DartClientAdapter forwards ResolvedConnectionMode to LDCommonClient.setResolvedMode (the new advanced FDv2 entry point). - ConnectionManagerConfig.backgroundConnectionMode (new) is typed FDv2ConnectionMode; initialConnectionMode stays ConnectionMode (legacy). - disableAutomaticBackgroundHandling keeps its original name. - Automatic mode resolution uses resolveMode + flutterDefaultResolutionTable; setMode override path supports the 3 FDv1 modes only (background is auto-resolved, not user-selectable). Flutter umbrella re-exports cover the FDv2 types consumed by this PR (FDv2 connection-mode subtypes, ResolvedConnectionMode family, OfflineDetail family, ModeState/ModeResolutionEntry/resolveMode/ flutterDefaultResolutionTable). Platform defaults (io_config / js_config / stub_config / flutter_default_config) return FDv2ConnectionMode for the background slot. LDClient passes backgroundConnectionMode to the ConnectionManager and sets offline=true post-construction (no longer forces initial mode to offline in config). Tests updated to match. Depends on the LDCommonClient.setResolvedMode addition in the predecessor common-only PR.
Splits the active mode and offline detail into two fields: - _activeConnectionMode: FDv2ConnectionMode -- drives factory lookup (matches the factory map's key type directly). - _offlineDetail: OfflineDetail -- semantically meaningful only when _activeConnectionMode is FDv2Offline; carries a stale value in other modes and is intentionally only read inside the FDv2Offline arm of _setupConnection. ResolvedConnectionMode is now a true boundary type: consumed by setMode, decomposed into the two fields, then discarded. This removes the .connectionMode getter call previously needed for factory lookup. setMode dedup is rewritten to compare the FDv2 mode and (when offline) the offline detail explicitly, so a redundant call with the same effective state still short-circuits. Constructor initializes _offlineDetail to OfflineSetOffline() as a placeholder; it is overwritten the next time setMode receives a ResolvedOffline value.
… ta/SDK-2187/connection-mode-and-resolution-flutter
The runtime mode-override path now uses FDv2ConnectionMode end-to-end: - ConnectionManager._modeOverride is FDv2ConnectionMode? (was ConnectionMode?). - ConnectionManager.setMode signature accepts FDv2ConnectionMode? so callers can request any FDv2 mode -- including FDv2Background() -- as the override. - _handleState pattern-matches the four FDv2 subtypes when applying the override. initialConnectionMode stays ConnectionMode (FDv1) because it is bound to LDConfig.dataSourceConfig.initialConnectionMode, an existing user-visible configuration field. The _fdv2FromFdv1 helper retains its remaining caller (translating initialConnectionMode into the ModeState slot). Tests updated. The override iteration in 'given requested connection modes' now exercises all four FDv2 modes (background included).
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.
2efbef6 to
3315a48
Compare
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.
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.
| _onReconcile(_pending); | ||
| } catch (error, stackTrace) { | ||
| _logger?.error( | ||
| 'State debounce reconcile callback threw: $error\n$stackTrace'); |
There was a problem hiding this comment.
Raw exception text in logs
Low Severity
The reconcile error handler logs the caught value with string interpolation ($error), which can embed sensitive request URIs or other PII from exception toString() output instead of a fixed, categorized message.
Triggered by learned rule: Never expose raw exception toString() in logs or StatusEvent messages in data sources
Reviewed by Cursor Bugbot for commit 8edb1a6. Configure here.
Latent: background-launched SDK never reconciles the destination from the buffered initial detector emissionWhile testing the new Trace
Observable wrong outcome: a background-launched app runs a streaming/polling connection in the background, contrary to the configured Pre-#281 this didn't fire because Test gapThe new Bug-proving test (red on HEAD
|
The event processor's flush() short-circuits when both the event buffer and the summarizer are empty, so the second flush during a debounced fg->bg reconcile is already a free no-op. The gate added no observable behavior; remove it and the bookkeeping that supported it.
…ebouncer # Conflicts: # packages/flutter_client_sdk/lib/src/connection_manager.dart # packages/flutter_client_sdk/test/persistence/connection_manager_test.dart
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8213de6. Configure here.
|
|
||
| if (config.offline) { | ||
| _connectionManager.offline = true; | ||
| } |
There was a problem hiding this comment.
Duplicate offline assignment in client
Low Severity
The same if (config.offline) { _connectionManager.offline = true; } block appears twice in a row in LDClient’s constructor, so offline mode is set redundantly with no added behavior.
Reviewed by Cursor Bugbot for commit 8213de6. Configure here.


Summary
Implements CSFDV2 CONNMODE section 3.5 state-change debouncing for the Flutter SDK. Lifecycle, network, and user-requested-mode signals accumulate over a configurable window (default 1 second) before driving automatic mode resolution. Per spec 3.5.6,
identifycalls do not participate.Stacked on #280 (
-flutter); rebase ontomainonce that merges.StateDebounceManager (
common_client/data_sources/fdv2)DebouncedStateholdsnetworkAvailable,inForeground,requestedMode.Duration.zerobypasses the timer (synchronous fire) for tests and FDv1-style immediate behavior.DebounceTimerFactoryforfake_async-based testing.close()cancels any pending timer; setters after close are no-ops.Flutter
ConnectionManagerintegrationConnectionManagerConfig.debounceWindow(default 1s)._handleState()directly. The debouncer's reconcile callback invokes_handleState()once the window closes.setMode(ConnectionMode? mode)sets_modeOverridesynchronously (CONNMODE 2.0.3 -- automatic transitions suppressed immediately) and pushes through the debouncer (CONNMODE 3.5.5 -- mode application is debounced).Tests
state_debounce_manager_test: single-fire, flap-and-return, multi-axis change, requested-mode, close cancellation, immediate mode.connection_manager_test: existing assertions pinned toDuration.zero(preserve FDv1-style synchronous semantics); three new debounce-window tests usingfake_async.Resolves the two TODO sites in
connection_manager.dartpreviously tagged SDK-2187.Note
Medium Risk
Changes when connection modes flip (up to 1s delay by default), which affects streaming/polling/offline transitions; behavior is spec-driven and heavily tested, but timing-sensitive app logic could notice the difference from immediate resolution.
Overview
Adds FDv2 CONNMODE §3.5 debouncing so rapid lifecycle, network, and user
setModesignals coalesce before automatic connection-mode resolution runs (default 1s window, configurable viaConnectionManagerConfig.debounceWindow).Introduces
StateDebounceManagerincommon_clientwithDebouncedState, timer reset on change,Duration.zerofor immediate/test behavior, and public exports. FlutterConnectionManagerroutes detector/setModeupdates through the debouncer whileoffline, foreground→background flush, and destination network availability stay synchronous.setModestill sets the override immediately but applies the resolved mode after debounce.initialApplicationState(fromFlutterStateDetector’s sync lifecycle read) seeds lifecycle so apps that start in background aren’t treated as foreground until the first event.Tests use
fake_async; existing connection-manager tests pindebounceWindow: Duration.zero; new tests cover debounce coalescing, immediate flush, and override-vs-network mid-window.Note:
ld_client.dartcurrently contains a duplicateif (config.offline)block (likely accidental).Reviewed by Cursor Bugbot for commit 8213de6. Bugbot is set up for automated code reviews on this repo. Configure here.