Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/common_client/lib/launchdarkly_common_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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.
///
/// 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;

static const _unset = Object();

const DebouncedState({
required this.networkAvailable,
required this.inForeground,
required this.requestedMode,
});

DebouncedState _copyWith({
bool? networkAvailable,
bool? inForeground,
Object? requestedMode = _unset,
}) {
return DebouncedState(
networkAvailable: networkAvailable ?? this.networkAvailable,
inForeground: inForeground ?? this.inForeground,
requestedMode: identical(requestedMode, _unset)
? this.requestedMode
: requestedMode as FDv2ConnectionMode?,
);
}
}

/// 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.
///
/// 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 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;
bool _closed = false;

StateDebounceManager({
required DebouncedState initialState,
required Duration debounceWindow,
required OnDebounceReconcile onReconcile,
DebounceTimerFactory? timerFactory,
LDLogger? logger,
}) : _pending = initialState,
_debounceWindow = debounceWindow,
_onReconcile = onReconcile,
_timerFactory = timerFactory ?? _defaultTimerFactory,
_logger = logger;

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) {
_invokeReconcile();
return;
}
_timer?.cancel();
_timer = _timerFactory(_debounceWindow, _onTimer);
}

void _onTimer() {
_timer = null;
if (_closed) {
return;
}
_invokeReconcile();
}

void _invokeReconcile() {
try {
_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.

}
}
}
1 change: 1 addition & 0 deletions packages/common_client/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ dev_dependencies:
test: ^1.24.3
lints: ^3.0.0
mocktail: ^1.0.1
fake_async: ^1.3.1
Loading
Loading