Skip to content

feat: Debounce lifecycle, network, and setMode signals#281

Open
tanderson-ld wants to merge 11 commits into
mainfrom
ta/SDK-2333/state-change-debouncer
Open

feat: Debounce lifecycle, network, and setMode signals#281
tanderson-ld wants to merge 11 commits into
mainfrom
ta/SDK-2333/state-change-debouncer

Conversation

@tanderson-ld
Copy link
Copy Markdown
Contributor

@tanderson-ld tanderson-ld commented May 29, 2026

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, identify calls do not participate.

Stacked on #280 (-flutter); rebase onto main once that merges.

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: single-fire, flap-and-return, multi-axis change, requested-mode, close cancellation, immediate mode.
  • connection_manager_test: existing assertions pinned to Duration.zero (preserve FDv1-style synchronous semantics); three new debounce-window tests using fake_async.

Resolves the two TODO sites in connection_manager.dart previously 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 setMode signals coalesce before automatic connection-mode resolution runs (default 1s window, configurable via ConnectionManagerConfig.debounceWindow).

Introduces StateDebounceManager in common_client with DebouncedState, timer reset on change, Duration.zero for immediate/test behavior, and public exports. Flutter ConnectionManager routes detector/setMode updates through the debouncer while offline, foreground→background flush, and destination network availability stay synchronous. setMode still sets the override immediately but applies the resolved mode after debounce. initialApplicationState (from FlutterStateDetector’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 pin debounceWindow: Duration.zero; new tests cover debounce coalescing, immediate flush, and override-vs-network mid-window.

Note: ld_client.dart currently contains a duplicate if (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.

tanderson-ld and others added 7 commits May 29, 2026 10:09
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.
@tanderson-ld tanderson-ld force-pushed the ta/SDK-2333/state-change-debouncer branch from 2efbef6 to 3315a48 Compare May 29, 2026 20:13
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.
@tanderson-ld tanderson-ld marked this pull request as ready for review June 2, 2026 18:37
@tanderson-ld tanderson-ld requested a review from a team as a code owner June 2, 2026 18:37
Comment thread packages/flutter_client_sdk/lib/src/connection_manager.dart
_onReconcile(_pending);
} catch (error, stackTrace) {
_logger?.error(
'State debounce reconcile callback threw: $error\n$stackTrace');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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.

@kinyoklion
Copy link
Copy Markdown
Member

Comment from Claude (AI assistant) running a review pass. Posted under @rlamb's account; treat as a draft for your judgment.

Latent: background-launched SDK never reconciles the destination from the buffered initial detector emission

While testing the new initialApplicationState plumbing I found a regression: when the SDK is constructed with initialApplicationState: ApplicationState.background (e.g. iOS/Android cold-launch into AppLifecycleState.paused), the destination never receives an initial setMode(...) call, and the data source keeps running whatever mode LDCommonClient was constructed with (default streaming).

Trace

  1. FlutterStateDetector constructor synchronously calls _handleApplicationLifecycle(SchedulerBinding.instance.lifecycleState) (flutter_state_detector.dart:41-44). With the system in paused/hidden that adds ApplicationState.background to the single-subscription _applicationStateController.sink, which buffers the event until ConnectionManager subscribes.
  2. ConnectionManager constructor (connection_manager.dart:191, 198-208):
    • sets _applicationState = config.initialApplicationStatebackground
    • seeds _debouncer._pending.inForeground = (config.initialApplicationState == ApplicationState.foreground)false
  3. ConnectionManager subscribes to the detector at line 211-212 and drains the buffered background event. _onApplicationStateChanged(background) (connection_manager.dart:220-230) runs:
    • Flush guard at lines 222-227: newState == background && _applicationState == foreground && !_offline. _applicationState is already background (from step 2), so guard is false. No flush.
    • _applicationState = background is a no-op (line 228).
    • _debouncer.setInForeground(false) early-returns at state_debounce_manager.dart:104-110 because _pending.inForeground == false already.
  4. No timer schedules. _handleState() never runs. The destination receives no setMode(...) call.

Observable wrong outcome: a background-launched app runs a streaming/polling connection in the background, contrary to the configured backgroundConnectionMode (default FDv2Offline). On iOS that means an active SSE socket while the platform is about to suspend the process, with unflushed events (spec Req 3.3.1 violation). On Android, battery drain from a foreground-mode connection that should have switched to the background slot.

Pre-#281 this didn't fire because _applicationState was always seeded to foreground, so the buffered background event caused a real transition through _handleState().

Test gap

The new initialApplicationState seeds the lifecycle assumption ... test at connection_manager_test.dart:770-807 only asserts behaviour under a synchronous offline=true; offline=false toggle, which forces _handleState to run. It does not assert that the destination is reconciled without that user intervention.

Bug-proving test (red on HEAD 8edb1a6)

I verified red on HEAD (No matching calls (actually, no calls at all)) and green with the fix below. Drop this into the debounce window group in connection_manager_test.dart:

test(
    'background-launched SDK reconciles the destination from the buffered '
    'initial detector emission (without user intervention)', () async {
  registerFallbackValue(ConnectionMode.streaming);

  final destination = MockDestination();
  final logAdapter = MockLogAdapter();
  final logger = LDLogger(adapter: logAdapter);
  final config = ConnectionManagerConfig(
    runInBackground: false,
    debounceWindow: Duration.zero,
    initialApplicationState: ApplicationState.background,
  );
  final mockDetector = MockStateDetector();

  final connectionManager = ConnectionManager(
    logger: logger,
    config: config,
    destination: destination,
    detector: mockDetector,
  );

  // Simulate the real FlutterStateDetector emitting its buffered
  // initial-lifecycle event after ConnectionManager subscribes.
  mockDetector.setApplicationState(ApplicationState.background);
  await mockDetector.applicationState.first;

  verify(() => destination.setMode(
      const ResolvedOffline(OfflineBackgroundDisabled()))).called(1);

  connectionManager.dispose();
});

Suggested fix (verified green; all 26 tests pass)

Keep _applicationState seeded (so the synchronous flush-edge detection in _onApplicationStateChanged still works), but don't seed the debouncer's inForeground from initialApplicationState. Then the first detector emission flips a real bit through _debouncer.setInForeground(...), schedules a reconcile, and _handleState() runs:

// connection_manager.dart, in the ConnectionManager constructor
_debouncer = StateDebounceManager(
  // Seed the debouncer's view conservatively (default foreground +
  // available) so the first detector emission flips a real bit and
  // schedules a reconcile, even when [_applicationState] was seeded
  // to background via [config.initialApplicationState]. Otherwise
  // the buffered initial background event from the detector becomes
  // a double no-op (canonical field already matches, debouncer
  // setter early-returns) and the destination never sees the
  // initial mode.
  initialState: const DebouncedState(
    networkAvailable: true,
    inForeground: true,
    requestedMode: null,
  ),
  debounceWindow: config.debounceWindow,
  onReconcile: _onDebounceReconcile,
  logger: _logger,
);

I also tried calling _handleState() once at the end of the constructor (an alternative fix), but it broke 5 existing tests by dispatching an extra setMode at construction time. The above one-line change is the smaller, lower-blast-radius fix.

Happy to push this as a follow-up commit if useful.

Base automatically changed from ta/SDK-2187/connection-mode-and-resolution-flutter to main June 4, 2026 00:26
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
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix All in Cursor

❌ 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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8213de6. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants