feat: lease-expire sandbox foreground holds so a dead host can't pin the network

The cross-process foreground hold relied on the `:napplet` host delivering its
onPause "false" to release. If that process dies while foreground (a crash, an
OS kill) it never sends it, and the broker would hold the main process resumed —
Tor/relays/AUTH up — forever.

Turn the hold into a renewing lease. A resumed host re-reports foreground on a
30s heartbeat; the broker stamps each launch token's last-seen time and a
watchdog reaps any lease older than a 90s TTL, releasing its hold. A live app
keeps renewing so it's never wrongly dropped; a dead one stops renewing and is
reaped within the TTL, bounding any leak to one window instead of forever.

Only the cross-process napplet/nSite path needs this — BrowserHostActivity runs
in the main process, so if it dies the hold and every connection die with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-23 20:28:36 +00:00
parent 62f0adaa76
commit d99ae48847
2 changed files with 105 additions and 18 deletions
@@ -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<String>()
// 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<String, Long>()
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:<origin>`. 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
}
}
@@ -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
}
}