commit 8c5ad79b9ddc9bbf32a303d0d1f26d59c25a755f
parent 61c2e4f7a62e8e3be51346495489801edbfa6a3a
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 00:53:19 +0900
Clamp crash-reconciled solve intervals to the reopen instant
When the first open of a game since launch found a session still open —
the previous run was force-quit or crashed without a clean seal — it
banked that dead sitting bounded at the last heartbeat plus the open
grace, or the hard session cap, whichever came first. Neither floor was
the present, so a relaunch less than the grace window after the crash
sealed an interval that ran past now. The fresh live session then opened
inside that already-sealed interval, and because the game clock is the
union of every interval, up to the whole grace window of unplayed time
was credited to the solve.
This commit adds now as a third floor to the bounded end, so a crashed
sitting is never sealed further than the moment it is reconciled. This
matches the peer-extrapolation read path, which already caps another
device's open session at min(now, beatAt + grace); the seal path now
applies the same clamp the reader does. The existing guard that skips
the insertion unless the bounded end is past the open start continues to
absorb the degenerate clock-skew case where now precedes the open start.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/Crossmate/Models/TimeLog.swift b/Crossmate/Models/TimeLog.swift
@@ -72,7 +72,8 @@ struct TimeLog: Codable, Equatable {
if reconcileStale {
let boundedEnd = min(
(slot.beatAt ?? openStart).addingTimeInterval(Self.openGrace),
- openStart.addingTimeInterval(Self.maxSessionCap)
+ openStart.addingTimeInterval(Self.maxSessionCap),
+ now
)
if boundedEnd > openStart {
slot.intervals = Self.inserting(
diff --git a/Tests/Unit/TimeLogTests.swift b/Tests/Unit/TimeLogTests.swift
@@ -187,6 +187,24 @@ struct TimeLogTests {
#expect(total == 100 + TimeLog.openGrace + 60)
}
+ @Test("A crash reconciled soon after the last beat seals no further than now")
+ func staleReconcileDoesNotSealPastNow() {
+ var log = TimeLog()
+ // Sitting one: opened at 0, last heartbeat at 60, then force-quit. The
+ // relaunch's first open lands at 90 — inside beat(60) + grace(240) = 300,
+ // so the crashed sitting must bank only [0, 90], never the future tail.
+ log.open(deviceID: "alice-phone", at: at(0))
+ log.beat(deviceID: "alice-phone", at: at(60))
+ log.open(deviceID: "alice-phone", at: at(90), reconcileStale: true)
+ log.seal(deviceID: "alice-phone", at: at(150))
+ let total = TimeLog.accumulatedSeconds(
+ forLogs: [log], localDeviceID: "alice-phone", asOf: at(200)
+ )
+ // Crashed sitting [0, 90] clamped to the reopen instant, plus the fresh
+ // 60s sitting [90, 150] — no unplayed [90, 300] tail double-counted.
+ #expect(total == 90 + 60)
+ }
+
@Test("A resume (not reconciled) keeps one continuous session, no split")
func resumeKeepsContinuousSession() {
var log = TimeLog()