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.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.logic.events.Events
|
import org.strongswan.android.logic.events.Events
|
||||||
|
|
||||||
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
|
|||||||
private var tunnelAttempts = 0
|
private var tunnelAttempts = 0
|
||||||
private var fastPollShifts = 0
|
private var fastPollShifts = 0
|
||||||
private var powerState = PowerState()
|
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 */
|
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||||
private val powerReceiver = object : BroadcastReceiver()
|
private val powerReceiver = object : BroadcastReceiver()
|
||||||
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val gateway = svc.profile?.gateway
|
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 mode = config.serverCheckMode
|
||||||
val timeout = config.timeoutMs
|
val timeout = config.timeoutMs
|
||||||
|
|
||||||
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
|
|||||||
tunnelAttempts = 0
|
tunnelAttempts = 0
|
||||||
fastPollShifts = 0
|
fastPollShifts = 0
|
||||||
pendingDecision = null
|
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()
|
private fun registerVpnCallback()
|
||||||
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
|
|||||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
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. */
|
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
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, 3)).isTrue()
|
||||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
|
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. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта)
|
## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта)
|
||||||
|
|
||||||
### A. Проверка здоровья: пассивные сигналы вместо проб (высокий эффект, средняя сложность)
|
### A. Проверка здоровья: пассивные сигналы вместо проб (сделано в этой ветке)
|
||||||
`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы
|
`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы
|
||||||
через туннель — каждая держит радио. Идеи:
|
через туннель — каждая держит радио. Идеи:
|
||||||
- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив);
|
- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив);
|
||||||
- при выключенном экране: один таргет вместо трёх, интервал 5 мин;
|
- при выключенном экране: один таргет вместо трёх, интервал 5 мин;
|
||||||
- не стартовать цикл вообще, пока устройство в Doze (ждать выхода/`ACTION_SCREEN_ON`).
|
- не стартовать цикл вообще, пока устройство в Doze — не делалось: корутинные таймеры и так не будят спящий CPU.
|
||||||
|
|
||||||
### B. Keepalive и будильники strongSwan (высокий эффект, проверить)
|
Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 4 КБ входящих данных
|
||||||
`Scheduler.java` ставит `setExactAndAllowWhileIdle(RTC_WAKEUP)` на NAT-keepalive и ребилд IKE. Профильный `nat_keepalive`
|
(`CharonVpnService.queryTraffic()`), но реальная проба идёт не реже раза в 5 минут; при наблюдаемом сбое, ожидающем решении,
|
||||||
ограничен 10–120 с (`Constants.NAT_KEEPALIVE_MIN/MAX`); при пустом значении берётся умолчание charon.
|
агрессивном режиме или неизвестном счётчике пропуска нет. При выключенном экране проверяется один адрес вместо трёх.
|
||||||
- проверить, что реально приходит из `.sswan` и какое значение у charon по умолчанию;
|
Сама проверка здоровья включается пользователем (`HealthConfig.enabled = false` по умолчанию), поэтому выигрыш — для тех, кто её включил.
|
||||||
- рассмотреть умолчание 30–60 с для мобильной сети (после проверки, что NAT оператора не рвёт соединение) и большие значения на Wi-Fi;
|
|
||||||
- замерить число будильников в `dumpsys alarm` за ночь.
|
### 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. Единый хаб сетевых колбэков (средний эффект, средняя сложность)
|
### C. Единый хаб сетевых колбэков (средний эффект, средняя сложность)
|
||||||
`registerNetworkCallback` вызывают минимум семь компонентов: `CharonVpnService`, `NetworkManager`, `NetworkMonitor`,
|
`registerNetworkCallback` вызывают минимум семь компонентов: `CharonVpnService`, `NetworkManager`, `NetworkMonitor`,
|
||||||
|
|||||||
Reference in new issue
Block a user