| name | ha-android-concurrency |
| description | Home Assistant Android coroutine and threading guidance. Use when launching coroutines, choosing dispatchers, sharing mutable state across threads, exposing Flows, or reviewing for blocking calls and race conditions. |
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
CoroutineScope per object: the lifecycle owner (service, screen) owns the scope and passes it (or the caller's coroutineScope { }) 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.
synchronized blocks are forbidden; use Mutex or 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:
Default for CPU-bound work, IO for blocking network or storage calls. Don't reflexively add withContext(Dispatchers.IO) around calls that are already main-safe (repository suspend functions usually are).
- Only use
Main for system APIs that require the main thread, and keep the work inside it to the strict minimum: precompute on Default, then withContext(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 }
return mutex.withLock {
value?.let { return it }
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
runTest using a TestDispatcher to control virtual time.
- For the Main-dispatcher swap helpers,
TestDispatcher choice, and Turbine Flow rules, see the ha-android-testing skill.