fix(android): time out hung game-state and settings reads so loading gates cannot stick

This commit is contained in:
tiennm99 committed 2026-10-12 10:15:07 +07:00
1 parent b209902e53
commit b608ab0a68
5 files changed
+91 -9

No files matched your search

@@ -13,11 +13,17 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.audio.Voice
import com.miti99.loto.audio.VoiceCatalog
import java.io.IOException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withTimeoutOrNull
/**
* DataStore-backed settings store. Every field is validated independently on
@@ -28,10 +34,14 @@ import kotlinx.coroutines.flow.onStart
* ids are the voice allowlist and its first entry the default voice;
* [settingsFlow] awaits it, so no settings snapshot is ever published
* from a not-yet-loaded manifest.
* @param readTimeout how long the first DataStore read may take before
* [settingsFlow] publishes defaults instead, so consumers gated on
* "settings loaded" never wait forever on a hung store.
*/
class SettingsRepository(
private val dataStore: DataStore<Preferences>,
private val voices: Deferred<List<Voice>>,
private val readTimeout: Duration = 5.seconds,
) {
private object Keys {
@@ -65,16 +75,27 @@ class SettingsRepository(
// save" (acceptable, L4) had regressed into "settings never load and
// auto-cross is dead for the rest of the process" (not acceptable). A
// broken settings store must degrade to defaults, not to a dead board.
val settingsFlow: Flow<Settings> = dataStore.data
private val persistedPrefs: Flow<Preferences> = dataStore.data
.catch {
Log.w(TAG, "Failed to read settings; falling back to defaults", it)
emit(emptyPreferences())
}
.map { prefs -> toSettings(prefs, voices.await()) }
// A first read that hangs (rather than fails) publishes defaults once,
// then keeps following the store so the real values still land if the
// read eventually resolves.
val settingsFlow: Flow<Settings> = flow {
if (withTimeoutOrNull(readTimeout) { persistedPrefs.first() } == null) {
Log.w(TAG, "Settings read timed out after $readTimeout; using defaults")
emit(toSettings(emptyPreferences(), voices.await()))
}
emitAll(persistedPrefs.map { prefs -> toSettings(prefs, voices.await()) })
}
/**
* True once [settingsFlow] has produced its first value — either the
* real persisted read or the [catch] fallback after a read failure.
* real persisted read, the [catch] fallback after a read failure, or the
* defaults published when the first read times out.
* False only for the brief startup window before that read lands (M4
* residual): [LotoApplication]'s `settingsOrNull` is
* `stateIn(..., SharingStarted.Eagerly, null)`, so its *initial* value
@@ -9,7 +9,10 @@ import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.game.CardGenerator
import java.io.IOException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** The player's persisted round: card, marks, and explicit unticks. */
data class PlayerRoundState(
@@ -31,9 +34,13 @@ data class MasterRoundState(
* Both failure classes fall back instead of crashing (the web's
* try/catch-everything localStorage posture): malformed payloads
* shape-validate to "no saved round", and IO errors are swallowed on write
* and read as empty state on load.
* and read as empty state on load. A read that hangs past [readTimeout] also
* reads as empty state, so `loading` gates can never stick forever.
*/
class GameStateRepository(private val dataStore: DataStore<Preferences>) {
class GameStateRepository(
private val dataStore: DataStore<Preferences>,
private val readTimeout: Duration = 5.seconds,
) {
private object Keys {
val PLAYER_GRID = stringPreferencesKey("player_grid")
@@ -84,7 +91,10 @@ class GameStateRepository(private val dataStore: DataStore<Preferences>) {
}
private suspend fun readPrefs(): Preferences = try {
dataStore.data.first()
withTimeoutOrNull(readTimeout) { dataStore.data.first() } ?: run {
Log.w(TAG, "Game state read timed out after $readTimeout; falling back to empty state")
emptyPreferences()
}
} catch (e: IOException) {
// L4: was silently swallowed — a persistently failing store used to
// degrade to "round never restores" with zero signal for device QA.
@@ -11,11 +11,13 @@ import com.miti99.loto.SlowDataStore
import com.miti99.loto.ThrowingDataStore
import com.miti99.loto.audio.Voice
import com.miti99.loto.audio.VoiceCatalog
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
@@ -253,6 +255,32 @@ class SettingsRepositoryTest {
assertEquals(listOf(false, true), states)
}
@Test
fun `a hung first read publishes defaults, then the real values once it resolves`() = runTest {
val backing = InMemoryDataStore()
repo(backing).setMode(AppMode.BOTH)
val gate = CompletableDeferred<Unit>()
val repository = SettingsRepository(
SlowDataStore(backing, gate),
CompletableDeferred(VoiceCatalog.FALLBACK_VOICES),
readTimeout = 5.seconds,
)
val emitted = mutableListOf<Settings>()
backgroundScope.launch { repository.settingsFlow.toList(emitted) }
advanceTimeBy(4.seconds)
runCurrent()
assertEquals(emptyList<Settings>(), emitted)
advanceTimeBy(2.seconds)
runCurrent()
assertEquals(listOf(AppMode.PLAYER), emitted.map { it.mode })
gate.complete(Unit)
runCurrent()
assertEquals(listOf(AppMode.PLAYER, AppMode.BOTH), emitted.map { it.mode })
}
@Test
fun `settings are not published until the voice manifest has loaded`() = runTest {
// The manifest is parsed off the main thread; a snapshot published
@@ -3,11 +3,18 @@ package com.miti99.loto.state
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.InMemoryDataStore
import com.miti99.loto.SlowDataStore
import com.miti99.loto.ThrowingDataStore
import com.miti99.loto.game.CardGenerator
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
@@ -143,6 +150,25 @@ class GameStateRepositoryTest {
assertNull(repo.loadMasterState())
}
@Test
fun `a read that never resolves times out to empty state`() = runTest {
val backing = InMemoryDataStore()
GameStateRepository(backing).saveMasterState(
MasterRoundState(called = (1..10).toList(), remaining = (11..90).toList()),
)
// The gate is never completed: the store hangs instead of failing.
val repo = GameStateRepository(SlowDataStore(backing, CompletableDeferred()), readTimeout = 5.seconds)
val load = async { repo.loadMasterState() }
advanceTimeBy(4.seconds)
runCurrent()
assertFalse(load.isCompleted)
advanceTimeBy(2.seconds)
runCurrent()
assertNull(load.await())
}
@Test
fun `IO failures on write are swallowed instead of throwing (L4)`() = runTest {
val repo = GameStateRepository(ThrowingDataStore())
-3
View File
@@ -147,9 +147,6 @@ iOS Safari. No code; verification only.
## Tech debt
- **Android loading-flag timeouts.** `MasterStore.loading` and
`PlayerUiState.loading` have no timeout; a hung DataStore read would
leave the UI on its loading state. (Web has no async loads.)
- **M6 recomposition perf** — needs a profiled baseline on a device first.
- **`web/package.json` overrides.** `cookie` still forces 0.7.2 over
Kit's `^0.6.0`. `serialize-javascript` stays: it holds the floor