From 910037ad8e803bc3b808afaf6355bf40c6b4db05 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 17:48:28 -0400 Subject: [PATCH] refactor(quartz): add TimeUtils.nowMillis, drop raw System calls in pagers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-paging trackers measured elapsed wall-clock with System.currentTimeMillis() directly. Introduce a multiplatform millisecond clock (currentTimeMillis expect/actual across jvm/android/ios/macos/linux, mirroring the existing currentTimeSeconds) exposed as TimeUtils.nowMillis(), and route PerRelayLoadTracker + WindowLoadTracker through it. TimeUtils.now() is seconds, so it can't be used for the ms-scale silence / idle / linger timers — nowMillis() is the correct primitive. No behavior change (same underlying clock on JVM/Android). Note: the two trackers stay in jvmAndroid for now — @Synchronized has no commonMain equivalent. ConcurrentHashMap is kept deliberately: LargeCache is a sorted ConcurrentSkipListMap (Comparable, compareTo-identity keys) and the pager keys (ConvoKey not Comparable; ChatroomKey.compareTo is hashCode-based) don't satisfy that, so ConcurrentHashMap is the correct structure here. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/quartz/utils/Platform.android.kt | 2 ++ .../kotlin/com/vitorpamplona/quartz/utils/Platform.kt | 2 ++ .../com/vitorpamplona/quartz/utils/TimeUtils.kt | 2 ++ .../com/vitorpamplona/quartz/utils/Platform.ios.kt | 2 ++ .../relay/client/paging/PerRelayLoadTracker.kt | 9 +++++---- .../relay/client/paging/WindowLoadTracker.kt | 11 ++++++----- .../com/vitorpamplona/quartz/utils/Platform.jvm.kt | 2 ++ .../com/vitorpamplona/quartz/utils/Platform.linux.kt | 9 +++++++++ .../com/vitorpamplona/quartz/utils/Platform.macos.kt | 2 ++ 9 files changed, 32 insertions(+), 9 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt index 88befaaa2c..1acffd1427 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "Android" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt index b007a052eb..3a49a2df69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils expect fun platform(): String expect fun currentTimeSeconds(): Long + +expect fun currentTimeMillis(): Long diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index 90ca1272c8..b8f1a09ec8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -36,6 +36,8 @@ object TimeUtils { fun now() = currentTimeSeconds() + fun nowMillis() = currentTimeMillis() + fun tenSecondsFromNow() = now() + TEN_SECONDS fun tenSecondsAgo() = now() - TEN_SECONDS diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt index 9f2b842edb..fee99bbf15 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt @@ -31,3 +31,5 @@ actual fun currentTimeSeconds(): Long { // NSDate().timeIntervalSince1970 returns seconds since 1970-01-01 00:00:00 UTC return (NSDate().timeIntervalSince1970).toLong() } + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt index 5b97e2fefe..12333caf38 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.paging import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -82,14 +83,14 @@ class PerRelayLoadTracker( clearJob?.cancel() // a new page is starting — keep the spinner up, no flicker clearJob = null inFlight.add(relay) - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() _loading.value = true ensureWatchdog() } /** A sign of life from a relay (an event). Keeps the silence watchdog from firing. */ fun onActivity() { - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() } /** @@ -101,7 +102,7 @@ class PerRelayLoadTracker( */ @Synchronized fun onSettled(relay: NormalizedRelayUrl) { - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() if (inFlight.remove(relay) && inFlight.isEmpty()) scheduleClear() } @@ -141,7 +142,7 @@ class PerRelayLoadTracker( delay(WATCHDOG_TICK_MS) val silenced = synchronized(this@PerRelayLoadTracker) { - if (inFlight.isNotEmpty() && System.currentTimeMillis() - lastActivityMs > silenceMs) { + if (inFlight.isNotEmpty() && TimeUtils.nowMillis() - lastActivityMs > silenceMs) { val pending = inFlight.toSet() inFlight.clear() _loading.value = false diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt index fa5b16b47d..c5d99df778 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -104,17 +105,17 @@ class WindowLoadTracker( expected = emptySet() heardFrom.clear() settled.clear() - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() val wasLoading = _loading.value _loading.value = true Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } watchdog?.cancel() watchdog = scope.launch { - val deadline = System.currentTimeMillis() + absoluteCap.inWholeMilliseconds + val deadline = TimeUtils.nowMillis() + absoluteCap.inWholeMilliseconds while (isActive) { delay(IDLE_CHECK_MS) - if (!tick(gen, System.currentTimeMillis(), deadline)) break + if (!tick(gen, TimeUtils.nowMillis(), deadline)) break } } } @@ -164,7 +165,7 @@ class WindowLoadTracker( /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ fun onRelayEvent(relay: NormalizedRelayUrl) { heardFrom.add(relay) - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() } /** @@ -173,7 +174,7 @@ class WindowLoadTracker( */ @Synchronized fun onRelaySettled(relay: NormalizedRelayUrl) { - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() heardFrom.add(relay) settled.add(relay) if (expected.isNotEmpty() && settled.containsAll(expected)) finish("all relays") diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt index d4b4f6b5e5..d6979a6e06 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "JVM" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt index 7e4b5fc506..0b02bf8b58 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt @@ -38,3 +38,12 @@ actual fun currentTimeSeconds(): Long { return ts.tv_sec } } + +@OptIn(ExperimentalForeignApi::class) +actual fun currentTimeMillis(): Long { + memScoped { + val ts = alloc() + clock_gettime(CLOCK_REALTIME, ts.ptr) + return ts.tv_sec * 1000 + ts.tv_nsec / 1_000_000 + } +} diff --git a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt index 75f7e49519..49c1daf844 100644 --- a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt +++ b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt @@ -26,3 +26,5 @@ import platform.Foundation.timeIntervalSince1970 actual fun platform() = "macOS" actual fun currentTimeSeconds(): Long = (NSDate().timeIntervalSince1970).toLong() + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()