refactor(quartz): add TimeUtils.nowMillis, drop raw System calls in pagers

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-05 17:48:28 -04:00
co-authored by Claude Opus 4.8
parent cd6537bfd5
commit 910037ad8e
9 changed files with 32 additions and 9 deletions
@@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils
actual fun platform() = "Android"
actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000
actual fun currentTimeMillis() = System.currentTimeMillis()
@@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils
expect fun platform(): String
expect fun currentTimeSeconds(): Long
expect fun currentTimeMillis(): Long
@@ -36,6 +36,8 @@ object TimeUtils {
fun now() = currentTimeSeconds()
fun nowMillis() = currentTimeMillis()
fun tenSecondsFromNow() = now() + TEN_SECONDS
fun tenSecondsAgo() = now() - TEN_SECONDS
@@ -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()
@@ -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
@@ -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")
@@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils
actual fun platform() = "JVM"
actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000
actual fun currentTimeMillis() = System.currentTimeMillis()
@@ -38,3 +38,12 @@ actual fun currentTimeSeconds(): Long {
return ts.tv_sec
}
}
@OptIn(ExperimentalForeignApi::class)
actual fun currentTimeMillis(): Long {
memScoped {
val ts = alloc<timespec>()
clock_gettime(CLOCK_REALTIME, ts.ptr)
return ts.tv_sec * 1000 + ts.tv_nsec / 1_000_000
}
}
@@ -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()