android: health check skips probes while inbound tunnel traffic proves liveness (adaptive mode, real probe at least every 5 min) and probes a single target with the screen off; unit tests; energy plan notes keepalive stays at 45 s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
38e5fd84bb
commit
f5b15a0394
3 files changed
+110
-9
No files matched your search
+71
-1
@@ -30,6 +30,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
|
||||
private var tunnelAttempts = 0
|
||||
private var fastPollShifts = 0
|
||||
private var powerState = PowerState()
|
||||
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||
private var lastCycleInbound = -1L
|
||||
private var lastProbeAt = 0L
|
||||
|
||||
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||
private val powerReceiver = object : BroadcastReceiver()
|
||||
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
val targets = config.targets
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
/* passive liveness: payload that came back through the tunnel since the
|
||||
* previous cycle proves it works without waking the radio for probes */
|
||||
val inbound = tunnelInboundBytes()
|
||||
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||
lastCycleInbound = inbound
|
||||
if (trafficProvesAlive(
|
||||
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||
inboundDelta, nowMs - lastProbeAt,
|
||||
))
|
||||
{
|
||||
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||
return
|
||||
}
|
||||
lastProbeAt = nowMs
|
||||
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||
{
|
||||
setOf(config.targets.first())
|
||||
}
|
||||
else
|
||||
{
|
||||
config.targets
|
||||
}
|
||||
val mode = config.serverCheckMode
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
|
||||
tunnelAttempts = 0
|
||||
fastPollShifts = 0
|
||||
pendingDecision = null
|
||||
lastCycleInbound = -1L
|
||||
lastProbeAt = 0L
|
||||
}
|
||||
|
||||
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||
private fun tunnelInboundBytes(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerVpnCallback()
|
||||
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
|
||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||
|
||||
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||
|
||||
/** Even with traffic flowing a real probe runs at least this often. */
|
||||
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Pure function: whether the cycle may skip its probes because the
|
||||
* tunnel carried inbound data since the previous cycle. Only in
|
||||
* adaptive mode, never in aggressive mode, never while a failure is
|
||||
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||
* Outbound-only traffic proves nothing, so it does not count.
|
||||
*/
|
||||
fun trafficProvesAlive(
|
||||
adapted: HealthConfig,
|
||||
failedAttempts: Int,
|
||||
fastPollShifts: Int,
|
||||
decisionPending: Boolean,
|
||||
inboundDelta: Long,
|
||||
sinceLastProbeMs: Long,
|
||||
): Boolean =
|
||||
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||
|
||||
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||
|
||||
|
||||
+26
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundTrafficSkipsProbesInAdaptiveMode()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWithoutInboundOrAfterCap()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse()
|
||||
assertThat(
|
||||
HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS)
|
||||
).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -50,19 +50,24 @@
|
||||
|
||||
## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта)
|
||||
|
||||
### A. Проверка здоровья: пассивные сигналы вместо проб (высокий эффект, средняя сложность)
|
||||
### A. Проверка здоровья: пассивные сигналы вместо проб (сделано в этой ветке)
|
||||
`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы
|
||||
через туннель — каждая держит радио. Идеи:
|
||||
- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив);
|
||||
- при выключенном экране: один таргет вместо трёх, интервал 5 мин;
|
||||
- не стартовать цикл вообще, пока устройство в Doze (ждать выхода/`ACTION_SCREEN_ON`).
|
||||
- не стартовать цикл вообще, пока устройство в Doze — не делалось: корутинные таймеры и так не будят спящий CPU.
|
||||
|
||||
### B. Keepalive и будильники strongSwan (высокий эффект, проверить)
|
||||
`Scheduler.java` ставит `setExactAndAllowWhileIdle(RTC_WAKEUP)` на NAT-keepalive и ребилд IKE. Профильный `nat_keepalive`
|
||||
ограничен 10–120 с (`Constants.NAT_KEEPALIVE_MIN/MAX`); при пустом значении берётся умолчание charon.
|
||||
- проверить, что реально приходит из `.sswan` и какое значение у charon по умолчанию;
|
||||
- рассмотреть умолчание 30–60 с для мобильной сети (после проверки, что NAT оператора не рвёт соединение) и большие значения на Wi-Fi;
|
||||
- замерить число будильников в `dumpsys alarm` за ночь.
|
||||
Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 4 КБ входящих данных
|
||||
(`CharonVpnService.queryTraffic()`), но реальная проба идёт не реже раза в 5 минут; при наблюдаемом сбое, ожидающем решении,
|
||||
агрессивном режиме или неизвестном счётчике пропуска нет. При выключенном экране проверяется один адрес вместо трёх.
|
||||
Сама проверка здоровья включается пользователем (`HealthConfig.enabled = false` по умолчанию), поэтому выигрыш — для тех, кто её включил.
|
||||
|
||||
### B. Keepalive и будильники strongSwan — проверено, код не менялся
|
||||
Умолчание уже экономное: `charonservice.c` ставит `charon.keep_alive = 45` с (`ANDROID_KEEPALIVE_INTERVAL`, у upstream-strongSwan 20 с)
|
||||
и DPD-запас 20 с; профильный `nat_keepalive` (10–120 с) его переопределяет. Keepalive уходит только когда исходящего трафика не было.
|
||||
Поднимать 45 с выше без замеров нельзя: UDP-таймауты NAT операторов бывают 30–60 с, туннель «жив», но молчит — это прямое нарушение условия раздела 5.
|
||||
Что остаётся: замерить число будильников (`dumpsys alarm`) за ночь и, если вклад велик, добавить в профиль **необязательную** настройку,
|
||||
не меняя умолчание; рекомендовать значения по результатам теста на конкретной сети пользователя.
|
||||
|
||||
### C. Единый хаб сетевых колбэков (средний эффект, средняя сложность)
|
||||
`registerNetworkCallback` вызывают минимум семь компонентов: `CharonVpnService`, `NetworkManager`, `NetworkMonitor`,
|
||||
|
||||
Reference in new issue
Block a user