From da5669e1dfaf2c0b3e7841b6127fa6ec2f7f8a59 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 28 Sep 2026 14:35:17 +0000 Subject: [PATCH] android: make HealthNotifier init-order test fail on the NPE headscale's Android emulator integration tests (juanfont/headscale#3488) hit this NPE on 1.102.4, which predates the #835 fix. Reproducing it here showed constructionWithNonRunningStateDoesNotCrash (#835) still passes with currentWarnings declared after init again. The NPE is thrown in a launched coroutine with no handler, so it goes to the thread's uncaught handler, which only prints on the JVM (on Android it kills the process). Capture it via CoroutineExceptionHandler and rethrow. Updates tailscale/corp#46191 Signed-off-by: Kristoffer Dalby --- .../tailscale/ipn/ui/notifier/HealthNotifierTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/HealthNotifierTest.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/HealthNotifierTest.kt index 8fb2c4e086..b14b719fd5 100644 --- a/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/HealthNotifierTest.kt +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/HealthNotifierTest.kt @@ -10,6 +10,7 @@ import com.tailscale.ipn.ui.model.Health.UnhealthyState import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -131,15 +132,20 @@ class HealthNotifierTest { * immediately calls dropAllWarnings() (reading currentWarnings) when the initial ipn state is not * Running. If currentWarnings/currentIcon are declared after the init block, that read hits a * null StateFlow and NPEs. Using Dispatchers.Unconfined runs the launched coroutine eagerly - * inside the constructor, deterministically reproducing the race on the old ordering. + * inside the constructor, deterministically reproducing the race on the old ordering. The NPE + * surfaces as an uncaught coroutine exception, which kills the process on Android but only gets + * printed on the JVM, so it must be captured explicitly for the test to fail. */ @Test fun constructionWithNonRunningStateDoesNotCrash() { - val scope = CoroutineScope(Dispatchers.Unconfined) + var uncaught: Throwable? = null + val scope = + CoroutineScope(Dispatchers.Unconfined + CoroutineExceptionHandler { _, e -> uncaught = e }) try { val healthFlow = MutableStateFlow(healthState(derpWarning())) val ipnFlow = MutableStateFlow(Ipn.State.Stopped) val notifier = HealthNotifier(healthFlow, ipnFlow, scope) + uncaught?.let { throw it } assertTrue(notifier.currentWarnings.value.isEmpty()) assertNull(notifier.currentIcon.value) } finally {