From 8c8aa5d431420969b9b575d682e98fd9c0f81475 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:52:41 +0000 Subject: [PATCH 1/5] test: poll for shutDownCalled in startWithHttp401PreventsSubsequentStart test The startWithHttp401PreventsSubsequentStart test is flaky due to a race condition. In StreamingDataSource.onError(), the production code calls resultCallback.onError() (which unblocks the test's callback.awaitError()) before setting connection401Error and calling dataSourceUpdateSink.shutDown(). The test thread can resume and call the second sds.start() before connection401Error is set, causing the second start to proceed and produce a callback. This adds a short polling loop (up to 1 second, checking every 10ms) to wait for shutDownCalled to become true before testing the second start, matching the pattern used in the startWithHttp401ShutsDownSink fix. Co-Authored-By: rlamb@launchdarkly.com --- .../sdk/android/StreamingDataSourceTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index c56dd394..114aee02 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -690,6 +690,15 @@ public void startWithHttp401PreventsSubsequentStart() throws Exception { assertNotNull(callback1.awaitError()); + // The background thread calls resultCallback.onError() before setting + // connection401Error and calling dataSourceUpdateSink.shutDown(), so + // we need to wait for shutDown to complete before testing the second start. + long deadline = System.currentTimeMillis() + 1000; + while (!dataSourceUpdateSink.shutDownCalled && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + assertTrue(dataSourceUpdateSink.shutDownCalled); + // Second start should be a no-op due to connection401Error flag TrackingCallback callback2 = new TrackingCallback(); sds.start(callback2); From 806d4a0df58f07c876abeb0623a450d7f2fcdcd4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:03:11 +0000 Subject: [PATCH 2/5] ci: re-run tests to verify flake fix (run 1) Co-Authored-By: rlamb@launchdarkly.com From fc30c1e49dc5dd868c91578fcc53b02cda28c461 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:13:53 +0000 Subject: [PATCH 3/5] ci: re-run tests to verify flake fix (run 2) Co-Authored-By: rlamb@launchdarkly.com From 753f9460ff858aea3f3381eeb984daf24f5b21e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:41:10 +0000 Subject: [PATCH 4/5] fix: set connection401Error before invoking resultCallback in onError Move connection401Error and shutDown() before resultCallback.onError() so that the 401 state is fully established before the callback unblocks any waiting threads. This eliminates the race condition at the source rather than working around it in tests. Also reverts the polling workarounds from both startWithHttp401ShutsDownSink (#329) and startWithHttp401PreventsSubsequentStart since they are no longer needed. Co-Authored-By: rlamb@launchdarkly.com --- .../sdk/android/StreamingDataSource.java | 2 +- .../sdk/android/StreamingDataSourceTest.java | 16 ---------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java index 2ca8cadb..04131b53 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java @@ -133,11 +133,11 @@ public void onError(Throwable t) { if (code >= 400 && code < 500) { logger.error("Encountered non-retriable error: {}. Aborting connection to stream. Verify correct Mobile Key and Stream URI", code); running = false; - resultCallback.onError(new LDInvalidResponseCodeFailure("Unexpected Response Code From Stream Connection", t, code, false)); if (code == 401) { connection401Error = true; dataSourceUpdateSink.shutDown(); } + resultCallback.onError(new LDInvalidResponseCodeFailure("Unexpected Response Code From Stream Connection", t, code, false)); stop(null); } else { eventSourceStarted = System.currentTimeMillis(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index 114aee02..b11c61fe 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -617,13 +617,6 @@ public void startWithHttp401ShutsDownSink() throws Exception { LDInvalidResponseCodeFailure failure = (LDInvalidResponseCodeFailure) error; assertEquals(401, failure.getResponseCode()); assertFalse(failure.isRetryable()); - // The background thread calls resultCallback.onError() before - // dataSourceUpdateSink.shutDown(), so shutDownCalled may not be true yet. - // Poll briefly to allow the background thread to complete. - long deadline = System.currentTimeMillis() + 1000; - while (!dataSourceUpdateSink.shutDownCalled && System.currentTimeMillis() < deadline) { - Thread.sleep(10); - } assertTrue(dataSourceUpdateSink.shutDownCalled); } } @@ -690,15 +683,6 @@ public void startWithHttp401PreventsSubsequentStart() throws Exception { assertNotNull(callback1.awaitError()); - // The background thread calls resultCallback.onError() before setting - // connection401Error and calling dataSourceUpdateSink.shutDown(), so - // we need to wait for shutDown to complete before testing the second start. - long deadline = System.currentTimeMillis() + 1000; - while (!dataSourceUpdateSink.shutDownCalled && System.currentTimeMillis() < deadline) { - Thread.sleep(10); - } - assertTrue(dataSourceUpdateSink.shutDownCalled); - // Second start should be a no-op due to connection401Error flag TrackingCallback callback2 = new TrackingCallback(); sds.start(callback2); From 4201d236f8cf3654bad758c467a95f03eb5a5a4f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:42:15 +0000 Subject: [PATCH 5/5] fix: set connection401Error before callback, keep original shutDown ordering Set connection401Error = true before resultCallback.onError() so the flag is visible when the callback unblocks waiting threads. Keep dataSourceUpdateSink.shutDown() after the callback to preserve the original status listener notification ordering. The startWithHttp401ShutsDownSink test retains its polling workaround since shutDown() still executes after the callback returns. Co-Authored-By: rlamb@launchdarkly.com --- .../com/launchdarkly/sdk/android/StreamingDataSource.java | 4 +++- .../launchdarkly/sdk/android/StreamingDataSourceTest.java | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java index 04131b53..055af34c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java @@ -135,9 +135,11 @@ public void onError(Throwable t) { running = false; if (code == 401) { connection401Error = true; - dataSourceUpdateSink.shutDown(); } resultCallback.onError(new LDInvalidResponseCodeFailure("Unexpected Response Code From Stream Connection", t, code, false)); + if (code == 401) { + dataSourceUpdateSink.shutDown(); + } stop(null); } else { eventSourceStarted = System.currentTimeMillis(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index b11c61fe..bf54c0fd 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -617,6 +617,12 @@ public void startWithHttp401ShutsDownSink() throws Exception { LDInvalidResponseCodeFailure failure = (LDInvalidResponseCodeFailure) error; assertEquals(401, failure.getResponseCode()); assertFalse(failure.isRetryable()); + // shutDown() is called after the error callback, so we need to + // wait briefly for the background thread to complete the call. + long deadline = System.currentTimeMillis() + 1000; + while (!dataSourceUpdateSink.shutDownCalled && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } assertTrue(dataSourceUpdateSink.shutDownCalled); } }