From 74835b42ea28ec834f3187d4b3d42571c77edb96 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:55:31 +0200 Subject: [PATCH] Slow the WG connectivity watchdog when the screen is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The liveness monitor polled tunnel counters every second for as long as the tunnel ran, including overnight with the screen off — a CPU wake per second for hours, for a watchdog whose purpose is catching stalls while traffic flows. Poll at 15s instead while the screen is off (1s when interactive), and scale the doze-detection gap threshold with the active interval so the slow cadence is not mistaken for a suspension. --- .../wg/WgConnectivityMonitor.kt | 50 +++++++++++++++---- .../net/kollnig/missioncontrol/wg/WgEgress.kt | 7 ++- .../wg/WgConnectivityMonitorTest.kt | 49 ++++++++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitorTest.kt diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitor.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitor.kt index b70d8ce4a..eae839a9c 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitor.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitor.kt @@ -197,9 +197,17 @@ internal class WgConnectivityChecker(private val prod: () -> Unit) { private const val TAG = "WgConnMonitor" /** - * Continuous WireGuard liveness watchdog. Owns the 1s polling thread and the + * Continuous WireGuard liveness watchdog. Owns the polling thread and the * Android clock, delegating the actual decision to [WgConnectivityChecker]. * + * The poll cadence adapts to screen state: 1s while the user is interactive, + * 15s while it is off. The monitor is purely passive — it reads transfer + * counters and never wakes the radio or holds a wakelock — but each poll still + * costs a CPU wake, and its whole purpose is catching stalls *while traffic + * flows*, which overwhelmingly happens with the screen on. Background sync at + * night still gets stall detection, just at ~1/15th the wakeups; deep doze + * suspends the thread entirely either way. + * * Doze handling mirrors Mullvad's `SUSPEND_TIMEOUT` reset: if the loop notices * it was suspended longer than expected (the device dozed), the checker * rebases its timestamps instead of mistaking the gap for a stalled tunnel. @@ -219,14 +227,35 @@ internal class WgConnectivityMonitor( // only end-to-end proof the data path works (handshake success is not: // some paths pass handshakes but drop transport packets), so it is what // recovery backoff resets must key off. - private val onRxAdvanced: () -> Unit = {} + private val onRxAdvanced: () -> Unit = {}, + // Screen state, sampled every iteration so the cadence follows the screen + // without restarting the thread. Backed by WgEgress.currentInteractive. + private val isInteractive: () -> Boolean = { true } ) { - private companion object { - /** How often the loop samples the tunnel counters. */ - const val LOOP_SLEEP_MS = 1_000L - - /** A wall-clock gap larger than this means the device dozed. */ - const val SUSPEND_TIMEOUT_MS = 6_000L + internal companion object { + /** How often the loop samples the tunnel counters while the screen is on. */ + const val INTERACTIVE_LOOP_SLEEP_MS = 1_000L + + /** + * Sampling interval while the screen is off. Slow enough that the + * wakeup cost is negligible, fast enough that a background-sync stall + * is caught within one cycle and recovery still runs before the next + * maintenance window. + */ + const val IDLE_LOOP_SLEEP_MS = 15_000L + + /** + * A wall-clock gap past one full cycle plus this margin means the + * device dozed. Scales with the current interval, preserving the old + * fixed 6s threshold at the 1s interactive cadence. + */ + const val SUSPEND_MARGIN_MS = 5_000L + + fun pollIntervalMs(interactive: Boolean): Long = + if (interactive) INTERACTIVE_LOOP_SLEEP_MS else IDLE_LOOP_SLEEP_MS + + fun isSuspendGap(sleptMs: Long, intervalMs: Long): Boolean = + sleptMs >= intervalMs + SUSPEND_MARGIN_MS } private val checker = WgConnectivityChecker(prod) @@ -256,8 +285,9 @@ internal class WgConnectivityMonitor( var announcedConnected = false while (running && !Thread.currentThread().isInterrupted) { + val intervalMs = pollIntervalMs(isInteractive()) try { - Thread.sleep(LOOP_SLEEP_MS) + Thread.sleep(intervalMs) } catch (e: InterruptedException) { break } @@ -267,7 +297,7 @@ internal class WgConnectivityMonitor( lastCheck = now // The device dozed; the elapsed gap is not evidence of a stall. - if (slept >= SUSPEND_TIMEOUT_MS) { + if (isSuspendGap(slept, intervalMs)) { checker.onSuspended(now) continue } diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt index ff1928173..01ac986da 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt @@ -77,7 +77,9 @@ object WgEgress { // pfd object distinguishes "same TUN reused across a Native restart" // (same object) from "new TUN that happens to alias the fd number". private var currentTunPfd: ParcelFileDescriptor? = null - private var currentInteractive: Boolean = true + // Volatile: read on every poll by the wg-conn-monitor thread, written from + // the vpn handler thread (startOrUpdate / reapplyConfig). + @Volatile private var currentInteractive: Boolean = true private var currentKeepaliveAlwaysOn: Boolean = false @Volatile private var forceRestartPending: Boolean = false @Volatile private var lastCheapRecoveryMs: Long = 0 @@ -325,7 +327,8 @@ object WgEgress { // a fresh handshake shortly after a restart is exactly the // signal a handshake-passes-but-data-drops failure loop also // produces, so resetting on it would defeat the backoff. - onRxAdvanced = { if (isCurrent(expected)) restartAttempts = 0 } + onRxAdvanced = { if (isCurrent(expected)) restartAttempts = 0 }, + isInteractive = { currentInteractive } ).also { it.start() } } } diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitorTest.kt b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitorTest.kt new file mode 100644 index 000000000..73e2e9e34 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConnectivityMonitorTest.kt @@ -0,0 +1,49 @@ +package net.kollnig.missioncontrol.wg + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure unit tests for the [WgConnectivityMonitor] loop cadence: fast polling + * while the screen is on, slow (battery-preserving) polling while it is off, + * and a doze-detection threshold that scales with the active interval. + */ +class WgConnectivityMonitorTest { + + @Test + fun interactiveCadenceIsOneSecond() { + assertEquals(1_000L, WgConnectivityMonitor.INTERACTIVE_LOOP_SLEEP_MS) + assertEquals(1_000L, WgConnectivityMonitor.pollIntervalMs(true)) + } + + @Test + fun screenOffCadenceIsSlow() { + val idle = WgConnectivityMonitor.pollIntervalMs(false) + assertTrue(idle >= 10_000L) + assertTrue(idle <= 15_000L) + } + + /** + * At the 1s interactive cadence the suspend threshold must stay at the + * historical fixed value of 6s, so doze detection does not regress. + */ + @Test + fun suspendThresholdAtInteractiveCadenceIsSixSeconds() { + assertFalse(WgConnectivityMonitor.isSuspendGap(6_000L - 1, 1_000L)) + assertTrue(WgConnectivityMonitor.isSuspendGap(6_000L, 1_000L)) + } + + /** + * At the slow screen-off cadence a normal cycle must not be mistaken for + * a doze gap (that would rebase timestamps every tick), while genuine + * multi-second timer deferrals still are. + */ + @Test + fun suspendThresholdScalesWithIdleCadence() { + val idle = WgConnectivityMonitor.pollIntervalMs(false) + assertFalse(WgConnectivityMonitor.isSuspendGap(idle, idle)) + assertTrue(WgConnectivityMonitor.isSuspendGap(idle + WgConnectivityMonitor.SUSPEND_MARGIN_MS, idle)) + } +}