fix(tests): stop streaming data sources in StreamingDataSourceTest - #400
Merged
Merged
Conversation
StreamingDataSourceTest started 16 data sources and never stopped any of them. Each start creates a BackgroundEventSource with its own stream and events threads plus an open connection to the test server, so the class left 34 EventSource threads (and their retry loops) running for the rest of the forked test JVM. Simply calling stop() was not enough: StreamingDataSource.stop() lowers the priority of its shutdown thread via android.os.Process, which is not mocked in unit tests, so the shutdown thread died before closing anything and stop() was silently a no-op. Add an android.os.Process stub alongside the existing android.util.Base64 and android.util.Pair stubs, then track every started data source and stop them all in an @after, waiting for the stop callback so teardown is deterministic. Co-authored-by: Cursor <cursoragent@cursor.com>
tanderson-ld
approved these changes
Sep 24, 2026
abelonogov-ld
deleted the
andrey/fix-streaming-data-source-test-leak
branch
September 24, 2026 17:11
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.
What leaked
StreamingDataSourceTestcalledstart(...)on 16 data sources and never stopped a single one. EveryStreamingDataSource.start()builds aBackgroundEventSource, which owns two single-thread executors (okhttp-eventsource-stream[...],okhttp-eventsource-events[...]), an OkHttp client, and an open connection to the test'sHttpServer. Nothing ever closed them, so they survived for the life of the forked test JVM and were inherited by every test class Gradle ran afterwards in the same JVM.There was a second, less obvious half to this. Even adding
stop()would not have helped, becauseStreamingDataSource.stop()is a silent no-op in unit tests:Local unit tests run against the mockable
android.jar, where every method throws. A probe test confirmed it:So the shutdown thread died on its first line,
stopSync()never ran, theEventSourcewas never closed, and the completion callback never fired. This also means the SDK's own internalstop(null)on the non-retriable-error path (401/403) was a no-op under unit test.Evidence
Instrumented the class with a per-test census of
Thread.getAllStackTraces(), removed before commit.Running the class by itself, thread counts grew monotonically across the 40 tests:
okhttp-eventsource-streamokhttp-eventsource-eventsRunning the class as part of the full module suite, the JVM went from 94 threads to 146 across this one class — 52 threads added, 34 of them EventSource threads, and all of them still alive when the next test class started.
After the fix (measured at
@AfterClasswith a settle delay, three runs): total ends at 33–34 instead of 65,okhttp-eventsource-eventsis 0, andokhttp-eventsource-streamis 4–5 instead of 17. See "Known remaining residue" below for those last few.Did this actually cause a flake?
Yes — partially confirmed, and I want to be precise about what was and wasn't shown.
I ran the full module suite 8 times before the fix. 2 of 8 runs failed, each with a single different failure:
StreamingDataSourceTest.startWithHttp401PreventsSubsequentStartAssertionError: Second start should not produce a callback expected null, but was:<LDInvalidResponseCodeFailure>FDv2StreamingSynchronizerTest.streamRestartAfterInvalidDataRecordsMultipleDiagnosticsAssertionError: expected:<CHANGE_SET> but was:<STATUS>The second one is a genuinely downstream class, which is the collateral damage the leak was suspected of causing.
After the fix I ran the full suite 8 more times: 8 of 8 green, 730 tests each.
Caveat: 8 runs before and 8 after is suggestive, not statistically conclusive for a failure rate around 25%. I am not claiming certainty that the leak is the sole cause of either failure. What is certain is the leak itself and its magnitude, and that both flakes disappeared.
I did work out a consistent mechanism for failure 1 that fits the leak: under load the first connection attempt can fail with a network error before the server's 401 is seen.
StreamingDataSource.onError()reports that network error to the callback without setting any guard flags, so the test'sawaitError()returns early. Meanwhile the retry gets the 401 and begins the non-retriable branch, which setsrunning = falsebefore it setsconnection401Error = true. If the test's secondstart()reads both fields inside that window, the guard passes and a secondEventSourceis created, which then reports the 401 tocallback2. That is a pre-existing race in product code that the extra load makes reachable; this PR does not change it (see below).The fix
launchdarkly-android-client-sdk/src/test/java/android/os/Process.java(new) — a stub forandroid.os.Process, following the existingandroid.util.Base64andandroid.util.Pairstubs in the same test source set.setThreadPrioritybecomes a no-op sostop()'s shutdown thread survives and actually closes the stream.android.os.Processis referenced from exactly one place in main source (StreamingDataSource.stop()), so the blast radius is that method.StreamingDataSourceTest.java— astartDataSource(...)helper that registers each started source in a list before starting it, and an@Afterthat stops every registered source, awaiting the stop callback via the existingAwaitableCallbackhelper so teardown is deterministic. The 16sds.start(callback)call sites now go through the helper. No test logic was changed.Verification
StreamingDataSourceTestalone, 5 consecutive runs: 5/5 green, 40 tests each.<failure/<errorand counting<testcaseentries.Known remaining residue, deliberately left alone
4–5
okhttp-eventsource-streamthreads still survive the class (down from 34 EventSource threads). These come from the five tests whose data source is in a fast reconnect loop when we stop it (startWithHttp500/400/408/429ReportsRetriableErrorandstartWithNetworkErrorReportsNetworkFailure), and the cause is upstream in okhttp-eventsource 4.2.0. InEventSource.tryStart()the loop does:If
close()lands inside that window, the flag it just set is wiped, the failed connect is treated as an ordinary retry, and the loop continues forever — it never consultsreadyState == SHUTDOWN. The surviving threads are much more benign than before (their OkHttp client is closed, so they just back off up to the 5 minute cap doing nothing), but they are still leaked. Fixing it belongs in okhttp-eventsource, not here.The
running/connection401Errorordering race described above is real product code behavior, not a test bug. Tightening it means making the guard-flag update and the callback atomic inStreamingDataSource. I left it out to keep this PR to the test-hygiene change.Handlers.SSE.leaveOpen()leaves NanoHttpd request-processor threads sleeping afterHttpServer.close()— about 9 threads by the end of this class. That is test-server side, insidecom.launchdarkly.testhelpers, and unrelated to SDK behavior.Note
Overview
Fixes unit-test thread leaks from
StreamingDataSourceTeststarting SSE connections without tearing them down, and makesstop()actually run in JVM unit tests.Adds a test-only
android.os.Processstub sosetThreadPriorityno longer throws under the mockableandroid.jar. Without it,StreamingDataSource.stop()’s shutdown thread dies before closing theEventSource, so stop was effectively a no-op in unit tests.StreamingDataSourceTestnow registers each started source viastartDataSource(...)and an@Afterhook thatstop()s every registered source and awaits the stop callback (5s timeout). All HttpServer-based tests that calledsds.start(...)now go through the helper; assertions are unchanged.Reviewed by Cursor Bugbot for commit 521be77. Bugbot is set up for automated code reviews on this repo. Configure here.