Skip to content

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

Open
tanderson-ld wants to merge 3 commits into
ta/SDK-2187/connection-mode-and-resolution-flutterfrom
ta/SDK-2333/state-change-debouncer
Open

feat: Debounce lifecycle, network, and setMode signals#281
tanderson-ld wants to merge 3 commits into
ta/SDK-2187/connection-mode-and-resolution-flutterfrom
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 the SDK reconnects or changes mode during rapid lifecycle/network flaps; critical paths (background flush, offline, sync network availability) are explicitly preserved but timing of mode switches shifts by up to the debounce window.

Overview
Adds StateDebounceManager in common_client to batch lifecycle, network, and user setMode signals over a configurable window (default 1s) before a single reconcile callback, with Duration.zero for immediate/test behavior and exports from the public package API.

Flutter ConnectionManager now routes lifecycle and network updates through the debouncer (replacing direct _handleState on every signal), adds debounceWindow and initialApplicationState on config, seeds lifecycle from FlutterStateDetector.initialApplicationState at client startup, and keeps foreground→background flush and destination network availability synchronous while debouncing resolved mode application. setMode sets the override immediately but applies the resolved mode after debounce; offline still bypasses debouncing.

Tests add state_debounce_manager_test, pin existing connection_manager_test expectations to Duration.zero, and add fake_async debounce integration cases.

Reviewed by Cursor Bugbot for commit 8edb1a6. Bugbot is set up for automated code reviews on this repo. Configure here.

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
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 2 potential issues.

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 8edb1a6. Configure here.

});
_networkStateSub = detector.networkState.listen(_onNetworkStateChanged);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Startup never reconciles mode

Medium Severity

After construction, ConnectionManager only applies resolved mode when the debouncer fires, and StateDebounceManager skips scheduling when lifecycle/network values match its seeded pending state. Initial detector events that confirm foreground plus available network therefore never trigger _handleState, so automatic resolution (e.g. background launch with runInBackground: false) may not run until a later change plus the debounce window.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8edb1a6. Configure here.

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

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.

1 participant