Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}