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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public class LDClientDataSystemPlatformTest {

private static final String MOBILE_KEY = "test-mobile-key";
private static final LDContext CONTEXT = LDContext.create("context");
private static final long TEST_DEBOUNCE_MS = 50;

/** Matches {@link AndroidPlatformState} debounce (500 ms) plus a small buffer. */
private static final int AFTER_PAUSE_WAIT_MS = 600;
Expand Down Expand Up @@ -88,6 +89,7 @@ private LDConfig.Builder baseConfig() {
return new LDConfig.Builder(AutoEnvAttributes.Disabled)
.mobileKey(MOBILE_KEY)
.persistentDataStore(store)
.debounceMs(TEST_DEBOUNCE_MS)
.diagnosticOptOut(true)
.events(Components.noEvents())
.logAdapter(logging.logAdapter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class ConnectivityManager {
private final TaskExecutor taskExecutor;
private final boolean backgroundUpdatingDisabled;
private final List<WeakReference<LDStatusListener>> statusListeners = new ArrayList<>();
private final Debounce pollDebouncer = new Debounce();
private final Debounce pollDebouncer = new Debounce(); // FDv1 only
private final AtomicBoolean forcedOffline = new AtomicBoolean();
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean closed = new AtomicBoolean();
Expand All @@ -79,6 +79,8 @@ class ConnectivityManager {
private final ModeResolutionTable modeResolutionTable;
private volatile ConnectionMode currentFDv2Mode;
private final AutomaticModeSwitchingConfig autoModeSwitchingConfig;
private final long debounceMs; // visible for testing
private volatile StateDebounceManager stateDebounceManager; // FDv2 only

// The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource.
// This has two purposes: 1. to decouple the data source implementation from the details of how
Expand Down Expand Up @@ -161,6 +163,7 @@ public void shutDown() {
forcedOffline.set(clientContext.isSetOffline());

LDConfig ldConfig = clientContext.getConfig();
this.debounceMs = ldConfig.getDebounceMs();
connectionInformation = new ConnectionInformationState();
readStoredConnectionState();
this.backgroundUpdatingDisabled = ldConfig.isDisableBackgroundPolling();
Expand All @@ -170,21 +173,49 @@ public void shutDown() {
? ((FDv2DataSourceBuilder) dataSourceFactory).getResolutionTable()
: null;

if (useFDv2ModeResolution) {
this.stateDebounceManager = createDebounceManager();
}

connectivityChangeListener = networkAvailable -> {
if (useFDv2ModeResolution && !autoModeSwitchingConfig.isNetwork()) {
if (useFDv2ModeResolution) {
// Event processor state updated immediately so analytics events reflect reality.
updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground());
return;
if (!autoModeSwitchingConfig.isNetwork()) {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Debounce manager gets stale state when switching partially disabled

Medium Severity

When autoModeSwitchingConfig.isNetwork() is false, the connectivityChangeListener returns early without calling dm.setNetworkAvailable(), leaving the StateDebounceManager with stale network state. Symmetrically, when isLifecycle() is false, dm.setForeground() is never called. When the other (enabled) axis later triggers a debounce, handleDebouncedModeStateChange reads both dimensions from the debounce manager, causing resolveMode to use stale data — e.g., resolving to BACKGROUND when the network is actually offline, or STREAMING when the app is actually backgrounded.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c134ebf. Configure here.

// CONNMODE 3.5.1: route through debounce window instead of immediate rebuild
StateDebounceManager dm = stateDebounceManager;
if (dm != null) {
dm.setNetworkAvailable(networkAvailable);
}
} else {
// FDv1 path: handleModeStateChange updates event processor internally
handleModeStateChange();
}
handleModeStateChange();
};
platformState.addConnectivityChangeListener(connectivityChangeListener);

foregroundListener = foreground -> {
if (useFDv2ModeResolution && !autoModeSwitchingConfig.isLifecycle()) {
if (useFDv2ModeResolution) {
// Event processor state updated immediately so analytics events reflect reality.
updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground());
return;
if (!autoModeSwitchingConfig.isLifecycle()) {
return;
}
// CONNMODE 3.3.1: flush pending events before transitioning to background
if (!foreground) {
eventProcessor.flush();
}
// CONNMODE 3.5.1: route through debounce window
StateDebounceManager dm = stateDebounceManager;
if (dm != null) {
dm.setForeground(foreground);
}
} else {
// FDv1 path: handleModeStateChange updates event processor internally
handleModeStateChange();
}
handleModeStateChange();
};
platformState.addForegroundChangeListener(foregroundListener);
}
Expand All @@ -193,6 +224,10 @@ public void shutDown() {
* Switches the {@link ConnectivityManager} to begin fetching/receiving information
* relevant to the context provided. This is likely to result in the teardown of existing
* connections, but the timing of that is not guaranteed.
* <p>
* CONNMODE 3.5.6: identify does NOT participate in debounce. The debounce manager is
* destroyed and recreated so that any pending debounced state change is discarded and
* the new context starts with a clean timer.
*
* @param context to swtich to
* @param onCompletion callback that indicates when the switching is done
Expand All @@ -204,6 +239,15 @@ void switchToContext(@NonNull LDContext context, @NonNull Callback<Void> onCompl
if (oldContext == context || oldContext.equals(context)) {
onCompletion.onSuccess(null);
} else {
// CONNMODE 3.5.6: identify bypasses debounce — close and recreate the manager
if (useFDv2ModeResolution) {
StateDebounceManager oldDm = stateDebounceManager;
if (oldDm != null) {
oldDm.close();
}
stateDebounceManager = createDebounceManager();
}

ModeState state = snapshotModeState();
if (dataSource == null || dataSource.needsRefresh(!state.isForeground(), context)) {
updateEventProcessor(forcedOffline.get(), state.isNetworkAvailable(), state.isForeground());
Expand Down Expand Up @@ -536,6 +580,11 @@ void shutDown() {
if (closed.getAndSet(true)) {
return;
}
StateDebounceManager dm = stateDebounceManager;
if (dm != null) {
dm.close();
stateDebounceManager = null;
}
DataSource oldDataSource = currentDataSource.getAndSet(null);
if (oldDataSource != null) {
oldDataSource.stop(LDUtil.noOpCallback());
Expand All @@ -550,6 +599,10 @@ void shutDown() {
platformState.removeConnectivityChangeListener(connectivityChangeListener);
}

// Intentionally bypasses the FDv2 debounce manager. setForceOffline is a legacy
// API that predates FDv2 and must remain immediate for backward compatibility.
// This is safe because resolveMode() short-circuits to OFFLINE when forcedOffline
// is set, so any in-flight debounced callback will resolve to the same mode and no-op.
void setForceOffline(boolean forceOffline) {
boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline);
if (forceOffline != wasForcedOffline) {
Expand All @@ -570,13 +623,51 @@ private void updateEventProcessor(boolean forceOffline, boolean networkAvailable
* Unified handler for all platform/configuration state changes (foreground, connectivity,
* force-offline). Snapshots the current state once, updates the event processor, then
* routes to the appropriate data source update path.
* <p>
* FDv1 only — FDv2 state changes are routed through {@link StateDebounceManager} and
* reconciled via {@link #handleDebouncedModeStateChange()}.
*/
private synchronized void handleModeStateChange() {
ModeState state = snapshotModeState();
updateEventProcessor(forcedOffline.get(), state.isNetworkAvailable(), state.isForeground());
updateDataSource(false, state, LDUtil.noOpCallback());
}

/**
* Creates a new {@link StateDebounceManager} initialized with the current platform state.
* Called once during construction (for FDv2) and again on each identify to discard pending
* debounced changes (CONNMODE 3.5.6).
*/
private StateDebounceManager createDebounceManager() {
return new StateDebounceManager(
platformState.isNetworkAvailable(),
platformState.isForeground(),
taskExecutor,
debounceMs,
this::handleDebouncedModeStateChange
);
}

/**
* Reconciliation callback invoked by the {@link StateDebounceManager} when the debounce
* timer fires (CONNMODE 3.5.3). Reads the latest accumulated state from the debounce
* manager and triggers a data source update if the resolved mode has changed.
*/
private void handleDebouncedModeStateChange() {
StateDebounceManager dm = stateDebounceManager;
if (dm == null) {
return;
}
ModeState state = new ModeState(
dm.isForeground(),
dm.isNetworkAvailable(),
backgroundUpdatingDisabled
);
// updateEventProcessor() is intentionally not called here — it was already
// called immediately in the listener callbacks, before the debounce started.
updateDataSource(false, state, LDUtil.noOpCallback());
}

private ModeState snapshotModeState() {
return new ModeState(
platformState.isForeground(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public class LDConfig {
private final String loggerName;
private final int maxCachedContexts;
private final boolean offline;
private final long debounceMs;
private final PersistentDataStore persistentDataStore; // configurable for testing only

LDConfig(Map<String, String> mobileKeys,
Expand All @@ -105,6 +106,7 @@ public class LDConfig {
int maxCachedContexts,
boolean generateAnonymousKeys,
boolean autoEnvAttributes,
long debounceMs,
PersistentDataStore persistentDataStore,
LDLogAdapter logAdapter,
String loggerName) {
Expand All @@ -124,6 +126,7 @@ public class LDConfig {
this.maxCachedContexts = maxCachedContexts;
this.generateAnonymousKeys = generateAnonymousKeys;
this.autoEnvAttributes = autoEnvAttributes;
this.debounceMs = debounceMs;
this.persistentDataStore = persistentDataStore;
this.logAdapter = logAdapter;
this.loggerName = loggerName;
Expand Down Expand Up @@ -166,6 +169,11 @@ AutomaticModeSwitchingConfig getAutomaticModeSwitchingConfig() {
return automaticModeSwitchingConfig;
}

// visible for testing — allows instrumented tests to use a shorter debounce window
long getDebounceMs() {
return debounceMs;
}

/**
* @return true if evaluation reasons are turned on, false otherwise
*/
Expand Down Expand Up @@ -268,6 +276,8 @@ public enum AutoEnvAttributes {

private PersistentDataStore persistentDataStore;

private long debounceMs = StateDebounceManager.DEFAULT_DEBOUNCE_MS;

private LDLogAdapter logAdapter = defaultLogAdapter();
private String loggerName = LDPackageConsts.DEFAULT_LOGGER_NAME;
private LDLogLevel logLevel = null;
Expand Down Expand Up @@ -667,6 +677,12 @@ Builder persistentDataStore(PersistentDataStore persistentDataStore) {
return this;
}

// visible for testing — allows instrumented tests to use a shorter debounce window
Builder debounceMs(long debounceMs) {
this.debounceMs = debounceMs;
return this;
}

/**
* Specifies the implementation of logging to use.
* <p>
Expand Down Expand Up @@ -827,6 +843,7 @@ public LDConfig build() {
maxCachedContexts,
generateAnonymousKeys,
autoEnvAttributes,
debounceMs,
persistentDataStore,
actualLogAdapter,
loggerName);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.launchdarkly.sdk.android;

import java.util.concurrent.ScheduledFuture;

/**
* Debounces FDv2 state changes (network, lifecycle) into a single
* reconciliation callback (CONNMODE 3.5). Each state change resets a timer; when
* the timer fires, the callback runs with the latest accumulated state.
* <p>
* {@code identify()} does NOT participate in debounce (CONNMODE 3.5.6). Callers
* handle this by closing and recreating the manager on identify.
* <p>
* FDv1 data sources do not use this class. The existing {@link Debounce} class
* (used by {@code pollDebouncer}) serves a different purpose in the FDv1 path.
*/
final class StateDebounceManager {

static final long DEFAULT_DEBOUNCE_MS = 1000;

private final Object lock = new Object();
private final TaskExecutor taskExecutor;
private final long debounceMs;
private final Runnable onReconcile;

private volatile boolean networkAvailable;
private volatile boolean foreground;

private ScheduledFuture<?> pendingTimer;
private volatile boolean closed;

StateDebounceManager(
boolean initialNetworkAvailable,
boolean initialForeground,
TaskExecutor taskExecutor,
long debounceMs,
Runnable onReconcile
) {
this.networkAvailable = initialNetworkAvailable;
this.foreground = initialForeground;
this.taskExecutor = taskExecutor;
this.debounceMs = debounceMs;
this.onReconcile = onReconcile;
}

void setNetworkAvailable(boolean available) {
if (this.networkAvailable == available) {
return;
}
this.networkAvailable = available;
resetTimer();
}

void setForeground(boolean fg) {
if (this.foreground == fg) {
return;
}
this.foreground = fg;
resetTimer();
}

boolean isNetworkAvailable() {
return networkAvailable;
}

boolean isForeground() {
return foreground;
}

void close() {
closed = true;
synchronized (lock) {
if (pendingTimer != null) {
pendingTimer.cancel(false);
pendingTimer = null;
}
}
}

private void resetTimer() {
if (closed) {
return;
}
synchronized (lock) {
if (pendingTimer != null) {
pendingTimer.cancel(false);
}
pendingTimer = taskExecutor.scheduleTask(() -> {
if (!closed) {
onReconcile.run();
}
}, debounceMs);
}
}
}
Loading
Loading