feat(android): pure ReachabilityMachine + 4-state ServerHealth enum
android / Build + lint + test (push) Failing after 1m16s
android / Build + lint + test (push) Failing after 1m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
internal const val ESCALATE_AFTER_MS = 120_000L
|
||||||
|
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||||
|
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||||
|
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||||
|
* testable. [NetworkStatusController] wires real time + signals around it.
|
||||||
|
*
|
||||||
|
* Reachability (independent of the device link):
|
||||||
|
* - Reachable last evidence says the server answered.
|
||||||
|
* - Unstable a probe failed; arbitration/escalation pending.
|
||||||
|
* - Unreachable corroborated or sustained failure.
|
||||||
|
*
|
||||||
|
* [health] folds the device link over that: no link → Offline; otherwise the
|
||||||
|
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
|
||||||
|
*
|
||||||
|
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
|
||||||
|
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
|
||||||
|
* back to Reachable. A failure only escalates when a /healthz probe corroborates
|
||||||
|
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
|
||||||
|
* sustained-time backstop.
|
||||||
|
*/
|
||||||
|
class ReachabilityMachine {
|
||||||
|
|
||||||
|
private enum class Reachability { Reachable, Unstable, Unreachable }
|
||||||
|
|
||||||
|
private var linkUp = true
|
||||||
|
private var reachability = Reachability.Reachable
|
||||||
|
private var failureStreakStartMs: Long? = null
|
||||||
|
private val recentOpFailures = ArrayDeque<Long>()
|
||||||
|
|
||||||
|
fun onLinkChange(up: Boolean, nowMs: Long) {
|
||||||
|
linkUp = up
|
||||||
|
// Link transitions don't reset reachability — a restored link keeps the
|
||||||
|
// last-known server reachability until a fresh probe/op result arrives.
|
||||||
|
if (!up) recentOpFailures.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
|
||||||
|
fun onSuccess(nowMs: Long) {
|
||||||
|
reachability = Reachability.Reachable
|
||||||
|
failureStreakStartMs = null
|
||||||
|
recentOpFailures.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||||
|
fun onOpFailure(nowMs: Long) {
|
||||||
|
pruneOpFailures(nowMs)
|
||||||
|
recentOpFailures.addLast(nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
|
||||||
|
fun onProbeFailure(nowMs: Long) {
|
||||||
|
pruneOpFailures(nowMs)
|
||||||
|
if (reachability == Reachability.Reachable) {
|
||||||
|
reachability = Reachability.Unstable
|
||||||
|
failureStreakStartMs = nowMs
|
||||||
|
}
|
||||||
|
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
|
||||||
|
reachability = Reachability.Unreachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun health(): ServerHealth = when {
|
||||||
|
!linkUp -> ServerHealth.Offline
|
||||||
|
reachability == Reachability.Reachable -> ServerHealth.Healthy
|
||||||
|
reachability == Reachability.Unstable -> ServerHealth.Unstable
|
||||||
|
else -> ServerHealth.ServerDown
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldEscalate(nowMs: Long): Boolean {
|
||||||
|
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
|
||||||
|
val sustained =
|
||||||
|
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
|
||||||
|
return corroborated || sustained
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pruneOpFailures(nowMs: Long) {
|
||||||
|
while (recentOpFailures.isNotEmpty() &&
|
||||||
|
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
|
||||||
|
) {
|
||||||
|
recentOpFailures.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single reachability signal every consumer branches on.
|
||||||
|
*
|
||||||
|
* - [Healthy] link up, /healthz ok — normal network behavior.
|
||||||
|
* - [Unstable] link up, a recent failure with arbitration pending —
|
||||||
|
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
|
||||||
|
* anti-flicker intent of commit 5c0db429 while still warning
|
||||||
|
* the user that something is flaky.
|
||||||
|
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
|
||||||
|
* gate to cache-only.
|
||||||
|
* - [Offline] no device link at all — gate to cache-only.
|
||||||
|
*/
|
||||||
|
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ReachabilityMachineTest {
|
||||||
|
|
||||||
|
private fun machine() = ReachabilityMachine()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `starts healthy`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no link is offline regardless of probes`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = false, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1)
|
||||||
|
assertEquals(ServerHealth.Offline, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single probe failure is unstable not down`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1_000)
|
||||||
|
assertEquals(ServerHealth.Unstable, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `probe success from unstable recovers to healthy`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1_000)
|
||||||
|
m.onSuccess(nowMs = 2_000)
|
||||||
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `op success from unstable recovers to healthy`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1_000)
|
||||||
|
m.onSuccess(nowMs = 1_500) // a successful stream read / API 2xx is self-proving
|
||||||
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two op failures plus a failed probe escalate immediately`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onOpFailure(nowMs = 1_000)
|
||||||
|
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
||||||
|
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
||||||
|
assertEquals(ServerHealth.ServerDown, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `op failures with a successful probe stay healthy (track-specific)`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onOpFailure(nowMs = 1_000)
|
||||||
|
m.onOpFailure(nowMs = 1_500)
|
||||||
|
m.onSuccess(nowMs = 2_000) // arbiter says server is fine
|
||||||
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stale op failures do not corroborate`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onOpFailure(nowMs = 0)
|
||||||
|
m.onOpFailure(nowMs = 1_000)
|
||||||
|
// both op failures are now older than the corroboration window:
|
||||||
|
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
|
||||||
|
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sustained failure backstop escalates after the window`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
|
||||||
|
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
|
||||||
|
assertEquals(ServerHealth.ServerDown, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `link restored keeps last-known down until a fresh result`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1_000)
|
||||||
|
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
|
||||||
|
m.onLinkChange(up = false, nowMs = 200_000)
|
||||||
|
assertEquals(ServerHealth.Offline, m.health())
|
||||||
|
m.onLinkChange(up = true, nowMs = 201_000)
|
||||||
|
// link back but no fresh probe result yet — last known reachability was down:
|
||||||
|
assertEquals(ServerHealth.ServerDown, m.health())
|
||||||
|
m.onSuccess(nowMs = 202_000)
|
||||||
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unstable does not gate — it is not offline or serverdown`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true, nowMs = 0)
|
||||||
|
m.onProbeFailure(nowMs = 1_000)
|
||||||
|
val health = m.health()
|
||||||
|
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
|
||||||
|
assertEquals(ServerHealth.Unstable, health)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user