diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 68780b4d62..7b59dc24f7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -839,6 +839,18 @@ fun App( relayManager.startMetricsSnapshot(this) } + // Detect host-machine sleep/wake: after a long delay overshoot the OkHttp + // sockets we held are dead even though needsToReconnect() still reads false, + // so force a hard disconnect+connect. See SleepResumeMonitor.kt. + LaunchedEffect(relayManager) { + com.vitorpamplona.amethyst.desktop.network.runSleepResumeMonitor { + relayManager.client.reconnect( + onlyIfChanged = false, + ignoreRetryDelays = true, + ) + } + } + // Subscriptions coordinator — uses default relay URLs for metadata indexing. // Feed subscriptions (inside MainContent) drive actual relay pool connections. val subscriptionsCoordinator = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt new file mode 100644 index 0000000000..c2d8d3195d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/SleepResumeMonitor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.network + +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.coroutines.coroutineContext + +/** + * Detects host-machine sleep/wake transitions by watching wall-clock overshoot + * of a tight delay loop. When the OS suspends the JVM, [delay] returns far past + * its scheduled deadline; the OkHttp websocket connections we held are dead by + * then even though [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient] + * still reports `isConnected() == true` until the next ping fails — so the + * standard keep-alive reconnect path is a no-op and the offline banner stays + * stuck until a manual reload. + * + * Lives here in the desktop app (per Vitor's review of #3221) instead of the + * cross-platform NostrClient because sleep/wake semantics differ across + * platforms — Android has Doze + network change broadcasts, iOS has app + * lifecycle events, and macOS/Linux/Windows desktops can grow real OS-level + * sleep hooks here later (NSWorkspace notifications, D-Bus PrepareForSleep, + * WM_POWERBROADCAST) without touching Quartz. + * + * Real OS sleep events would be more precise, but the wall-clock heuristic + * needs zero native deps and catches the symptom for v1. + */ +suspend fun runSleepResumeMonitor( + intervalMs: Long = DEFAULT_INTERVAL_MS, + wakeThresholdMs: Long = DEFAULT_WAKE_THRESHOLD_MS, + nowMs: () -> Long = { System.currentTimeMillis() }, + onWake: () -> Unit, +) { + var lastTickMs = nowMs() + while (coroutineContext.isActive) { + delay(intervalMs) + val now = nowMs() + val elapsed = now - lastTickMs + lastTickMs = now + if (elapsed > wakeThresholdMs) onWake() + } +} + +private const val DEFAULT_INTERVAL_MS: Long = 60_000L + +// 5x the tick — wide enough to ignore GC stalls / brief scheduler hiccups, tight +// enough to recover quickly after a real sleep. +private const val DEFAULT_WAKE_THRESHOLD_MS: Long = 5 * DEFAULT_INTERVAL_MS diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 092ae94eea..693aa74661 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -165,27 +164,12 @@ class NostrClient( * error code) would stay disconnected forever in the absence of any * subscription change. The per-relay [BasicRelayClient] backoff still * gates the actual reconnect attempt, so dead relays are not hammered. - * - * Also detects system sleep/resume by tracking wall-clock overshoot of the - * scheduled tick. If the [delay] returned far later than expected the host - * was almost certainly suspended (laptop lid closed, OS sleep), and the - * OkHttp websockets we held are dead even though [isConnected] still reads - * true until the next ping fails. In that case force a hard reconnect. */ private val keepAliveJob = scope.launch { - var lastTickMs = TimeUtils.nowMillis() while (true) { delay(KEEP_ALIVE_INTERVAL_MS) - if (!this@NostrClient.isActive) continue - val now = TimeUtils.nowMillis() - val elapsed = now - lastTickMs - lastTickMs = now - if (elapsed > KEEP_ALIVE_WAKE_THRESHOLD_MS) { - // System likely resumed from sleep — force a hard reconnect. - relayPool.disconnect() - relayPool.connect() - } else { + if (this@NostrClient.isActive) { relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) } } @@ -193,11 +177,6 @@ class NostrClient( companion object { private const val KEEP_ALIVE_INTERVAL_MS = 60_000L - - // Treat any tick that overshoots the scheduled delay by more than this many - // milliseconds as a probable system-sleep resume. 5x interval (5 min) avoids - // firing on routine GC stalls or brief OS scheduler pauses. - private const val KEEP_ALIVE_WAKE_THRESHOLD_MS = 5 * KEEP_ALIVE_INTERVAL_MS } override fun reconnect(