ws: harden HermesWsClient (stale-socket watchdog, JSON ticket parse, dead-branch removal) (#954)

* ws: watchdog cancels stale sockets to trigger reconnect

* ws: parse ws-ticket response with kotlinx.serialization

* ws: remove unreachable buffer-overflow warning

* style: ktlint format ws client tests

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-08-22 18:49:50 +03:00
1 parent a11761f120
commit bb400881e6
3 files changed
+153 -10

No files matched your search

@@ -79,6 +79,15 @@ object HermesWsClient {
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
/**
* No inbound frame for this long means the link is dead even though the
* TCP session may still look open (silent NAT/VPN drop). OkHttp pings
* every 30s (OkHttpProvider.websocket) and any inbound frame refreshes
* [lastPongTimestamp], so a healthy link never exceeds ~30s of silence.
* 90s tolerates exactly one missed ping cycle before acting.
*/
private const val STALE_THRESHOLD_MS = 90_000L
// ── Internal state (all access through synchronized / atomic) ────────
private val requestId = AtomicInteger(0)
@@ -135,13 +144,41 @@ object HermesWsClient {
wsScope.launch {
while (connected.get()) {
delay(30_000L)
if (connected.get() && System.currentTimeMillis() - lastPongTimestamp > 60_000L) {
Log.w(TAG, "WebSocket connection appears unhealthy (no frames received for > 60s)")
}
if (!runHealthCheckPass()) break
}
}
}
/**
* One liveness pass. Returns false when the tracking loop should stop:
* either the connection is already gone or the watchdog fired a cancel.
*
* Cancelling (not close()) forces an immediate onFailure, which rides the
* existing teardown path: generation bump, RECONNECTING, scheduleReconnect.
*/
private fun runHealthCheckPass(): Boolean {
if (!connected.get()) return false
val staleMs = System.currentTimeMillis() - lastPongTimestamp
if (staleMs <= STALE_THRESHOLD_MS) return true
Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
synchronized(outboundLock) {
if (!connected.get()) return false
webSocket?.cancel()
}
return false
}
/**
* Test hook: backdate liveness past the staleness threshold and force one
* synchronous watchdog pass, so reconnect tests are deterministic instead
* of racing the real 30s/90s cadence.
*/
@VisibleForTesting
internal fun forceHealthCheckForTest(staleMillis: Long) {
lastPongTimestamp = System.currentTimeMillis() - staleMillis
runHealthCheckPass()
}
private fun stopHealthTracking() {
healthJob?.cancel()
healthJob = null
@@ -462,12 +499,18 @@ object HermesWsClient {
}
if (code in 200..299) {
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
val ticket = ticketMatch?.groupValues?.getOrNull(1)
// Real JSON decode — the ticket is base64url and may contain
// characters a naive [^"]+ regex cannot extract (see
// testGatedMode_parsesEscapedTicketFromRealJsonShape).
val parsed =
runCatching {
OkHttpProvider.json.decodeFromString<WsTicketResponse>(body)
}.getOrNull()
val ticket = parsed?.ticket
if (!ticket.isNullOrBlank()) {
return TicketRequestResult(ticket, null)
}
Log.w(TAG, "WS ticket mint failed: response body did not contain ticket")
Log.w(TAG, "WS ticket mint failed: unparseable ticket response")
} else {
Log.w(TAG, "WS ticket mint failed: HTTP $code")
return TicketRequestResult(null, code)
@@ -926,10 +969,10 @@ object HermesWsClient {
else -> Unit
}
val emitted = parsedEvents.tryEmit(event)
if (!emitted && BuildConfig.DEBUG) {
Log.w(TAG, "WebSocket message dropped due to buffer overflow")
}
// tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512
// and always-on init collectors this is unreachable in practice.
parsedEvents.tryEmit(event)
}
override fun onClosing(
@@ -75,3 +75,14 @@ fun JsonElement.toAny(): Any? =
map { it.toAny() }
}
}
/**
* Response body of POST /api/auth/ws-ticket (gated mode). Verified against
* hermes-agent dashboard_auth/routes.py — the ticket is base64url and can
* therefore contain characters that only a real JSON decoder survives.
*/
@Serializable
data class WsTicketResponse(
val ticket: String,
val ttl_seconds: Int? = null,
)