diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 1f2b48106f..a98635e980 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -29,6 +29,7 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.os.RemoteException +import android.os.SystemClock import android.util.Log import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.napplet.NappletBroker @@ -44,8 +45,10 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc import com.vitorpamplona.amethyst.ui.screen.AccountState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -100,32 +103,44 @@ class NappletBrokerService : Service() { liveSubscriptions.closeAll() identityWatch.stop() // Drop any foreground holds this broker still owns so they don't leak past the service. - synchronized(foregroundTokens) { - repeat(foregroundTokens.size) { SandboxForegroundHold.release() } - foregroundTokens.clear() + synchronized(foregroundLeases) { + repeat(foregroundLeases.size) { SandboxForegroundHold.release() } + foregroundLeases.clear() } scope.cancel() super.onDestroy() } - // Launch tokens of the sandbox surfaces currently reporting themselves foreground. While this set - // is non-empty the main process is held resumed (Tor/relays/AUTH up) via SandboxForegroundHold — - // the napplet host lives in :napplet and can't touch that lifecycle itself, so it signals over IPC. - // Set semantics make repeated "foreground" reports idempotent (no double-acquire). - private val foregroundTokens = mutableSetOf() + // Sandbox surfaces (full-screen :napplet hosts) currently reporting themselves foreground, mapped to + // the last time each renewed its lease (monotonic elapsedRealtime). While this map is non-empty the + // main process is held resumed (Tor/relays/AUTH up) via SandboxForegroundHold — the napplet host + // lives in :napplet and can't touch that lifecycle itself, so it signals over IPC. + // + // The host re-sends its foreground report on a heartbeat while genuinely resumed. We key on the + // launch token, so a repeated report just refreshes the timestamp (idempotent, no double-acquire). + // If a host's process dies while foreground it can't send its onPause "false", so the heartbeats + // simply stop and [foregroundLeaseWatchdog] reaps the stale lease — bounding any such leak to one + // [FOREGROUND_LEASE_TTL_MS] window instead of holding the network up forever. + private val foregroundLeases = HashMap() + private var foregroundLeaseWatchdog: Job? = null private fun handleMessage(msg: Message): Boolean { - // A sandbox surface (full-screen :napplet host) entered or left the foreground. Hold the main - // process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays. + // A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the + // main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays. if (msg.what == NappletIpc.MSG_SET_FOREGROUND) { val data = msg.data ?: return true val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true val foreground = data.getBoolean(NappletIpc.KEY_FOREGROUND, false) - synchronized(foregroundTokens) { + synchronized(foregroundLeases) { if (foreground) { - if (foregroundTokens.add(token)) SandboxForegroundHold.acquire() - } else { - if (foregroundTokens.remove(token)) SandboxForegroundHold.release() + val firstReport = !foregroundLeases.containsKey(token) + foregroundLeases[token] = SystemClock.elapsedRealtime() + if (firstReport) { + SandboxForegroundHold.acquire() + ensureForegroundLeaseWatchdog() + } + } else if (foregroundLeases.remove(token) != null) { + SandboxForegroundHold.release() } } return true @@ -207,6 +222,34 @@ class NappletBrokerService : Service() { return true } + /** + * Periodically reaps foreground leases that stopped renewing — the signature of a `:napplet` host + * process that died (or was killed) while foreground, so it never sent its onPause "false". Each + * reaped lease releases its [SandboxForegroundHold] so the network isn't held up forever by a host + * that's already gone. Runs only while at least one lease exists; cancelled with [scope] on destroy. + */ + private fun ensureForegroundLeaseWatchdog() { + if (foregroundLeaseWatchdog != null) return + foregroundLeaseWatchdog = + scope.launch { + while (true) { + delay(FOREGROUND_LEASE_CHECK_MS) + synchronized(foregroundLeases) { + val now = SystemClock.elapsedRealtime() + val iterator = foregroundLeases.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > FOREGROUND_LEASE_TTL_MS) { + Log.w("NappletBrokerService", "Foreground lease ${entry.key} expired (host process gone?); releasing hold") + iterator.remove() + SandboxForegroundHold.release() + } + } + } + } + } + } + /** * The broker for the *currently* signed-in account, cached and rebuilt only when the account * changes (reference identity). The gateways capture the account and read its flows live, so a @@ -272,5 +315,16 @@ class NappletBrokerService : Service() { * `browser:`. It is never treated as a real pubkey. */ private const val BROWSER_IDENTITY_AUTHOR = "browser" + + /** + * How long a foreground lease stays valid without a renewing heartbeat. A live host re-reports + * every [NappletHostActivity.FOREGROUND_HEARTBEAT_MS][com.vitorpamplona.amethyst.napplethost.NappletHostActivity] + * (well under this), so only a host that's actually gone lets its lease age past it. Sized to + * tolerate a couple of missed/delayed heartbeats while still reaping a dead host's hold promptly. + */ + private const val FOREGROUND_LEASE_TTL_MS = 90_000L + + /** How often [ensureForegroundLeaseWatchdog] sweeps for stale leases. */ + private const val FOREGROUND_LEASE_CHECK_MS = 20_000L } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index d0eae482f9..14f69d42c5 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -71,8 +71,10 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject @@ -168,6 +170,10 @@ class NappletHostActivity : ComponentActivity() { // the broker binds after this surface is already resumed (bindService is async). private var resumed = false + // Renews the broker's foreground lease while resumed. If this process dies, the heartbeat stops and + // the broker reaps the stale lease, so a crash can't pin the main process's network up forever. + private var foregroundHeartbeat: Job? = null + private val brokerConnection = object : ServiceConnection { override fun onServiceConnected( @@ -303,9 +309,10 @@ class NappletHostActivity : ComponentActivity() { webView.resumeTimers() } // Launching this :napplet-process surface backgrounded the main process; tell the broker to - // hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front. + // hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and + // keep renewing that lease so a crash here can't pin the network up forever. resumed = true - setBrokerForeground(true) + startForegroundHeartbeat() } override fun onPause() { @@ -316,12 +323,30 @@ class NappletHostActivity : ComponentActivity() { webView.onPause() webView.pauseTimers() } - // No longer foreground: let the main process resume its normal background resource scaling. + // No longer foreground: stop renewing and let the main process resume normal background scaling. resumed = false + stopForegroundHeartbeat() setBrokerForeground(false) super.onPause() } + /** Reports foreground=true immediately and then re-reports on a heartbeat to renew the broker lease. */ + private fun startForegroundHeartbeat() { + foregroundHeartbeat?.cancel() + foregroundHeartbeat = + uiScope.launch { + while (true) { + setBrokerForeground(true) + delay(FOREGROUND_HEARTBEAT_MS) + } + } + } + + private fun stopForegroundHeartbeat() { + foregroundHeartbeat?.cancel() + foregroundHeartbeat = null + } + /** Reports this surface's foreground state to the broker so it can hold the main process resumed. */ private fun setBrokerForeground(foreground: Boolean) { val msg = @@ -333,7 +358,8 @@ class NappletHostActivity : ComponentActivity() { } } // Before the broker binds, the surface isn't really up yet; the matching onPause(false) is a - // no-op on the broker's empty set, so dropping a pre-bind report is harmless. + // no-op on the broker's empty map, so dropping a pre-bind report is harmless — the heartbeat + // re-reports once connected (and onServiceConnected seeds it too). if (brokerMessenger != null) sendToBroker(msg) } @@ -812,5 +838,12 @@ class NappletHostActivity : ComponentActivity() { companion object { private const val TAG = "NappletHostActivity" + + /** + * How often a resumed host renews its foreground lease with the broker. Comfortably shorter than + * the broker's lease TTL so a couple of dropped/delayed beats don't expire a still-foreground + * surface, while a dead process (heartbeat stopped) is reaped within the TTL. + */ + const val FOREGROUND_HEARTBEAT_MS = 30_000L } }