feat(system): repair running timers and the stopwatch after a reboot

Closes M2's known blind spot. On the first pass of a new boot, every RUNNING
timer has its monotonic anchor rewritten from its wall-clock fallback — one
already past its end becomes EXPIRED, one with no fallback becomes PAUSED at
its banked remaining. The stopwatch keeps no wall-clock fallback at all, so it
is paused at what it had banked rather than being allowed to invent a segment
it never ran.

The gate is persisted, so the repair runs once per boot and survives a process
death in between. ARCHITECTURE §5 booked the stopwatch half of this to M7; it
belongs here with the timer half, and the roadmap is amended to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:04:53 +02:00
co-authored by Claude Opus 5
parent 57605f22f6
commit 92483cac4b
9 changed files with 367 additions and 0 deletions
@@ -19,4 +19,7 @@ interface StopwatchRepository {
/** Marks a lap at the current elapsed value. Returns null unless the stopwatch is RUNNING. */
suspend fun lap(): Lap?
/** PAUSED at the banked `accumulated`, discarding the segment the reboot lost. No-op otherwise. */
suspend fun pauseAfterReboot()
}
@@ -79,4 +79,17 @@ class StopwatchRepositoryImpl @Inject constructor(
stateStore.set(run.copy(lastLapCumulative = cumulative))
return LapMapper.toDomain(entity)
}
/**
* The segment since [StopwatchRun.startedAtElapsedRealtime] ran on a clock the
* reboot reset, so it cannot be measured — and the stopwatch has no wall-clock
* fallback by design. Banking what was already accumulated loses the segment
* honestly; carrying the anchor over would invent one.
*/
override suspend fun pauseAfterReboot() {
val run = stateStore.current()
if (run.state != StopwatchState.RUNNING) return
stateStore.set(run.copy(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null))
}
}
@@ -18,6 +18,9 @@ abstract class TimerDao {
@Query("SELECT * FROM timers WHERE id = :id LIMIT 1")
abstract suspend fun findById(id: Long): TimerEntity?
@Query("SELECT * FROM timers WHERE state = 'RUNNING'")
abstract suspend fun running(): List<TimerEntity>
@Query("SELECT MAX(sort_order) FROM timers")
abstract suspend fun maxSortOrder(): Int?
@@ -27,4 +27,13 @@ interface TimerRepository {
suspend fun markExpired(id: Long)
suspend fun reorder(idsInOrder: List<Long>)
/**
* Rewrites every RUNNING timer's monotonic anchors from its wall-clock
* fallback. Call once at the top of a new boot, before the new uptime can
* climb past a stored anchor. A timer already past its wall-clock end
* becomes EXPIRED; one with no wall-clock fallback becomes PAUSED at its
* banked remaining.
*/
suspend fun repairAfterReboot()
}
@@ -100,6 +100,32 @@ class TimerRepositoryImpl @Inject constructor(
dao.reorder(idsInOrder.filter { it in known })
}
/**
* A reboot invalidates every monotonic anchor, and the new boot's uptime
* eventually climbs past the stored one — at which point a dead anchor looks
* live. The wall-clock end is the only thing a reboot leaves standing, so
* every RUNNING timer is re-anchored from it.
*/
override suspend fun repairAfterReboot() {
val now = elapsedRealtimeClock.elapsedRealtime()
val wall = wallClock.now()
for (entity in dao.running()) {
edit(entity.id, wall) { timer ->
val endsAt = timer.endsAtWallClock
// No fallback to re-anchor from: bank what it had rather than
// invent a remainder.
?: return@edit timer.copy(state = TimerState.PAUSED).cleared()
val left = endsAt - wall
if (left <= Duration.ZERO) {
timer.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO).cleared()
} else {
timer.anchored(left, now, wall)
}
}
}
}
/**
* The row is read, transformed and written back in one DAO transaction, so a
* second writer racing this one cannot have its write swallowed whole. A
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.clockula.system
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
import javax.inject.Inject
import javax.inject.Singleton
/**
* The boot-id gate. Once per boot it re-anchors running timers and pauses the
* stopwatch, both of which hold monotonic anchors a reboot invalidated. The gate
* is persisted rather than held in memory, so a process death inside a boot does
* not make the repair run twice — and a `BOOT_COMPLETED` the system never
* delivered still gets repaired at the next launch.
*/
@Singleton
open class RebootRepair @Inject constructor(
private val bootIds: BootIdProvider,
private val bootState: BootStateStore,
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
) {
/**
* True when this is the first call of a new boot (including the very first
* run ever, where there is no stored id). Idempotent within a boot.
*/
open suspend fun repairIfRebooted(): Boolean {
val current = bootIds.current()
val last = bootState.lastBootId()
if (last != null && last.isSameBootAs(current)) return false
timers.repairAfterReboot()
stopwatch.pauseAfterReboot()
bootState.setLastBootId(current)
return true
}
}
@@ -222,4 +222,69 @@ class StopwatchRepositoryTest {
)
assertThat(repository.laps().first().map { it.index }).containsExactly(1)
}
// --- M3: the post-reboot repair ---
@Test
fun `pausing after a reboot banks what was accumulated and invents no segment`(@TempDir tempDir: Path) =
runTest {
val repository = repository(tempDir)
repository.start()
clock.reboot(20.seconds)
repository.pauseAfterReboot()
val run = repository.run().first()
assertThat(run.state).isEqualTo(StopwatchState.PAUSED)
assertThat(run.accumulated).isEqualTo(Duration.ZERO)
assertThat(run.startedAtElapsedRealtime).isNull()
}
@Test
fun `pausing after a reboot leaves a paused or idle run untouched`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_030.seconds
repository.pause()
val paused = repository.run().first()
repository.pauseAfterReboot()
assertThat(repository.run().first()).isEqualTo(paused)
}
@Test
fun `an idle run is not written at all by the reboot repair`(@TempDir tempDir: Path) = runTest {
val dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { tempDir.resolve("stopwatch_idle_test.preferences_pb").toFile() },
)
val repository = StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock)
repository.pauseAfterReboot()
assertThat(dataStore.data.first().asMap()).isEmpty()
}
@Test
fun `the record written by the reboot repair still holds no wall-clock value`(@TempDir tempDir: Path) =
runTest {
val dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { tempDir.resolve("stopwatch_keys_test.preferences_pb").toFile() },
)
val repository = StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock)
repository.start()
clock.reboot(20.seconds)
repository.pauseAfterReboot()
val snapshot = dataStore.data.first().asMap()
assertThat(snapshot.values.filterIsInstance<Long>().filter { it >= 1_000_000_000_000L }).isEmpty()
assertThat(snapshot.keys.map { it.name }).containsExactly(
"stopwatch_state",
"stopwatch_accumulated_millis",
"stopwatch_last_lap_cumulative_millis",
)
}
}
@@ -299,4 +299,92 @@ class TimerRepositoryTest {
assertThat(repository.timers().first()).isEqualTo(before)
assertThat(repository.find(999L)).isNull()
}
// --- M3: the post-reboot repair ---
@Test
fun `the repair re-anchors a running timer from its wall-clock fallback`() = runTest {
val id = startedTimer()
elapsed.reboot(50.seconds)
wallClock.advance(2.minutes)
repository.repairAfterReboot()
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.RUNNING)
assertThat(timer.remaining).isEqualTo(3.minutes)
assertThat(timer.endsAtElapsedRealtime).isEqualTo(50.seconds + 3.minutes)
assertThat(timer.startedAtElapsedRealtime).isEqualTo(50.seconds)
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 5.minutes)
}
@Test
fun `a running timer whose wall-clock end has passed is expired by the repair`() = runTest {
val id = startedTimer()
elapsed.reboot(50.seconds)
wallClock.advance(10.minutes)
repository.repairAfterReboot()
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.EXPIRED)
assertThat(timer.remaining).isEqualTo(Duration.ZERO)
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).containsExactly(null, null, null)
}
@Test
fun `the repair leaves every timer that was not running exactly as it was`() = runTest {
val paused = repository.create(TimerDraft(5.minutes, "paused"))
repository.start(paused)
elapsed.value = 1_100.seconds
repository.pause(paused)
repository.create(TimerDraft(3.minutes, "idle"))
val expired = repository.create(TimerDraft(1.minutes, "expired"))
repository.markExpired(expired)
elapsed.reboot(50.seconds)
wallClock.advance(2.minutes)
val before = dao.stored
repository.repairAfterReboot()
assertThat(dao.stored).isEqualTo(before)
}
@Test
fun `a running timer with no wall-clock fallback is paused at what it banked`() = runTest {
val id = startedTimer()
dao.update(dao.findById(id)!!.copy(endsAtWallClockMillis = null))
elapsed.reboot(50.seconds)
repository.repairAfterReboot()
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.PAUSED)
assertThat(timer.remaining).isAtLeast(Duration.ZERO)
assertThat(timer.remaining).isEqualTo(5.minutes)
}
@Test
fun `the repair closes the blind spot a reboot opens under a running timer`() = runTest {
val id = repository.create(TimerDraft(2.hours, "Roast"))
repository.start(id)
// A new boot whose uptime has already climbed past the stored start anchor,
// and half an hour of wall-clock time gone with it.
elapsed.reboot(5_000.seconds)
wallClock.advance(30.minutes)
val beforeRepair = repository.find(id)!!.snapshotAt(elapsed.elapsedRealtime(), wallClock.now())
assertThat(beforeRepair.isRunning).isTrue()
assertThat(beforeRepair.anchorIsStale).isFalse()
assertThat(beforeRepair.remaining).isNotEqualTo(90.minutes)
repository.repairAfterReboot()
val repaired = repository.find(id)!!.snapshotAt(elapsed.elapsedRealtime(), wallClock.now())
assertThat(repaired.remaining - 90.minutes).isLessThan(1.seconds)
assertThat(90.minutes - repaired.remaining).isLessThan(1.seconds)
}
}
@@ -0,0 +1,122 @@
package de.jeanlucmakiola.clockula.system
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
import de.jeanlucmakiola.clockula.data.prefs.SystemPrefs
import de.jeanlucmakiola.clockula.domain.time.BootId
import de.jeanlucmakiola.clockula.testing.FakeBootIdProvider
import de.jeanlucmakiola.clockula.testing.RecordingStopwatchRepository
import de.jeanlucmakiola.clockula.testing.RecordingTimerRepository
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
import kotlin.time.Duration.Companion.hours
/**
* The gate that runs the reboot repair exactly once per boot, over a real
* DataStore file so "survives process death" is a real assertion.
*/
class RebootRepairTest {
private val bootIds = FakeBootIdProvider(BootId(bootCount = 7, approximateBootInstant = T0))
private val timers = RecordingTimerRepository()
private val stopwatch = RecordingStopwatchRepository()
private fun TestScope.bootStateStore(file: File, job: Job = Job()) =
BootStateStore(PrefStore(prefStore(file, job)))
private fun TestScope.prefStore(file: File, job: Job) = PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + job),
produceFile = { file },
)
private fun repair(bootState: BootStateStore) = RebootRepair(bootIds, bootState, timers, stopwatch)
private fun Path.prefsFile(): File = resolve("clockula_prefs_test.preferences_pb").toFile()
@Test
fun `the very first run ever counts as a new boot and repairs`(@TempDir dir: Path) = runTest {
val bootState = bootStateStore(dir.prefsFile())
val repaired = repair(bootState).repairIfRebooted()
assertThat(repaired).isTrue()
assertThat(timers.repairCalls).isEqualTo(1)
assertThat(stopwatch.pauseCalls).isEqualTo(1)
assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId)
}
@Test
fun `the same boot repairs nothing`(@TempDir dir: Path) = runTest {
val bootState = bootStateStore(dir.prefsFile())
bootState.setLastBootId(bootIds.bootId)
val repaired = repair(bootState).repairIfRebooted()
assertThat(repaired).isFalse()
assertThat(timers.repairCalls).isEqualTo(0)
assertThat(stopwatch.pauseCalls).isEqualTo(0)
}
@Test
fun `a different boot repairs and stores the new id`(@TempDir dir: Path) = runTest {
val bootState = bootStateStore(dir.prefsFile())
bootState.setLastBootId(BootId(bootCount = 6, approximateBootInstant = T0 - 5.hours))
val repaired = repair(bootState).repairIfRebooted()
assertThat(repaired).isTrue()
assertThat(timers.repairCalls).isEqualTo(1)
assertThat(stopwatch.pauseCalls).isEqualTo(1)
assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId)
}
@Test
fun `calling twice in one boot repairs once`(@TempDir dir: Path) = runTest {
val repair = repair(bootStateStore(dir.prefsFile()))
val first = repair.repairIfRebooted()
val second = repair.repairIfRebooted()
assertThat(listOf(first, second)).containsExactly(true, false).inOrder()
assertThat(timers.repairCalls).isEqualTo(1)
assertThat(stopwatch.pauseCalls).isEqualTo(1)
}
@Test
fun `a malformed stored id reads as no known boot and is replaced`(@TempDir dir: Path) = runTest {
val file = dir.prefsFile()
val store = PrefStore(prefStore(file, Job()))
store.set(SystemPrefs.lastBootId, "not-a-boot-id")
val bootState = BootStateStore(store)
val repaired = repair(bootState).repairIfRebooted()
assertThat(repaired).isTrue()
assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId)
}
@Test
fun `the gate survives process death, so the repair does not run twice per boot`(@TempDir dir: Path) =
runTest {
val file = dir.prefsFile()
val firstProcess = Job()
repair(bootStateStore(file, firstProcess)).repairIfRebooted()
firstProcess.cancel()
val afterRestart = bootStateStore(file, Job())
assertThat(afterRestart.lastBootId()).isEqualTo(bootIds.bootId)
assertThat(repair(afterRestart).repairIfRebooted()).isFalse()
assertThat(timers.repairCalls).isEqualTo(1)
}
}