From 849f3e1ceba78443f8b7c8179655342e85a94b11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 08:44:26 +0000 Subject: [PATCH 1/2] cleanup(ios): make elapsedRealtimeMillis count through device sleep NSProcessInfo.systemUptime pauses while the phone is asleep, so rest deadlines armed on that clock under-counted until the process was restored. Use Darwin CLOCK_MONOTONIC_RAW, which follows mach_continuous_time and keeps counting through sleep. Co-authored-by: Devil <9thLevelSoftware@users.noreply.github.com> --- .../domain/model/PlatformUtils.kt | 5 ++- .../domain/model/PlatformUtils.ios.kt | 15 +++++++- .../domain/model/ElapsedRealtimeIosTest.kt | 37 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.kt index 5b4317d3f..81b35b904 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.kt @@ -10,6 +10,9 @@ package com.devil.phoenixproject.domain.model expect fun currentTimeMillis(): Long /** - * Get a monotonic elapsed time in milliseconds suitable for interval timing. + * Milliseconds since boot on a monotonic clock, including time spent in sleep. + * + * Matches Android `SystemClock.elapsedRealtime()`. Rest deadlines are armed on + * this clock, so both platforms must keep counting while the device is asleep. */ expect fun elapsedRealtimeMillis(): Long diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt index eac99b629..639a94ed4 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt @@ -1,9 +1,20 @@ package com.devil.phoenixproject.domain.model +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UnsafeNumber +import kotlinx.cinterop.convert import platform.Foundation.NSDate -import platform.Foundation.NSProcessInfo import platform.Foundation.timeIntervalSince1970 +import platform.posix.CLOCK_MONOTONIC_RAW +import platform.posix.clock_gettime_nsec_np actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() -actual fun elapsedRealtimeMillis(): Long = (NSProcessInfo.processInfo.systemUptime * 1000).toLong() +/** + * Milliseconds from Darwin `CLOCK_MONOTONIC_RAW`, which follows `mach_continuous_time` + * and keeps counting through sleep. `NSProcessInfo.systemUptime` stops while the + * device is asleep, so rest armed on that clock under-counted on a locked phone. + */ +@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) +actual fun elapsedRealtimeMillis(): Long = + (clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW.convert()) / 1_000_000uL).toLong() diff --git a/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt b/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt new file mode 100644 index 000000000..a002deb1d --- /dev/null +++ b/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt @@ -0,0 +1,37 @@ +package com.devil.phoenixproject.domain.model + +import kotlin.test.Test +import kotlin.test.assertTrue +import platform.Foundation.NSProcessInfo + +/** + * The simulator cannot be put to sleep here. These checks lock the part of the + * contract that is still observable: a positive boot-relative reading that does + * not move backwards, is not wall-clock time, and is at least the awake uptime + * (time spent asleep can only add to [elapsedRealtimeMillis]). + */ +class ElapsedRealtimeIosTest { + @Test + fun elapsedRealtimeIsBootRelativeAndDoesNotGoBackwards() { + val first = elapsedRealtimeMillis() + val awakeUptimeMs = (NSProcessInfo.processInfo.systemUptime * 1000.0).toLong() + val second = elapsedRealtimeMillis() + + assertTrue(first > 0L, "elapsed realtime should be past boot, was $first") + assertTrue(second >= first, "elapsed realtime went backwards: $first then $second") + assertTrue( + second < WALL_CLOCK_MS, + "elapsed realtime looks like wall-clock time: $second", + ) + assertTrue( + second + SAMPLE_SKEW_MS >= awakeUptimeMs, + "sleep-inclusive clock $second is behind awake uptime $awakeUptimeMs", + ) + } + + private companion object { + /** Below any current Unix epoch in milliseconds (2001-09-09). */ + private const val WALL_CLOCK_MS = 1_000_000_000_000L + private const val SAMPLE_SKEW_MS = 2_000L + } +} From 82e615acc404559c8c3f149e865447aa6000132d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:16:18 +0000 Subject: [PATCH 2/2] cleanup(ios): cite the Darwin clock mapping and make the iOS clock test a smoke check The KDoc now cites Apple Libc gen/clock_gettime.c: CLOCK_MONOTONIC_RAW reads mach_continuous_time() (counts through sleep), CLOCK_UPTIME_RAW reads mach_absolute_time() (stops while asleep). The "at least the awake uptime" assertion also held for the old NSProcessInfo.systemUptime implementation, so it could not catch a regression. Drop it and say plainly that the simulator test is a smoke check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015tsDJ6LzV6FwGD3VQwfNEn --- .../domain/model/PlatformUtils.ios.kt | 4 ++++ .../domain/model/ElapsedRealtimeIosTest.kt | 16 +++++----------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt index 639a94ed4..4f9624846 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/PlatformUtils.ios.kt @@ -14,6 +14,10 @@ actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).t * Milliseconds from Darwin `CLOCK_MONOTONIC_RAW`, which follows `mach_continuous_time` * and keeps counting through sleep. `NSProcessInfo.systemUptime` stops while the * device is asleep, so rest armed on that clock under-counted on a locked phone. + * + * Darwin clock ids are not Linux ones. Apple Libc `gen/clock_gettime.c` reads + * `CLOCK_MONOTONIC_RAW` from `mach_continuous_time()` (counts while asleep) and + * `CLOCK_UPTIME_RAW` from `mach_absolute_time()` (stops while asleep). */ @OptIn(ExperimentalForeignApi::class, UnsafeNumber::class) actual fun elapsedRealtimeMillis(): Long = diff --git a/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt b/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt index a002deb1d..7028c02df 100644 --- a/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt +++ b/shared/src/iosTest/kotlin/com/devil/phoenixproject/domain/model/ElapsedRealtimeIosTest.kt @@ -2,19 +2,18 @@ package com.devil.phoenixproject.domain.model import kotlin.test.Test import kotlin.test.assertTrue -import platform.Foundation.NSProcessInfo /** - * The simulator cannot be put to sleep here. These checks lock the part of the - * contract that is still observable: a positive boot-relative reading that does - * not move backwards, is not wall-clock time, and is at least the awake uptime - * (time spent asleep can only add to [elapsedRealtimeMillis]). + * Smoke check only. The simulator cannot be put to sleep here, so nothing in this + * test tells the sleep-inclusive clock from the awake-only one; that rests on the + * clock id cited in `PlatformUtils.ios.kt`. These checks lock what is observable: + * a positive boot-relative reading that does not move backwards and is not + * wall-clock time. */ class ElapsedRealtimeIosTest { @Test fun elapsedRealtimeIsBootRelativeAndDoesNotGoBackwards() { val first = elapsedRealtimeMillis() - val awakeUptimeMs = (NSProcessInfo.processInfo.systemUptime * 1000.0).toLong() val second = elapsedRealtimeMillis() assertTrue(first > 0L, "elapsed realtime should be past boot, was $first") @@ -23,15 +22,10 @@ class ElapsedRealtimeIosTest { second < WALL_CLOCK_MS, "elapsed realtime looks like wall-clock time: $second", ) - assertTrue( - second + SAMPLE_SKEW_MS >= awakeUptimeMs, - "sleep-inclusive clock $second is behind awake uptime $awakeUptimeMs", - ) } private companion object { /** Below any current Unix epoch in milliseconds (2001-09-09). */ private const val WALL_CLOCK_MS = 1_000_000_000_000L - private const val SAMPLE_SKEW_MS = 2_000L } }