Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ class HealthNotifier(
// notification
"wantrunning-false")

// These must be initialized before the init block below, which launches a coroutine that can
// immediately call dropAllWarnings() (reading currentWarnings) on a background dispatcher. If
// the collector observes the initial non-Running ipn state before these property initializers
// run, it would read a null StateFlow and crash with an NPE (see startup init-order race).
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)

init {
// This roughly matches the iOS/macOS implementation in terms of debouncing, and ignoring
// health warnings in various states.
Expand All @@ -68,9 +75,6 @@ class HealthNotifier(
}
}

val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)

private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings.value
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
Expand Down Expand Up @@ -123,6 +126,27 @@ class HealthNotifierTest {
assertTrue(notifier.currentWarnings.value.isEmpty())
}

/**
* Regression test for a startup init-order race: the init block launches a collector that
* 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.
*/
@Test
fun constructionWithNonRunningStateDoesNotCrash() {
val scope = CoroutineScope(Dispatchers.Unconfined)
try {
val healthFlow = MutableStateFlow<Health.State?>(healthState(derpWarning()))
val ipnFlow = MutableStateFlow(Ipn.State.Stopped)
val notifier = HealthNotifier(healthFlow, ipnFlow, scope)
assertTrue(notifier.currentWarnings.value.isEmpty())
assertNull(notifier.currentIcon.value)
} finally {
scope.cancel()
}
}

@Test
fun staleWarningFromStoppedDoesNotPersistAfterToggleOn() = runTest {
val (healthFlow, ipnFlow, notifier) = createRunningNotifier()
Expand Down
Loading