From 0160533a7fa985ccf36caf84fcee9ebed85d344d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 18 Aug 2026 22:29:15 -0700 Subject: [PATCH] fix: make the close/reset timer tests deterministic closeCancelsPendingTimer and resetCancelsPendingTimer slept for a fraction of the debounce window and then asserted the timer never fired. On a loaded runner that sleep can overshoot the window, and once the timer has begun firing neither close() nor reset() can recall it, so the tests failed for reasons unrelated to the behaviour they cover. Both now drive a ManualTaskExecutor instead of sleeping, asserting that the scheduled task was cancelled and that draining the queue runs nothing. Co-authored-by: Cursor --- .../sdk/android/StateDebounceManagerTest.java | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java index 3fcf52b0..eb971396 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java @@ -144,15 +144,20 @@ public void closePreventsFutureCallbacks() throws InterruptedException { } @Test - public void closeCancelsPendingTimer() throws InterruptedException { + public void closeCancelsPendingTimer() { + // Manually-driven executor for the same reason as resetCancelsPendingTimer: the pre-close + // sleep can overshoot the debounce window on a loaded runner, letting the timer fire before + // close() has a chance to cancel it. AtomicInteger callCount = new AtomicInteger(0); - StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + ManualTaskExecutor manualExecutor = new ManualTaskExecutor(); + StateDebounceManager mgr = new StateDebounceManager( + true, true, manualExecutor, TEST_DEBOUNCE_MS, callCount::incrementAndGet); mgr.setNetworkAvailable(false); - Thread.sleep(TEST_DEBOUNCE_MS / 3); mgr.close(); + assertEquals("close should cancel the scheduled timer", 1, manualExecutor.cancelledCount()); - Thread.sleep(TEST_DEBOUNCE_MS * 3); + manualExecutor.runPendingTasks(); assertEquals("pending timer should be cancelled on close", 0, callCount.get()); } @@ -341,15 +346,23 @@ public void dedupAllowsGenuineChangeAfterSuppression() throws InterruptedExcepti // ==== reset() (CONNMODE 3.5.6 — identify bypasses debounce) ==== @Test - public void resetCancelsPendingTimer() throws InterruptedException { + public void resetCancelsPendingTimer() { + // Uses a manually-driven executor instead of Thread.sleep so the test is deterministic: + // the short pre-reset sleep can overshoot the debounce window on a loaded runner, and once + // the timer has begun firing reset() cannot recall it -- reset() re-seeds the baseline + // under taskLock while fireIfChanged() reads it under workLock, an interleaving the + // implementation documents as harmless but which this assertion cannot tolerate. AtomicInteger callCount = new AtomicInteger(0); - StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + ManualTaskExecutor manualExecutor = new ManualTaskExecutor(); + StateDebounceManager mgr = new StateDebounceManager( + true, true, manualExecutor, TEST_DEBOUNCE_MS, callCount::incrementAndGet); mgr.setNetworkAvailable(false); - Thread.sleep(TEST_DEBOUNCE_MS / 3); mgr.reset(true, true); + assertEquals("reset should cancel the scheduled timer", 1, manualExecutor.cancelledCount()); - Thread.sleep(TEST_DEBOUNCE_MS * 3); + // Draining the queue runs nothing, because the only scheduled task was cancelled. + manualExecutor.runPendingTasks(); assertEquals("reset should cancel the pending timer", 0, callCount.get()); mgr.close();