fix(infrared): add cutoff timestamp to failInterruptedLearning to prevent race with subsequent tasks

This commit is contained in:
2026-08-25 13:07:38 +08:00
parent a3812aae27
commit 2185e73f5e
9 changed files with 89 additions and 12 deletions
@@ -139,6 +139,25 @@ class RoomInfraredKeyRepositoryTest {
assertEquals(InfraredKeyStatus.READY, k3.status)
}
@Test
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
val countResult = repository.failInterruptedLearning(
profileId = ProviderProfileId("profile1"),
cutoffEpochMillis = 150L,
updatedAtEpochMillis = 150L,
) as DomainResult.Success
assertEquals(1, countResult.value)
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
}
@Test
fun persistsAndObservesDeviceName() = runTest {
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
@@ -39,9 +39,17 @@ class InfraredLearningCoordinator(
relearnInfraredKey(ref)
}
fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) {
fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long = System.currentTimeMillis(),
updatedAtEpochMillis: Long = cutoffEpochMillis,
) {
externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis)
keyRepository.failInterruptedLearning(
profileId = profileId,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
}
}
@@ -81,13 +81,16 @@ interface InfraredDao {
lastErrorCode = NULL,
lastErrorDetail = :errorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND status = 'LEARNING'
WHERE profileId = :profileId
AND status = 'LEARNING'
AND createdAtEpochMillis <= :cutoffEpochMillis
""",
)
suspend fun failInterruptedLearning(
profileId: String,
targetStatus: String,
errorDetail: String,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): Int
@@ -11,6 +11,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -147,6 +148,7 @@ class RoomInfraredKeyRepository(
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
return try {
@@ -154,10 +156,14 @@ class RoomInfraredKeyRepository(
profileId = profileId.value,
targetStatus = InfraredKeyStatus.FAILED.name,
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
DomainResult.Success(count)
} catch (throwable: Throwable) {
if (throwable is CancellationException) {
throw throwable
}
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
@@ -77,11 +77,12 @@ class InfraredRemoteViewModel @Inject constructor(
init {
if (authenticatedProfileId != null) {
viewModelScope.launch {
val startupCutoff = System.currentTimeMillis()
runtimeModeStore.mode.collect { mode ->
val effective = profileResolver.resolve(authenticatedProfileId, mode)
learningCoordinator.failInterruptedLearning(effective)
learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff)
if (effective != authenticatedProfileId) {
learningCoordinator.failInterruptedLearning(authenticatedProfileId)
learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff)
}
}
}
@@ -185,13 +186,14 @@ class InfraredRemoteViewModel @Inject constructor(
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (!enabled) {
val shutdownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile)
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
}
} else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
@@ -285,12 +287,13 @@ class InfraredRemoteViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
val teardownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
state.value.effectiveProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile)
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
}
}