fix: keep transient ticket failures reconnecting (#740)
* fix: keep transient ticket failures reconnecting * fix: recover rejected websocket sends * fix: serialize websocket reconnect scheduling
This commit is contained in:
1 parent
13895585b0
commit
e39544ebd5
4 files changed
+748
-113
No files matched your search
@@ -14,6 +14,7 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -33,6 +34,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.utf8Size
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@@ -65,17 +67,26 @@ object HermesWsClient {
|
|||||||
private const val INITIAL_BACKOFF_MS = 1_000L
|
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||||
private const val MAX_BACKOFF_MS = 30_000L
|
private const val MAX_BACKOFF_MS = 30_000L
|
||||||
private const val BACKOFF_MULTIPLIER = 2.0
|
private const val BACKOFF_MULTIPLIER = 2.0
|
||||||
|
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||||
|
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||||
|
|
||||||
// ── Internal state (all access through synchronized / atomic) ────────
|
// ── Internal state (all access through synchronized / atomic) ────────
|
||||||
|
|
||||||
private val requestId = AtomicInteger(0)
|
private val requestId = AtomicInteger(0)
|
||||||
|
private val connectionGeneration = AtomicInteger(0)
|
||||||
private val connected = AtomicBoolean(false)
|
private val connected = AtomicBoolean(false)
|
||||||
private val intentionalClose = AtomicBoolean(false)
|
private val intentionalClose = AtomicBoolean(false)
|
||||||
|
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||||
|
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||||
|
private val outboundLock = Any()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closingSocket: WebSocket? = null
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var currentBackoff = INITIAL_BACKOFF_MS
|
private var currentBackoff = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
@@ -84,6 +95,9 @@ object HermesWsClient {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var reconnectJob: Job? = null
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var outboundDrainJob: Job? = null
|
||||||
|
|
||||||
// ── Health and Ping/Pong tracking ────────────────────────────────────
|
// ── Health and Ping/Pong tracking ────────────────────────────────────
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -161,10 +175,19 @@ object HermesWsClient {
|
|||||||
// Monitor network state to trigger immediate reconnect when network is restored
|
// Monitor network state to trigger immediate reconnect when network is restored
|
||||||
wsScope.launch {
|
wsScope.launch {
|
||||||
NetworkMonitor.isConnected.collect { connected ->
|
NetworkMonitor.isConnected.collect { connected ->
|
||||||
if (connected && !isConnected && !intentionalClose.get() && AuthManager.isAutoReconnect()) {
|
val shouldOpen =
|
||||||
Log.d(TAG, "Network restored — triggering immediate reconnect")
|
synchronized(outboundLock) {
|
||||||
currentBackoff = INITIAL_BACKOFF_MS
|
if (connected && !isConnected && !intentionalClose.get() && AuthManager.isAutoReconnect()) {
|
||||||
reconnectJob?.cancel()
|
Log.d(TAG, "Network restored — triggering immediate reconnect")
|
||||||
|
currentBackoff = INITIAL_BACKOFF_MS
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldOpen) {
|
||||||
openSocket()
|
openSocket()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +216,7 @@ object HermesWsClient {
|
|||||||
|
|
||||||
/** Open a WebSocket connection using settings from [AuthManager]. */
|
/** Open a WebSocket connection using settings from [AuthManager]. */
|
||||||
fun connect() {
|
fun connect() {
|
||||||
|
acceptQueuedMessages.set(true)
|
||||||
if (connected.get()) {
|
if (connected.get()) {
|
||||||
Log.d(TAG, "Already connected — skipping")
|
Log.d(TAG, "Already connected — skipping")
|
||||||
return
|
return
|
||||||
@@ -217,7 +241,9 @@ object HermesWsClient {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
intentionalClose.set(false)
|
intentionalClose.set(false)
|
||||||
currentBackoff = INITIAL_BACKOFF_MS
|
synchronized(outboundLock) {
|
||||||
|
currentBackoff = INITIAL_BACKOFF_MS
|
||||||
|
}
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||||
openSocket()
|
openSocket()
|
||||||
}
|
}
|
||||||
@@ -268,41 +294,64 @@ object HermesWsClient {
|
|||||||
client.newCall(request).execute()
|
client.newCall(request).execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
response.use {
|
||||||
val body = response.body.string()
|
if (response.isSuccessful) {
|
||||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
val body = response.body.string()
|
||||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||||
if (!ticket.isNullOrBlank()) {
|
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||||
AuthManager.setToken(ticket)
|
if (!ticket.isNullOrBlank()) {
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
AuthManager.setToken(ticket)
|
||||||
return true
|
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
|
||||||
|
return handleWsTicketRefreshFailure(ConnectionStatus.DISCONNECTED)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
|
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
val status =
|
||||||
return false
|
when {
|
||||||
|
response.code == 401 || response.code == 403 -> ConnectionStatus.AUTH_EXPIRED
|
||||||
|
response.code == 408 || response.code == 429 || response.code >= 500 ->
|
||||||
|
ConnectionStatus.RECONNECTING
|
||||||
|
else -> ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
return handleWsTicketRefreshFailure(status)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "WS ticket refresh failed: ${e.javaClass.simpleName}")
|
Log.w(TAG, "WS ticket refresh failed: ${e.javaClass.simpleName}")
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
return handleWsTicketRefreshFailure(ConnectionStatus.RECONNECTING)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleWsTicketRefreshFailure(status: ConnectionStatus): Boolean {
|
||||||
|
_connectionStatus.value = status
|
||||||
|
if (status == ConnectionStatus.RECONNECTING) scheduleReconnect()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||||
fun disconnect() {
|
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||||
intentionalClose.set(true)
|
synchronized(outboundLock) {
|
||||||
reconnectJob?.cancel()
|
intentionalClose.set(true)
|
||||||
reconnectJob = null
|
acceptQueuedMessages.set(!clearPendingMessages)
|
||||||
stopHealthTracking()
|
connectionGeneration.incrementAndGet()
|
||||||
webSocket?.close(1000, "Client closed")
|
reconnectJob?.cancel()
|
||||||
webSocket = null
|
reconnectJob = null
|
||||||
connected.set(false)
|
outboundDrainJob?.cancel()
|
||||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
outboundDrainJob = null
|
||||||
|
stopHealthTracking()
|
||||||
|
webSocket?.close(1000, "Client closed")
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
if (clearPendingMessages) {
|
||||||
|
messageQueue.clear()
|
||||||
|
queuedMessagesById.clear()
|
||||||
|
}
|
||||||
|
connected.set(false)
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Awaited RPC request layer (issue #526) ─────────────────────────
|
// ── Awaited RPC request layer (issue #526) ─────────────────────────
|
||||||
@@ -368,6 +417,7 @@ object HermesWsClient {
|
|||||||
error: JsonRpcError?,
|
error: JsonRpcError?,
|
||||||
) {
|
) {
|
||||||
val call = pendingCalls.remove(id) ?: return
|
val call = pendingCalls.remove(id) ?: return
|
||||||
|
removeQueuedMessage(id)
|
||||||
call.timeoutJob?.cancel()
|
call.timeoutJob?.cancel()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
call.deferred.completeExceptionally(HermesRpcException(error.message))
|
call.deferred.completeExceptionally(HermesRpcException(error.message))
|
||||||
@@ -390,6 +440,7 @@ object HermesWsClient {
|
|||||||
val snapshot = pendingCalls.toList()
|
val snapshot = pendingCalls.toList()
|
||||||
pendingCalls.clear()
|
pendingCalls.clear()
|
||||||
for ((id, call) in snapshot) {
|
for ((id, call) in snapshot) {
|
||||||
|
removeQueuedMessage(id)
|
||||||
Log.w(TAG, "Rejecting pending request on disconnect: ${call.method} (id=$id)")
|
Log.w(TAG, "Rejecting pending request on disconnect: ${call.method} (id=$id)")
|
||||||
call.timeoutJob?.cancel()
|
call.timeoutJob?.cancel()
|
||||||
call.deferred.completeExceptionally(error)
|
call.deferred.completeExceptionally(error)
|
||||||
@@ -412,16 +463,79 @@ object HermesWsClient {
|
|||||||
val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() })
|
val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() })
|
||||||
val json = OkHttpProvider.json.encodeToString(request)
|
val json = OkHttpProvider.json.encodeToString(request)
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
||||||
val ws = webSocket
|
synchronized(outboundLock) {
|
||||||
if (ws != null && connected.get()) {
|
val ws = webSocket
|
||||||
ws.send(json)
|
if (ws != null && connected.get()) {
|
||||||
} else {
|
if (!ws.send(json)) {
|
||||||
Log.d(TAG, "WS disconnected — queuing message")
|
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
|
||||||
messageQueue.add(json)
|
if (isRetryableMessage(json)) {
|
||||||
|
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
|
||||||
|
queueMessage(id, json)
|
||||||
|
recoverRejectedSocket(ws)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (acceptQueuedMessages.get()) {
|
||||||
|
if (isRetryableMessage(json)) {
|
||||||
|
Log.d(TAG, "WS disconnected — queuing message")
|
||||||
|
queueMessage(id, json)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isRetryableMessage(json: String): Boolean {
|
||||||
|
if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false
|
||||||
|
if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true
|
||||||
|
return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queueMessage(
|
||||||
|
id: String,
|
||||||
|
json: String,
|
||||||
|
) {
|
||||||
|
messageQueue.add(json)
|
||||||
|
queuedMessagesById[id] = json
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeQueuedMessage(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
queuedMessagesById.remove(id)?.let(messageQueue::remove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markQueuedMessageSent(json: String) {
|
||||||
|
queuedMessagesById.entries.removeIf { it.value == json }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverRejectedSocket(ws: WebSocket) {
|
||||||
|
connected.set(false)
|
||||||
|
if (closingSocket === ws) return
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
if (ws.queueSize() == 0L) {
|
||||||
|
ws.cancel()
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (intentionalClose.get()) return
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob =
|
||||||
|
wsScope.launch {
|
||||||
|
delay(OUTBOUND_DRAIN_TIMEOUT_MS)
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!connected.get() && !intentionalClose.get() && webSocket === ws) {
|
||||||
|
ws.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Convenience: submit a user prompt to an existing session. */
|
/** Convenience: submit a user prompt to an existing session. */
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
@@ -463,56 +577,96 @@ object HermesWsClient {
|
|||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||||
|
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
webSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl())
|
val generation = connectionGeneration.incrementAndGet()
|
||||||
|
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||||
|
webSocket = newSocket
|
||||||
|
} else {
|
||||||
|
newSocket.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
if (intentionalClose.get()) return
|
synchronized(outboundLock) {
|
||||||
if (!AuthManager.isAutoReconnect()) {
|
if (intentionalClose.get() || reconnectJob?.isActive == true) return
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
if (!AuthManager.isAutoReconnect()) {
|
||||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||||
return
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
}
|
return
|
||||||
if (!NetworkMonitor.isConnected.value) {
|
|
||||||
Log.d(TAG, "No network available — delaying reconnect scheduling")
|
|
||||||
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val delay = currentBackoff
|
|
||||||
currentBackoff =
|
|
||||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
|
||||||
.toLong()
|
|
||||||
.coerceAtMost(MAX_BACKOFF_MS)
|
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${delay}ms …")
|
|
||||||
|
|
||||||
reconnectJob?.cancel()
|
|
||||||
reconnectJob =
|
|
||||||
wsScope.launch {
|
|
||||||
delay(delay)
|
|
||||||
if (!intentionalClose.get() && !connected.get()) {
|
|
||||||
openSocket()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!NetworkMonitor.isConnected.value) {
|
||||||
|
Log.d(TAG, "No network available — delaying reconnect scheduling")
|
||||||
|
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val reconnectDelay = currentBackoff
|
||||||
|
currentBackoff =
|
||||||
|
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||||
|
.toLong()
|
||||||
|
.coerceAtMost(MAX_BACKOFF_MS)
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …")
|
||||||
|
|
||||||
|
reconnectJob =
|
||||||
|
wsScope.launch {
|
||||||
|
delay(reconnectDelay)
|
||||||
|
val owner = currentCoroutineContext()[Job]
|
||||||
|
val shouldOpen =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (reconnectJob !== owner) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
reconnectJob = null
|
||||||
|
!intentionalClose.get() && !connected.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldOpen) openSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Listener ─────────────────────────────────────────────────────────
|
// ── Listener ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private class WsListenerImpl : WebSocketListener() {
|
private class WsListenerImpl(
|
||||||
|
private val generation: Int,
|
||||||
|
) : WebSocketListener() {
|
||||||
|
private fun isCurrent(): Boolean = connectionGeneration.get() == generation && !intentionalClose.get()
|
||||||
|
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
response: Response,
|
response: Response,
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "WebSocket opened")
|
synchronized(outboundLock) {
|
||||||
connected.set(true)
|
if (!isCurrent()) {
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
webSocket.close(1000, "Superseded")
|
||||||
currentBackoff = INITIAL_BACKOFF_MS
|
return
|
||||||
startHealthTracking()
|
}
|
||||||
|
HermesWsClient.webSocket = webSocket
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
Log.i(TAG, "WebSocket opened")
|
||||||
|
connected.set(true)
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
|
currentBackoff = INITIAL_BACKOFF_MS
|
||||||
|
startHealthTracking()
|
||||||
|
|
||||||
while (messageQueue.isNotEmpty()) {
|
while (true) {
|
||||||
val msg = messageQueue.poll()
|
val msg = messageQueue.peek() ?: break
|
||||||
if (msg != null) {
|
if (!isRetryableMessage(msg)) {
|
||||||
|
Log.w(TAG, "Dropping oversized queued message")
|
||||||
|
messageQueue.poll()
|
||||||
|
markQueuedMessageSent(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
||||||
webSocket.send(msg)
|
if (!webSocket.send(msg)) {
|
||||||
|
recoverRejectedSocket(webSocket)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messageQueue.poll()
|
||||||
|
markQueuedMessageSent(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,6 +675,7 @@ object HermesWsClient {
|
|||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
text: String,
|
text: String,
|
||||||
) {
|
) {
|
||||||
|
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
||||||
lastPongTimestamp = System.currentTimeMillis()
|
lastPongTimestamp = System.currentTimeMillis()
|
||||||
// Resolve any in-flight `request()` awaiting this RPC result/error
|
// Resolve any in-flight `request()` awaiting this RPC result/error
|
||||||
@@ -549,9 +704,22 @@ object HermesWsClient {
|
|||||||
code: Int,
|
code: Int,
|
||||||
reason: String,
|
reason: String,
|
||||||
) {
|
) {
|
||||||
// Do NOT log [reason] — it may carry server-side context.
|
synchronized(outboundLock) {
|
||||||
Log.d(TAG, "WebSocket closing: $code")
|
if (!isCurrent()) {
|
||||||
webSocket.close(code, reason)
|
webSocket.close(code, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closingSocket = webSocket
|
||||||
|
// Do NOT log [reason] — it may carry server-side context.
|
||||||
|
Log.d(TAG, "WebSocket closing: $code")
|
||||||
|
if (code == 4001 || code == 4401 ||
|
||||||
|
reason.contains("unauthorized", ignoreCase = true) ||
|
||||||
|
reason.startsWith("auth:", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
}
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(
|
override fun onClosed(
|
||||||
@@ -559,19 +727,27 @@ object HermesWsClient {
|
|||||||
code: Int,
|
code: Int,
|
||||||
reason: String,
|
reason: String,
|
||||||
) {
|
) {
|
||||||
// Do NOT log [reason] — it may carry server-side context. The
|
synchronized(outboundLock) {
|
||||||
// reason is still inspected internally to detect auth failures.
|
if (!isCurrent()) return
|
||||||
Log.i(TAG, "WebSocket closed: $code")
|
connectionGeneration.incrementAndGet()
|
||||||
connected.set(false)
|
closingSocket = null
|
||||||
stopHealthTracking()
|
outboundDrainJob?.cancel()
|
||||||
if (code == 4001 || code == 4401 ||
|
outboundDrainJob = null
|
||||||
reason.contains("unauthorized", ignoreCase = true) ||
|
if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null
|
||||||
reason.startsWith("auth:", ignoreCase = true)
|
// Do NOT log [reason] — it may carry server-side context. The
|
||||||
) {
|
// reason is still inspected internally to detect auth failures.
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
Log.i(TAG, "WebSocket closed: $code")
|
||||||
} else {
|
connected.set(false)
|
||||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
stopHealthTracking()
|
||||||
scheduleReconnect()
|
if (code == 4001 || code == 4401 ||
|
||||||
|
reason.contains("unauthorized", ignoreCase = true) ||
|
||||||
|
reason.startsWith("auth:", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
} else {
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,21 +756,29 @@ object HermesWsClient {
|
|||||||
t: Throwable,
|
t: Throwable,
|
||||||
response: Response?,
|
response: Response?,
|
||||||
) {
|
) {
|
||||||
// Log the exception class only — [Throwable.message] can leak URLs
|
synchronized(outboundLock) {
|
||||||
// or headers. The message is still inspected internally for auth
|
if (!isCurrent()) return
|
||||||
// detection.
|
connectionGeneration.incrementAndGet()
|
||||||
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
closingSocket = null
|
||||||
connected.set(false)
|
outboundDrainJob?.cancel()
|
||||||
stopHealthTracking()
|
outboundDrainJob = null
|
||||||
val code = response?.code ?: 0
|
if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null
|
||||||
if (code == 401 || t.message?.contains(
|
// Log the exception class only — [Throwable.message] can leak URLs
|
||||||
"401",
|
// or headers. The message is still inspected internally for auth
|
||||||
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
|
// detection.
|
||||||
) {
|
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
connected.set(false)
|
||||||
} else {
|
stopHealthTracking()
|
||||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
val code = response?.code ?: 0
|
||||||
scheduleReconnect()
|
if (code == 401 || t.message?.contains(
|
||||||
|
"401",
|
||||||
|
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||||
|
) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
} else {
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,7 @@ fun ChatConnectionBanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionStatus.RECONNECTING,
|
ConnectionStatus.AUTH_EXPIRED -> {
|
||||||
ConnectionStatus.AUTH_EXPIRED,
|
|
||||||
-> {
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onReloginClick,
|
onClick = onReloginClick,
|
||||||
colors =
|
colors =
|
||||||
@@ -135,6 +133,8 @@ fun ChatConnectionBanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ConnectionStatus.RECONNECTING -> {}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ class SettingsViewModel(
|
|||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
AuthManager.setSessionCookie(null)
|
AuthManager.setSessionCookie(null)
|
||||||
AuthManager.setWsAuthParam("token")
|
AuthManager.setWsAuthParam("token")
|
||||||
HermesWsClient.disconnect()
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
// Don't rebuild ApiClient here — let the navigation complete first
|
// Don't rebuild ApiClient here — let the navigation complete first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.util.Log
|
|||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||||
import com.m57.hermescontrol.data.remote.CookieManager
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -11,6 +12,7 @@ import io.mockk.mockk
|
|||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -24,9 +26,11 @@ import org.junit.After
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -72,12 +76,20 @@ class HermesWsClientTest {
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.DISCONNECTED
|
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.DISCONNECTED
|
||||||
|
|
||||||
HermesWsClient.disconnect() // Ensure it starts clean
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
|
||||||
|
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||||
|
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
||||||
|
acceptQueuedMessagesField.isAccessible = true
|
||||||
|
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
HermesWsClient.disconnect()
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
||||||
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
||||||
Thread.sleep(500)
|
Thread.sleep(500)
|
||||||
@@ -136,6 +148,395 @@ class HermesWsClientTest {
|
|||||||
assertTrue(msg.contains(id))
|
assertTrue(msg.contains(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFailedSocketSendQueuesMessageForReconnect() {
|
||||||
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { staleSocket.send(any<String>()) } returns false
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, staleSocket)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||||
|
statusField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.CONNECTED
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
assertFalse(HermesWsClient.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
|
assertEquals(1, queue.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReconnectSchedulingIsIdempotentWhilePending() {
|
||||||
|
mockkObject(NetworkMonitor)
|
||||||
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
|
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||||
|
backoffField.isAccessible = true
|
||||||
|
backoffField.setLong(HermesWsClient, 1_000L)
|
||||||
|
|
||||||
|
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||||
|
reconnectMethod.isAccessible = true
|
||||||
|
reconnectMethod.invoke(HermesWsClient)
|
||||||
|
reconnectMethod.invoke(HermesWsClient)
|
||||||
|
|
||||||
|
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompletedReconnectDoesNotClearReplacementJob() {
|
||||||
|
mockkObject(NetworkMonitor)
|
||||||
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
|
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||||
|
backoffField.isAccessible = true
|
||||||
|
backoffField.setLong(HermesWsClient, 0L)
|
||||||
|
|
||||||
|
val reconnectJobField = HermesWsClient::class.java.getDeclaredField("reconnectJob")
|
||||||
|
reconnectJobField.isAccessible = true
|
||||||
|
val replacementJob = mockk<Job>(relaxed = true)
|
||||||
|
|
||||||
|
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock")
|
||||||
|
lockField.isAccessible = true
|
||||||
|
val lock = lockField.get(HermesWsClient)
|
||||||
|
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||||
|
reconnectMethod.isAccessible = true
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
reconnectMethod.invoke(HermesWsClient)
|
||||||
|
reconnectJobField.set(HermesWsClient, replacementJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.sleep(100)
|
||||||
|
assertSame(replacementJob, reconnectJobField.get(HermesWsClient))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOversizedRejectedMessageIsNotQueued() {
|
||||||
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { staleSocket.send(any<String>()) } returns false
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, staleSocket)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
|
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
io.mockk.verify(exactly = 0) { staleSocket.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDisconnectedOversizedMessageIsNotQueued() {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testQueuePressureDoesNotCancelAcceptedFrames() {
|
||||||
|
val pressuredSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { pressuredSocket.send(any<String>()) } returns false
|
||||||
|
every { pressuredSocket.queueSize() } returns 1024L
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, pressuredSocket)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
|
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||||
|
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||||
|
drainJobField.isAccessible = true
|
||||||
|
assertNotNull(drainJobField.get(HermesWsClient))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFailureDuringOutboundDrainSchedulesReconnect() {
|
||||||
|
mockkObject(NetworkMonitor)
|
||||||
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
|
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||||
|
|
||||||
|
val pressuredSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { pressuredSocket.send(any<String>()) } returns false
|
||||||
|
every { pressuredSocket.queueSize() } returns 1024L
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, pressuredSocket)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||||
|
generationField.isAccessible = true
|
||||||
|
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||||
|
|
||||||
|
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||||
|
backoffField.isAccessible = true
|
||||||
|
backoffField.setLong(HermesWsClient, 1_000L)
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
|
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||||
|
val constructor = listenerClass.declaredConstructors.single()
|
||||||
|
constructor.isAccessible = true
|
||||||
|
val listener = constructor.newInstance(1) as WebSocketListener
|
||||||
|
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
|
||||||
|
|
||||||
|
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testReplayPressureSchedulesRecoveryWithoutDroppingHead() {
|
||||||
|
val pressuredSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
|
||||||
|
every { pressuredSocket.queueSize() } returns 1024L
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||||
|
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||||
|
generationField.isAccessible = true
|
||||||
|
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, pressuredSocket)
|
||||||
|
|
||||||
|
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||||
|
val constructor = listenerClass.declaredConstructors.single()
|
||||||
|
constructor.isAccessible = true
|
||||||
|
val listener = constructor.newInstance(1) as WebSocketListener
|
||||||
|
listener.onOpen(pressuredSocket, mockk(relaxed = true))
|
||||||
|
|
||||||
|
assertFalse(HermesWsClient.isConnected)
|
||||||
|
assertEquals(1, queue.size)
|
||||||
|
assertTrue(queue.peek()?.contains("\"2\"") == true)
|
||||||
|
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||||
|
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||||
|
drainJobField.isAccessible = true
|
||||||
|
assertNotNull(drainJobField.get(HermesWsClient))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStaleOnOpenDoesNotReplayQueue() {
|
||||||
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { currentSocket.send(any<String>()) } returns true
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, currentSocket)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||||
|
generationField.isAccessible = true
|
||||||
|
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(2)
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||||
|
|
||||||
|
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||||
|
val constructor = listenerClass.declaredConstructors.single()
|
||||||
|
constructor.isAccessible = true
|
||||||
|
val staleListener = constructor.newInstance(1) as WebSocketListener
|
||||||
|
val currentListener = constructor.newInstance(2) as WebSocketListener
|
||||||
|
val response = mockk<okhttp3.Response>(relaxed = true)
|
||||||
|
|
||||||
|
staleListener.onOpen(staleSocket, response)
|
||||||
|
currentListener.onOpen(currentSocket, response)
|
||||||
|
|
||||||
|
io.mockk.verify(exactly = 0) { staleSocket.send(any<String>()) }
|
||||||
|
io.mockk.verify(exactly = 1) { currentSocket.send(any<String>()) }
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRejectedSendAfterDisconnectIsNotQueued() {
|
||||||
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { staleSocket.send(any<String>()) } answers {
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, staleSocket)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
assertEquals(1, queue.size)
|
||||||
|
|
||||||
|
HermesWsClient.disconnect()
|
||||||
|
assertEquals(1, queue.size)
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "during reconnect"))
|
||||||
|
assertEquals(2, queue.size)
|
||||||
|
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "after logout"))
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||||
|
|
||||||
|
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||||
|
queueField.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||||
|
assertEquals(1, queue.size)
|
||||||
|
|
||||||
|
HermesWsClient.rejectAllPending()
|
||||||
|
|
||||||
|
assertTrue(deferred.isCompleted)
|
||||||
|
assertTrue(queue.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAuthClosingCodeSurvivesRejectedSendRecovery() {
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send(any<String>()) } returns false
|
||||||
|
every { socket.queueSize() } returns 0L
|
||||||
|
|
||||||
|
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||||
|
socketField.isAccessible = true
|
||||||
|
socketField.set(HermesWsClient, socket)
|
||||||
|
|
||||||
|
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||||
|
connectedField.isAccessible = true
|
||||||
|
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||||
|
|
||||||
|
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||||
|
intentionalCloseField.isAccessible = true
|
||||||
|
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||||
|
|
||||||
|
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||||
|
generationField.isAccessible = true
|
||||||
|
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||||
|
|
||||||
|
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||||
|
val constructor = listenerClass.declaredConstructors.single()
|
||||||
|
constructor.isAccessible = true
|
||||||
|
val listener = constructor.newInstance(1) as WebSocketListener
|
||||||
|
|
||||||
|
listener.onClosing(socket, 4401, "expired")
|
||||||
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
|
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
|
||||||
|
io.mockk.verify(exactly = 0) { socket.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testReceiveMessage() {
|
fun testReceiveMessage() {
|
||||||
var serverWebSocket: WebSocket? = null
|
var serverWebSocket: WebSocket? = null
|
||||||
@@ -211,7 +612,7 @@ class HermesWsClientTest {
|
|||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
HermesWsClient.disconnect()
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
assertFalse(HermesWsClient.isConnected)
|
assertFalse(HermesWsClient.isConnected)
|
||||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
|
|
||||||
@@ -378,7 +779,7 @@ class HermesWsClientTest {
|
|||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
// Disconnect — this sets intentionalClose = true and cancels reconnect
|
// Disconnect — this sets intentionalClose = true and cancels reconnect
|
||||||
HermesWsClient.disconnect()
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
|
||||||
assertFalse(HermesWsClient.isConnected)
|
assertFalse(HermesWsClient.isConnected)
|
||||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
@@ -482,7 +883,7 @@ class HermesWsClientTest {
|
|||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
// Disconnect (sets intentionalClose) — after this, reconnect should be prevented
|
// Disconnect (sets intentionalClose) — after this, reconnect should be prevented
|
||||||
HermesWsClient.disconnect()
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
assertFalse(HermesWsClient.isConnected)
|
assertFalse(HermesWsClient.isConnected)
|
||||||
}
|
}
|
||||||
@@ -557,4 +958,54 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
ticketServer.shutdown()
|
ticketServer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGatedMode_permanentTicketFailureStopsRetrying() {
|
||||||
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
|
every { AuthManager.serverStore } returns
|
||||||
|
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||||
|
every { it.getLatestState() } returns
|
||||||
|
com.m57.hermescontrol.data.config
|
||||||
|
.ServerStoreState(wsAuthParam = "ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
val ticketServer = MockWebServer()
|
||||||
|
ticketServer.start()
|
||||||
|
ticketServer.enqueue(MockResponse().setResponseCode(400))
|
||||||
|
every { AuthManager.endpointForBuild() } returns
|
||||||
|
ServerEndpoint.parse(
|
||||||
|
ticketServer.url("/").toString(),
|
||||||
|
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.connect()
|
||||||
|
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
|
ticketServer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGatedMode_transientTicketFailureKeepsReconnectFlow() {
|
||||||
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
|
every { AuthManager.serverStore } returns
|
||||||
|
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||||
|
every { it.getLatestState() } returns
|
||||||
|
com.m57.hermescontrol.data.config
|
||||||
|
.ServerStoreState(wsAuthParam = "ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
val unavailableTicketServer = MockWebServer()
|
||||||
|
unavailableTicketServer.start()
|
||||||
|
val unavailableEndpoint = unavailableTicketServer.url("/").toString()
|
||||||
|
unavailableTicketServer.shutdown()
|
||||||
|
every { AuthManager.endpointForBuild() } returns
|
||||||
|
ServerEndpoint.parse(
|
||||||
|
unavailableEndpoint,
|
||||||
|
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.connect()
|
||||||
|
|
||||||
|
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user