test: make the remaining StateDebounceManager tests deterministic - #396
Merged
Merged
Conversation
The debounce tests inferred the state of an asynchronous scheduler from wall-clock Thread.sleep on the test thread. With a 50ms debounce window and sleeps of 3-4x that, the margin was only ~100ms, which is inside normal jitter on a loaded CI runner. That produced two independent races: the timer thread might not get scheduled within the sleep budget, and a sequence of setters might be split across two debounce windows so a suppression assertion saw a callback it did not expect. Convert the 12 timing-agnostic tests to ManualTaskExecutor, where a scheduled task runs only when the test drains the queue. Each drain stands in for the debounce window elapsing, which removes both races by construction and drops all 15 Thread.sleep calls from those tests. callbackFiredAfterDebounceWindow and closeWaitsForInflightReconcileCallback deliberately keep the real executor: they cover real timer delay and the cross-thread close() drain barrier respectively, neither of which a manual executor can exercise. Immediate-mode tests are unaffected because they bypass the executor entirely. Also adds a createManager overload taking the manual executor, and moves the repeated per-method rationale comments into class Javadoc so the four previously converted tests share one construction path.
kinyoklion
approved these changes
Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requirements
Test-only change; no production code is touched.
Related issues
SDK-3006
Describe the solution you've provided
StateDebounceManagerTestis the largest single source of flaky CI failures in this repo. Mining every failedci.ymlrun with retained logs (2026-06-01 to 2026-08-25, all 27 of them) turned up 15 failures caused by flaky unit tests, and 7 of those were this one class across 5 different methods and 5 unrelated branches. Each failure was exactly 1 failed test out of ~730, and each was verified unrelated to its PR's diff — the clearest example being a PR that changed onlybuild.gradleand still failed a debounce assertion. Six of the 15 landed directly on pushes tomain.Root cause: the tests infer the state of an asynchronous scheduler from wall-clock
Thread.sleepon the test thread. WithTEST_DEBOUNCE_MS = 50and sleeps of 3–4x the window, the margin is ~100ms, inside normal jitter for a loaded 2-core runner executing the whole suite in one JVM. That yields two races pulling in opposite directions:Because they pull opposite ways, fixing one method just relocates the failure. That is the actual history here: three reactive determinism commits (2026-06-01, 2026-06-03, and PR 394 on 2026-08-18), each converting whichever method had most recently flaked.
This change finishes the job. The 12 timing-agnostic tests move to
ManualTaskExecutor, where a scheduled task runs only when the test drains the queue — so each drain stands in for the debounce window elapsing, and both races become impossible by construction. All 15Thread.sleepcalls in those tests are gone.The strongest evidence this is the right remedy: no method has ever failed after being converted.
callbackNotFiredBeforeDebounceWindowfailed Jun 2 and Jun 3, was converted Jun 3, and has been silent since.resetCancelsPendingTimerfailed Aug 18 at 14:13Z (its conversion landed Aug 19 at 05:29Z) and again Aug 21 on a branch that does not contain that conversion commit.Two tests deliberately keep the real executor, because a manual executor would delete what they test:
callbackFiredAfterDebounceWindow— the only end-to-end coverage that a realscheduleTask(delayMillis)actually fires.closeWaitsForInflightReconcileCallback— theclose()drain barrier, which needs a callback genuinely in flight on another thread.Both already use latches with generous timeouts rather than fixed sleeps, and neither has ever flaked. The four immediate-mode tests (
debounceMs == 0) are untouched: they bypass the executor entirely and fire synchronously inside the setter.Breakdown of all 22 methods:
Describe alternatives you've considered
TEST_DEBOUNCE_MSor the sleep multipliers — makes under-run rarer but stays probabilistic, does nothing structural for the setup race, and adds ~20s to the suite.Clockand keeping a real executor — the flakiness is thread scheduling, not time reading; you would still be waiting on another thread.StateDebounceManager's documentedtaskLock/workLockcontract andclose()drain barrier would then go entirely unexercised.Additional context
Verification: the class passes 22/22; run 20 times consecutively it was clean 20/20; the full
testDebugUnitTestsuite is 730 tests, 0 failures. Note that the local repeat loop only proves no new nondeterminism was introduced — the original failures were CI-load-dependent and would not reproduce on an idle dev machine either. The real argument is structural, and real confirmation is the absence of recurrence in CI.Reviewer notes on scope, both called out deliberately:
createManager(ManualTaskExecutor, ...)overload. Leaving 4 tests inlining the constructor while 12 use a helper seemed worse than a slightly wider diff.resetCancelsPendingTimer's comment (thetaskLock/workLockinterleaving note) is retained.One assertion was strengthened:
multipleRapidChangesCoalesceIntoOneCallbacknow also assertscancelledCount() == 4, proving the five changes genuinely coalesced rather than merely yielding one callback. This mirrors the existing pattern intimerResetsOnEachEvent.Known follow-ups, not in scope:
ManualTaskExecutordiscardsdelayMillisandrunPendingTasks()flushes the whole queue, so after this changedebounceMsis only exercised end-to-end by one test. A virtual clock (advanceTimeBy) would make window-boundary assertions meaningful rather than near-vacuous.ManualTaskExecutoris not thread-safe (plainArrayList, non-volatile fields). Correct today because it is single-threaded by construction, but worth a class-level warning before anyone extends it.Note
Overview
Eliminates CI flakiness in
StateDebounceManagerTestby replacing wall-clockThread.sleepwith explicitManualTaskExecutor#runPendingTasks()in the 12 debounce-semantics tests, so each drain simulates the debounce window without scheduler races.Adds class Javadoc and a
createManager(ManualTaskExecutor, …)helper, routes those tests through it (including ones already on a manual executor), dropsthrows InterruptedExceptionwhere sleeps are gone, and tightensmultipleRapidChangesCoalesceIntoOneCallbackwith acancelledCount() == 4check.callbackFiredAfterDebounceWindowandcloseWaitsForInflightReconcileCallbackstill useSimpleTestTaskExecutorfor real timing/cross-thread behavior; immediate-mode tests are unchanged.No production code changes.
Reviewed by Cursor Bugbot for commit cfc839b. Bugbot is set up for automated code reviews on this repo. Configure here.