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