mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-14 20:17:28 +00:00
fix(android): time out hung game-state and settings reads so loading gates cannot stick
This commit is contained in:
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user