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
}
}