HA Android Concurrency
Use this skill when writing or reviewing anything asynchronous: coroutines, Flows, dispatchers, or shared mutable state.
All concurrency uses Kotlin Coroutines and Flow exclusively.
Scopes and Lifecycle
- Tie every new coroutine scope to an Android lifecycle (
viewModelScope,lifecycleScope) to prevent leaks. - Don't create a
CoroutineScopeper object: the lifecycle owner (service, screen) owns the scope and passes it (or the caller'scoroutineScope { }) to the objects it manages. Creating one scope per session/item multiplies leaks and cancellation bugs. - A child object must never cancel a scope it doesn't own — cancelling a shared scope kills unrelated siblings. If a class needs its own scope, build it internally and document the required
release()/close()call. - Never catch
kotlin.coroutines.cancellation.CancellationException— let it propagate so coroutines cancel properly.
Never Block a Thread
- Never use
runBlocking. - Prefer suspension over blocking everywhere.
synchronizedblocks are forbidden; useMutexor another non-blocking mechanism for concurrent access. - Race conditions are hard to debug — design concurrent access carefully upfront and make shared references thread-safe or don't share them.
Dispatchers
- Run heavy work on a dedicated dispatcher:
Defaultfor CPU-bound work,IOfor blocking network or storage calls. Don't reflexively addwithContext(Dispatchers.IO)around calls that are already main-safe (repository suspend functions usually are). - Only use
Mainfor system APIs that require the main thread, and keep the work inside it to the strict minimum: precompute onDefault, thenwithContext(Dispatchers.Main)for just the UI/system call. Don't run a whole function on Main because one line needs it. - Make functions main-safe whenever possible; when a function must run on a specific thread, annotate it (
@MainThread) or document it, and keep the docs accurate when call sites change. - Confine mutable state (like a session map in a service) to one thread instead of sprinkling synchronization — reads and writes from mixed dispatchers on a plain map is a recurring review finding.
Mutex and Double-Checked Locking
Use kotlinx.coroutines.sync.Mutex (never synchronized) to serialize access to shared mutable state. When a value must be initialized exactly once but reads should stay cheap after that, combine a @Volatile field with double-checked locking: read the volatile field first without locking, and only take the mutex when it's still unset.
@Volatile makes a write by one coroutine/thread immediately visible to others, so the fast path outside the lock never reads a stale or half-published value; the mutex guarantees the initializer runs once even under a concurrent race.
private val mutex = Mutex()
@Volatile private var value: T? = null
suspend fun get(): T {
value?.let { return it } // fast path, no lock
return mutex.withLock {
value?.let { return it } // re-check: another coroutine may have set it while we waited
initializer().also { value = it }
}
}
The project already provides this as SuspendLazy in :common (common/src/main/kotlin/io/homeassistant/companion/android/common/util/SuspendLazy.kt) — reuse it for lazy single initialization instead of re-implementing the pattern. Write the pattern by hand only when the caller isn't suspend or the state is reset/invalidated over time. Both re-checks inside the lock are required: dropping the second one reintroduces the race the pattern exists to close.
Testing Concurrency
- Test concurrent behavior with unit tests under
runTestusing aTestDispatcherto control virtual time. - For the Main-dispatcher swap helpers,
TestDispatcherchoice, and Turbine Flow rules, see theha-android-testingskill.